@fmsim/machine 2.0.0-beta.10 → 2.0.0-beta.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/agv.js +6 -2
  2. package/dist/agv.js.map +1 -1
  3. package/dist/carrier-manager.js +183 -0
  4. package/dist/carrier-manager.js.map +1 -0
  5. package/dist/carrier.js +99 -9
  6. package/dist/carrier.js.map +1 -1
  7. package/dist/conveyor-join.js +8 -2
  8. package/dist/conveyor-join.js.map +1 -1
  9. package/dist/conveyor.js +8 -0
  10. package/dist/conveyor.js.map +1 -1
  11. package/dist/crane.js +144 -1
  12. package/dist/crane.js.map +1 -1
  13. package/dist/factories/agv-3d.js +17 -36
  14. package/dist/factories/agv-3d.js.map +1 -1
  15. package/dist/factories/conveyor-3d.js +179 -100
  16. package/dist/factories/conveyor-3d.js.map +1 -1
  17. package/dist/factories/crane-3d.js +56 -29
  18. package/dist/factories/crane-3d.js.map +1 -1
  19. package/dist/factories/crane-rail-3d.js +14 -18
  20. package/dist/factories/crane-rail-3d.js.map +1 -1
  21. package/dist/factories/machine-3d.js +533 -197
  22. package/dist/factories/machine-3d.js.map +1 -1
  23. package/dist/factories/oht-3d.js +29 -47
  24. package/dist/factories/oht-3d.js.map +1 -1
  25. package/dist/factories/shuttle-3d.js +13 -32
  26. package/dist/factories/shuttle-3d.js.map +1 -1
  27. package/dist/factories/z-priority-defaults.js +33 -0
  28. package/dist/factories/z-priority-defaults.js.map +1 -0
  29. package/dist/factories/z-priority.js +54 -0
  30. package/dist/factories/z-priority.js.map +1 -0
  31. package/dist/features/conveyor-mixin.js.map +1 -1
  32. package/dist/features/mcs-status-default.js +15 -1
  33. package/dist/features/mcs-status-default.js.map +1 -1
  34. package/dist/features/mcs-status-mixin.js +22 -0
  35. package/dist/features/mcs-status-mixin.js.map +1 -1
  36. package/dist/features/mcs-zone-mixin.js.map +1 -1
  37. package/dist/features/parent-object-mixin.js +15 -7
  38. package/dist/features/parent-object-mixin.js.map +1 -1
  39. package/dist/floating-container.js +5 -0
  40. package/dist/floating-container.js.map +1 -1
  41. package/dist/index.js +52 -34
  42. package/dist/index.js.map +1 -1
  43. package/dist/mcs-carrier-holder.js +56 -22
  44. package/dist/mcs-carrier-holder.js.map +1 -1
  45. package/dist/mcs-machine.js +20 -0
  46. package/dist/mcs-machine.js.map +1 -1
  47. package/dist/mcs-unit.js +17 -0
  48. package/dist/mcs-unit.js.map +1 -1
  49. package/dist/mcs-vehicle.js +8 -3
  50. package/dist/mcs-vehicle.js.map +1 -1
  51. package/dist/node.js +10 -3
  52. package/dist/node.js.map +1 -1
  53. package/dist/oht.js +4 -0
  54. package/dist/oht.js.map +1 -1
  55. package/dist/port-flow.js +4 -0
  56. package/dist/port-flow.js.map +1 -1
  57. package/dist/port.js +4 -0
  58. package/dist/port.js.map +1 -1
  59. package/dist/scanner.js +30 -0
  60. package/dist/scanner.js.map +1 -0
  61. package/dist/scene/root-container-override.js +160 -96
  62. package/dist/scene/root-container-override.js.map +1 -1
  63. package/dist/shelf.js +4 -0
  64. package/dist/shelf.js.map +1 -1
  65. package/dist/static-container.js +5 -0
  66. package/dist/static-container.js.map +1 -1
  67. package/dist/stocker-abnormal-bar.js +5 -1
  68. package/dist/stocker-abnormal-bar.js.map +1 -1
  69. package/dist/stocker-capacity-bar.js +253 -6
  70. package/dist/stocker-capacity-bar.js.map +1 -1
  71. package/dist/stocker-carrier-bar.js +5 -1
  72. package/dist/stocker-carrier-bar.js.map +1 -1
  73. package/dist/stocker.js +6 -0
  74. package/dist/stocker.js.map +1 -1
  75. package/dist/templates/backdrop.js +30 -0
  76. package/dist/templates/backdrop.js.map +1 -0
  77. package/dist/templates/decal.js +21 -0
  78. package/dist/templates/decal.js.map +1 -0
  79. package/dist/templates/index.js +7 -1
  80. package/dist/templates/index.js.map +1 -1
  81. package/dist/templates/scanner.js +20 -0
  82. package/dist/templates/scanner.js.map +1 -0
  83. package/dist/tsconfig.tsbuildinfo +1 -1
  84. package/dist/utils/crane-position.js +83 -0
  85. package/dist/utils/crane-position.js.map +1 -0
  86. package/dist/utils/data-value.js +21 -0
  87. package/dist/utils/data-value.js.map +1 -0
  88. package/dist/utils/fit-gauge-scale.js +28 -0
  89. package/dist/utils/fit-gauge-scale.js.map +1 -0
  90. package/dist/utils/get-value-on-ranges.js +2 -4
  91. package/dist/utils/get-value-on-ranges.js.map +1 -1
  92. package/dist/utils/stocker-gauge.js +78 -0
  93. package/dist/utils/stocker-gauge.js.map +1 -0
  94. package/dist/zone-capacity-bar.js +3 -3
  95. package/dist/zone-capacity-bar.js.map +1 -1
  96. package/icons/backdrop.png +0 -0
  97. package/icons/decal.png +0 -0
  98. package/jest.config.js +10 -0
  99. package/package.json +4 -3
  100. package/src/agv.ts +7 -2
  101. package/src/carrier-manager.ts +223 -0
  102. package/src/carrier.ts +103 -10
  103. package/src/conveyor-join.ts +5 -2
  104. package/src/conveyor.ts +8 -0
  105. package/src/crane.ts +181 -1
  106. package/src/factories/agv-3d.ts +19 -36
  107. package/src/factories/conveyor-3d.ts +183 -103
  108. package/src/factories/crane-3d.ts +62 -29
  109. package/src/factories/crane-rail-3d.ts +17 -20
  110. package/src/factories/machine-3d.ts +631 -205
  111. package/src/factories/oht-3d.ts +30 -46
  112. package/src/factories/shuttle-3d.ts +14 -32
  113. package/src/factories/z-priority-defaults.ts +38 -0
  114. package/src/factories/z-priority.ts +60 -0
  115. package/src/features/conveyor-mixin.ts +1 -1
  116. package/src/features/mcs-status-default.ts +15 -1
  117. package/src/features/mcs-status-mixin.ts +26 -4
  118. package/src/features/mcs-zone-mixin.ts +1 -1
  119. package/src/features/parent-object-mixin.ts +17 -8
  120. package/src/floating-container.ts +5 -0
  121. package/src/index.ts +58 -34
  122. package/src/mcs-carrier-holder.ts +57 -34
  123. package/src/mcs-machine.ts +23 -0
  124. package/src/mcs-unit.ts +21 -1
  125. package/src/mcs-vehicle.ts +9 -6
  126. package/src/node.ts +9 -3
  127. package/src/oht.ts +6 -1
  128. package/src/port-flow.ts +5 -0
  129. package/src/port.ts +5 -0
  130. package/src/scanner.ts +29 -0
  131. package/src/scene/root-container-override.ts +154 -103
  132. package/src/shelf.ts +5 -0
  133. package/src/static-container.ts +5 -0
  134. package/src/stocker-abnormal-bar.ts +6 -1
  135. package/src/stocker-capacity-bar.ts +298 -7
  136. package/src/stocker-carrier-bar.ts +6 -1
  137. package/src/stocker.ts +7 -0
  138. package/src/templates/backdrop.ts +30 -0
  139. package/src/templates/decal.ts +21 -0
  140. package/src/templates/index.ts +7 -1
  141. package/src/templates/scanner.ts +20 -0
  142. package/src/utils/crane-position.ts +105 -0
  143. package/src/utils/data-value.ts +19 -0
  144. package/src/utils/fit-gauge-scale.ts +35 -0
  145. package/src/utils/get-value-on-ranges.ts +2 -4
  146. package/src/utils/stocker-gauge.ts +111 -0
  147. package/src/zone-capacity-bar.ts +3 -3
  148. package/translations/en.json +26 -1
  149. package/translations/ja.json +26 -1
  150. package/translations/ko.json +26 -1
  151. package/translations/ms.json +26 -1
  152. package/translations/zh.json +26 -1
@@ -9,10 +9,19 @@
9
9
  */
10
10
 
11
11
  import * as THREE from 'three'
12
- import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
12
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
13
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D, MaterialBank } from '@hatiolab/things-scene'
13
14
  import type { Component } from '@hatiolab/things-scene'
14
- import { LEGEND_CAPACITY } from '../features/mcs-status-default'
15
- import { getVaueOnRanges } from '../utils/get-value-on-ranges'
15
+ import { LEGEND_CAPACITY, resolveCarrierLegend } from '../features/mcs-status-default'
16
+ import { getValueOnRanges } from '../utils/get-value-on-ranges'
17
+ import { fitGaugeScale } from '../utils/fit-gauge-scale'
18
+ import {
19
+ carrierSegmentsFromData,
20
+ abnormalSegmentsFromData,
21
+ carrierColorsFromLegend,
22
+ ABNORMAL_COLORS
23
+ } from '../utils/stocker-gauge'
24
+ import { Z_FMSIM } from './z-priority'
16
25
 
17
26
  // ── 설정 타입 ────────────────────────────────────────────
18
27
 
@@ -29,8 +38,8 @@ interface MCS3DConfig {
29
38
  roughness: number
30
39
  /** statusColor 미사용 시 기본 색상 */
31
40
  defaultColor: number
32
- /** 'box' | 'hexagon' */
33
- shape: 'box' | 'hexagon'
41
+ /** 'box' | 'hexagon' | 'plane' | 'triangle' */
42
+ shape: 'box' | 'hexagon' | 'plane' | 'triangle'
34
43
  /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
35
44
  isVisible?: (component: Component) => boolean
36
45
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
@@ -41,6 +50,14 @@ interface MCS3DConfig {
41
50
  matteTop?: boolean
42
51
  /** 상판에 2D 렌더링 텍스처 적용 */
43
52
  texturedTop?: boolean
53
+ /** 자체 발광 강도 (0~1). 조명 약한 면에서도 색상 유지 */
54
+ emissiveIntensity?: number
55
+ /** 바닥면 유지 (기본 false — 바닥면 제거) */
56
+ keepBottomFace?: boolean
57
+ /** syncZPosOffset 고정값 (설정 시 다른 로직 무시) */
58
+ zPosOverride?: number
59
+ /** z-priority — polygonOffset 으로 z-fight 완화. 기본 0 (OBJECTS). */
60
+ zPriority?: number
44
61
  }
45
62
 
46
63
  /**
@@ -69,36 +86,68 @@ class MCSRealObject3D extends RealObjectGroup {
69
86
  this._config = config
70
87
  }
71
88
 
72
- // width, height 절대값 (음수 값은 양수로 취급)
89
+ get zPriority(): number {
90
+ return this._config.zPriority ?? 0
91
+ }
92
+
93
+ // width, height 절대값 (음수 값은 양수로 취급).
94
+ // `resizable:false` 컴포넌트(예: Scanner)는 state.width/height 가 없고
95
+ // bounds 만 있을 수 있으므로 bounds 를 fallback 으로 사용.
96
+ //
97
+ // `??` only catches null/undefined — a property-panel input that produced
98
+ // NaN would slip through and corrupt every downstream Math.* call. Use a
99
+ // finite check so NaN falls through to bounds → 10 like missing values do.
73
100
  protected get absSize(): { w: number; h: number } {
74
- const { width = 10, height = 10 } = this.component.state
101
+ const state = (this.component.state || {}) as any
102
+ const bounds = (this.component.bounds || {}) as any
103
+ const width = pickFinite(state.width, bounds.width, 10)
104
+ const height = pickFinite(state.height, bounds.height, 10)
75
105
  return { w: Math.abs(width), h: Math.abs(height) }
76
106
  }
77
107
 
78
108
  get effectiveDepth(): number {
79
109
  const { depth } = this.component.state
80
- if (depth) return depth
110
+ // Truthy `if (depth)` already rejected NaN/0/undefined, but be explicit
111
+ // so the intent (and the resolution chain) reads cleanly: only an
112
+ // explicit positive finite number wins; everything else → defaults.
113
+ if (typeof depth === 'number' && Number.isFinite(depth) && depth > 0) {
114
+ return depth
115
+ }
81
116
  if (this._config.defaultDepth) return this._config.defaultDepth
82
117
  const { w, h } = this.absSize
83
118
  return Math.min(w, h) * this._config.depthRatio
84
119
  }
85
120
 
86
121
  protected get syncZPosOffset(): number {
122
+ // syncZPosOffset = placement + geometric (center origin = effectiveDepth/2)
123
+ // 중첩 보정(parent shift 상쇄)은 base RealObject.cz가 parent.geometricOffsetY를
124
+ // 빼는 방식으로 처리하므로 여기선 placement + 본인 geometric만 반환
125
+ const halfD = this.effectiveDepth / 2
126
+ // zPosOverride: 고정 높이 (OHTLine 등)
127
+ if (this._config.zPosOverride != null) {
128
+ return this._config.zPosOverride + halfD
129
+ }
87
130
  // Port: 이송 표면 높이에 배치
88
131
  if (this._config.defaultZPosFromParent) {
89
- return TRANSPORT_SURFACE_HEIGHT
132
+ return TRANSPORT_SURFACE_HEIGHT + halfD
90
133
  }
91
- // Carrier: Port 안이면 중앙, 그 외 컨테이너면 상단 위
134
+ // Carrier: 부모 유형에 따라 배치 위치 결정
92
135
  if (this._config.sizeRatio && this._config.sizeRatio < 1) {
93
136
  const parentType = this.component.parent?.state?.type
94
- if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'OHT' || parentType === 'Shuttle') {
95
- return 0
137
+ // OHT: 그리퍼 아래에 매달림 — placement = -effectiveDepth(carrier top이 OHT bottom)
138
+ if (parentType === 'OHT') {
139
+ return -this.effectiveDepth + halfD
140
+ }
141
+ if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'Shuttle') {
142
+ return halfD
96
143
  }
97
- return TRANSPORT_SURFACE_HEIGHT
144
+ return TRANSPORT_SURFACE_HEIGHT + halfD
98
145
  }
99
- return 0
146
+ return halfD
100
147
  }
101
148
 
149
+ // geometric 부분(center origin: depth/2)은 base 기본값 사용 — override 불필요
150
+
102
151
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
103
152
  protected resolveColor(): number {
104
153
  const comp = this.component as any
@@ -107,14 +156,14 @@ class MCSRealObject3D extends RealObjectGroup {
107
156
  // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
108
157
  try {
109
158
  const sc = comp.statusColor
110
- if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0') return new THREE.Color(sc).getHex()
159
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex()
111
160
  } catch {
112
161
  /* statusColor 미구현 시 무시 */
113
162
  }
114
163
 
115
164
  // 2) state.fillStyle fallback
116
165
  const { fillStyle } = this.component.state
117
- if (fillStyle && typeof fillStyle === 'string') {
166
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
118
167
  try {
119
168
  return new THREE.Color(fillStyle).getHex()
120
169
  } catch {
@@ -163,7 +212,7 @@ class MCSRealObject3D extends RealObjectGroup {
163
212
 
164
213
  private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
165
214
  const geo = new THREE.BoxGeometry(w, d, h)
166
- return this.removeBottomFace(geo)
215
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
167
216
  }
168
217
 
169
218
  private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
@@ -194,6 +243,43 @@ class MCSRealObject3D extends RealObjectGroup {
194
243
  return this.removeBottomFace(geo)
195
244
  }
196
245
 
246
+ private buildPlaneGeometry(w: number, h: number): THREE.BufferGeometry {
247
+ const geo = new THREE.PlaneGeometry(w, h)
248
+ geo.rotateX(-Math.PI / 2)
249
+ return geo
250
+ }
251
+
252
+ /**
253
+ * 삼각형 prism — 2D Triangle 의 꼭짓점(state.x1/y1, x2/y2, x3/y3) 을 그대로 사용.
254
+ * things-scene 의 TriangleExtrude 와 동일한 방식 — 각 Scanner 인스턴스의
255
+ * 실제 정점 배치(방향, 비대칭 등) 가 3D 에 반영된다.
256
+ *
257
+ * `geometry.center()` 로 로컬 원점을 bounding-box 중심으로 맞추므로
258
+ * 후속 placement 와 정합.
259
+ */
260
+ private buildTriangleGeometry(d: number): THREE.BufferGeometry {
261
+ const state = (this.component.state || {}) as any
262
+ const { x1 = 0, y1 = 0, x2 = 0, y2 = 0, x3 = 0, y3 = 0 } = state
263
+
264
+ const shape = new THREE.Shape()
265
+ shape.moveTo(x1, y1)
266
+ shape.lineTo(x2, y2)
267
+ shape.lineTo(x3, y3)
268
+ shape.closePath()
269
+
270
+ const geo = new THREE.ExtrudeGeometry(shape, {
271
+ steps: 1,
272
+ depth: d,
273
+ bevelEnabled: false,
274
+ })
275
+
276
+ // bounding box 중심으로 원점 정렬 후 Y-up 좌표계로 회전
277
+ geo.center()
278
+ geo.rotateX(Math.PI / 2)
279
+
280
+ return this.removeBottomFace(geo)
281
+ }
282
+
197
283
  // ── Build ──
198
284
 
199
285
  protected _ownMesh?: THREE.Mesh
@@ -217,20 +303,39 @@ class MCSRealObject3D extends RealObjectGroup {
217
303
  const { opacity, metalness, roughness, shape } = this._config
218
304
 
219
305
  const geometry =
220
- shape === 'hexagon'
221
- ? this.buildHexagonGeometry(w, depth, h)
222
- : this.buildBoxGeometry(w, depth, h)
223
-
224
- const material = new THREE.MeshStandardMaterial({
225
- color,
226
- metalness,
227
- roughness,
228
- opacity,
229
- transparent: opacity < 1,
230
- side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
231
- depthWrite: opacity >= 0.9,
232
- })
233
- // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
306
+ shape === 'plane'
307
+ ? this.buildPlaneGeometry(w, h)
308
+ : shape === 'hexagon'
309
+ ? this.buildHexagonGeometry(w, depth, h)
310
+ : shape === 'triangle'
311
+ ? this.buildTriangleGeometry(depth)
312
+ : this.buildBoxGeometry(w, depth, h)
313
+
314
+ const isPlane = shape === 'plane'
315
+ // Standard body 는 MaterialBank 공유(색 변형) — 인스턴스 dedup + faint 안전(코어가 공유 base 의
316
+ // 변형만 스왑, 원본 base 불변). plane 은 unlit MeshBasicMaterial 이라 bank(Standard 전용) 비대상 → raw.
317
+ const material: THREE.Material = isPlane
318
+ ? new THREE.MeshBasicMaterial({
319
+ color,
320
+ opacity,
321
+ transparent: opacity < 1,
322
+ side: THREE.DoubleSide,
323
+ })
324
+ : MaterialBank.material(
325
+ 'mcs|body',
326
+ {
327
+ metalness,
328
+ roughness,
329
+ opacity,
330
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
331
+ ...(this._config.emissiveIntensity
332
+ ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity }
333
+ : {}),
334
+ },
335
+ color,
336
+ )
337
+ // z-fighting 방지: 바닥면(floor grid)보다 앞. bank body 는 공유 base 라 polygonOffset 은 모든 mcs
338
+ // body 공통 상수 — 반환 material 에 세팅(같은 값이라 멱등, 인스턴스별 발산 없음).
234
339
  material.polygonOffset = true
235
340
  material.polygonOffsetFactor = -1
236
341
  material.polygonOffsetUnit = -1
@@ -254,11 +359,19 @@ class MCSRealObject3D extends RealObjectGroup {
254
359
  }
255
360
 
256
361
  this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
257
- // sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), 아니면 자체 중심 (depth/2)
258
- this._ownMesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
259
- this._ownMesh.castShadow = true
362
+ // ownGroup 기준(bottom-up): mesh 바닥을 y=0에 맞추고 ownGroup이 -d/2로 shift해
363
+ // center-origin object3d에 정렬됨.
364
+ // box/hexagon (center-local geometry): y=rawDepth/2 (바닥을 0에 맞춤)
365
+ // plane: y=1 (바닥 살짝 띄움)
366
+ this._ownMesh.position.y = isPlane ? 1 : rawDepth / 2
367
+ this._ownMesh.castShadow = !isPlane
260
368
  this._ownMesh.receiveShadow = true
261
- this.object3d.add(this._ownMesh)
369
+ this.components3D.add(this._ownMesh)
370
+
371
+ // mesh 는 super.build() *이후* 생성되므로, 코어의 build-시점 faint 적용이 이 mesh 에 못 닿는다.
372
+ // mesh 생성 직후 명시 적용 — 코어 applyMaterialOpacity 가 content-alpha × faint 를 일괄 처리.
373
+ // view 모드에선 factor=1(=state.alpha 없음) → canonical(config.opacity) 그대로, 고객 화면 불변.
374
+ this.updateAlpha()
262
375
  }
263
376
 
264
377
  // ── Change handling ──
@@ -353,7 +466,7 @@ class MCSRealObject3D extends RealObjectGroup {
353
466
  update() {
354
467
  // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
355
468
  if (this._ownMesh) {
356
- this.object3d.remove(this._ownMesh)
469
+ this.components3D.remove(this._ownMesh)
357
470
  disposeObject3D(this._ownMesh)
358
471
  this._ownMesh = undefined
359
472
  }
@@ -367,22 +480,24 @@ class MCSRealObject3D extends RealObjectGroup {
367
480
  // RealObjectGroup 기본 동작 비활성화
368
481
  updateDimension() {}
369
482
 
370
- updateAlpha() {
371
- const { alpha = 1 } = this.component.state
372
- const configOpacity = this._config.opacity
373
- const effective = configOpacity * alpha
374
-
375
- this.object3d.traverse((o: any) => {
376
- if (!o.material) return
377
- const materials = Array.isArray(o.material) ? o.material : [o.material]
378
- for (const m of materials) {
379
- if (!m.visible) continue // openTop invisible face 무시
380
- if (m.map) continue // 텍스처 material은 자체 transparent 유지
381
- m.opacity = effective
382
- m.transparent = effective < 1
383
- }
384
- })
483
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴.
484
+ // 코어가 canonical(=build 시 config.opacity 로 만든 material) 위에 factor = state.alpha × faint 를
485
+ // 공유 변형 스왑으로 일괄 적용한다(원본 base 불변). 기존 in-place 변이는 (1) faint 미적용,
486
+ // (2) 코어 applyFocusDim 과 이중 적용 소지가 있어 방언이었다. config.opacity 는 canonical 로 보존.
487
+ }
488
+
489
+ // ── 헬퍼 ────────────────────────────────────────────────
490
+
491
+ /**
492
+ * Returns the first argument that is a finite number. NaN slips past `??`
493
+ * (which only catches null/undefined), so without this a property-panel
494
+ * input producing NaN would corrupt absSize and cascade through Math.*.
495
+ */
496
+ function pickFinite(...candidates: unknown[]): number {
497
+ for (const c of candidates) {
498
+ if (typeof c === 'number' && Number.isFinite(c)) return c
385
499
  }
500
+ return 0
386
501
  }
387
502
 
388
503
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
@@ -402,6 +517,7 @@ register('Equipment', {
402
517
  metalness: 0.4,
403
518
  roughness: 0.6,
404
519
  defaultColor: 0x8899aa,
520
+ zPriority: Z_FMSIM.EQUIPMENT,
405
521
  })
406
522
 
407
523
  register('Buffer', {
@@ -411,21 +527,113 @@ register('Buffer', {
411
527
  roughness: 0.7,
412
528
  defaultColor: 0x99aa88,
413
529
  shape: 'hexagon',
530
+ zPriority: Z_FMSIM.BUFFER,
414
531
  })
415
532
 
416
533
  register('AGVLine', {
417
- depthRatio: 0.12,
534
+ depthRatio: 0,
535
+ defaultDepth: 0.1,
418
536
  metalness: 0.6,
419
537
  roughness: 0.3,
420
538
  defaultColor: 0x888899,
539
+ zPriority: Z_FMSIM.AGV_LINE,
421
540
  })
422
541
 
423
- register('OHTLine', {
424
- depthRatio: 0.12,
425
- metalness: 0.6,
426
- roughness: 0.3,
427
- defaultColor: 0x888899,
428
- })
542
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
543
+ // OHTLine — 천장 I-beam 레일
544
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
545
+
546
+ const OHT_LINE_RAIL_COLOR = 0xcccccc
547
+ const OHT_LINE_HEIGHT = 7 * TRANSPORT_SURFACE_HEIGHT // OHT 상단과 일치
548
+
549
+ class OHTLineRealObject3D extends RealObjectGroup {
550
+ private _mesh?: THREE.Mesh
551
+
552
+ get zPriority(): number { return Z_FMSIM.OHT_LINE }
553
+ get effectiveDepth(): number { return 0 }
554
+ protected get syncZPosOffset(): number { return OHT_LINE_HEIGHT }
555
+
556
+ build() {
557
+ super.build()
558
+
559
+ const { width = 10, height = 10 } = this.component.state
560
+ const w = Math.abs(width)
561
+ const h = Math.abs(height)
562
+ if (!w || !h) return
563
+
564
+ // I-beam 치수: 이송 방향 = 긴 변, 폭은 2D의 1/2
565
+ const isVertical = w < h
566
+ const shortSide = isVertical ? w : h // 2D 원본 짧은 쪽 폭
567
+ const beltW = shortSide * 0.25 // I-beam 폭: 원본의 1/4
568
+ const beamH = Math.max(beltW * 0.5, 3) // I-beam 전체 높이
569
+ const inset = shortSide * 0.5 // 양끝 여백: 원본 폭의 1/2
570
+ const railLen = (isVertical ? h : w) - inset * 2
571
+ const flangeH = beamH * 0.2 // 플랜지 두께
572
+ const flangeW = beltW * 0.8 // 플랜지 폭
573
+ const webH = beamH - flangeH * 2 // 웹 높이
574
+ const webW = Math.max(flangeW * 0.25, 1) // 웹 두께
575
+
576
+ // 공유 base(MaterialBank) — OHTLine rail 은 정적 색이라 전 인스턴스 dedup. faint 는 코어가
577
+ // 공유 base 의 변형을 스왑(원본 불변). dispose 는 bank-safe(update 에서 material.dispose 안 함).
578
+ const mat = MaterialBank.material('oht-line|rail', { metalness: 0.5, roughness: 0.4 }, OHT_LINE_RAIL_COLOR)
579
+
580
+ const geoms: THREE.BufferGeometry[] = []
581
+
582
+ // 상부 플랜지
583
+ const topFlange = isVertical
584
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
585
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
586
+ topFlange.translate(0, -flangeH / 2, 0)
587
+ geoms.push(topFlange)
588
+
589
+ // 웹 (수직판)
590
+ const web = isVertical
591
+ ? new THREE.BoxGeometry(webW, webH, railLen)
592
+ : new THREE.BoxGeometry(railLen, webH, webW)
593
+ web.translate(0, -flangeH - webH / 2, 0)
594
+ geoms.push(web)
595
+
596
+ // 하부 플랜지
597
+ const botFlange = isVertical
598
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
599
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
600
+ botFlange.translate(0, -flangeH - webH - flangeH / 2, 0)
601
+ geoms.push(botFlange)
602
+
603
+ this._mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(geoms), mat)
604
+ this._mesh.castShadow = true
605
+ this.object3d.add(this._mesh)
606
+
607
+ // mesh 가 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게).
608
+ this.updateAlpha()
609
+ }
610
+
611
+ update() {
612
+ if (this._mesh) {
613
+ this.object3d.remove(this._mesh)
614
+ this._mesh.geometry.dispose()
615
+ // material 은 MaterialBank 공유(앱 수명) — 여기서 dispose 하면 다른 인스턴스가 깨진다.
616
+ this._mesh = undefined
617
+ }
618
+ this.build()
619
+ this.updateTransform()
620
+ this.updateHidden()
621
+ }
622
+
623
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
624
+ if ('width' in after || 'height' in after || 'depth' in after ||
625
+ 'fillStyle' in after || 'strokeStyle' in after) {
626
+ this.update()
627
+ return
628
+ }
629
+ super.onchange(after, before)
630
+ }
631
+
632
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint).
633
+ updateDimension() {}
634
+ }
635
+
636
+ registerRealObjectFactory('OHTLine', (component: Component) => new OHTLineRealObject3D(component))
429
637
 
430
638
  // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
431
639
  const STOCKER_CONFIG: MCS3DConfig = {
@@ -435,6 +643,7 @@ const STOCKER_CONFIG: MCS3DConfig = {
435
643
  metalness: 0.3,
436
644
  roughness: 0.6,
437
645
  defaultColor: 0x8899bb,
646
+ zPriority: Z_FMSIM.STOCKER,
438
647
  }
439
648
 
440
649
  class StockerRealObject3D extends MCSRealObject3D {
@@ -452,7 +661,7 @@ class StockerRealObject3D extends MCSRealObject3D {
452
661
  private buildGauge() {
453
662
  const comp = this.component as any
454
663
  const data = comp.data || {}
455
- const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
664
+ const gaugeMode = comp.root?.stockerGaugeMode || 'capacity'
456
665
 
457
666
  const gauge = this.resolveGauge(data, gaugeMode)
458
667
 
@@ -466,12 +675,20 @@ class StockerRealObject3D extends MCSRealObject3D {
466
675
  const depth = this.effectiveDepth
467
676
  const isWidthLonger = w >= h
468
677
 
469
- // 게이지 캔버스 (고해상도)
470
- const barW = 130
471
- const barH = 26
678
+ // 게이지 최대 크기 (스토커가 충분히 크면 이 크기 그대로 사용).
679
+ const MAX_BAR_W = 130
680
+ const MAX_BAR_H = 26
681
+
682
+ // 스토커 지붕 footprint 에 맞춰 비례 축소 (확대는 안 함 — 현재 크기가 상한).
683
+ const fit = fitGaugeScale(w, h, MAX_BAR_W, MAX_BAR_H)
684
+
685
+ // PlaneGeometry 의 월드 크기는 clamp, 텍스처 캔버스 해상도는 최대 크기 기준 고정
686
+ // (축소되어도 선명도 유지).
687
+ const barW = MAX_BAR_W * fit
688
+ const barH = MAX_BAR_H * fit
472
689
  const scale = 4
473
- const canvasW = barW * scale
474
- const canvasH = barH * scale
690
+ const canvasW = MAX_BAR_W * scale
691
+ const canvasH = MAX_BAR_H * scale
475
692
  const canvas = document.createElement('canvas')
476
693
  canvas.width = canvasW
477
694
  canvas.height = canvasH
@@ -486,24 +703,32 @@ class StockerRealObject3D extends MCSRealObject3D {
486
703
  ctx.lineWidth = 12
487
704
  ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
488
705
 
489
- // 세그먼트 채움 (왼→오)
706
+ // 세그먼트 채움 (왼→오) + carrier/abnormal 은 세그먼트별 숫자(%) 표기 (2D 와 동일)
490
707
  const innerW = canvasW - pad * 2
491
708
  const innerH = canvasH - pad * 2
709
+ // 컴포넌트 폰트 설정(fontFamily/bold/italic) 을 그대로, 크기만 텍스처 scale 배 (2D 와 상대 크기 일치).
710
+ const fontSize = (Number((this.component as any).state?.fontSize) || 15) * scale
711
+ const font = this.gaugeFont(fontSize)
492
712
  let x = pad
493
713
  for (const seg of gauge.segments) {
494
714
  const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
495
715
  if (segW <= 0) continue
496
716
  ctx.fillStyle = seg.color
497
717
  ctx.fillRect(x, pad, segW, innerH)
718
+ if (gauge.perSegment) {
719
+ this.drawGaugeSegmentNumber(ctx, seg.ratio, x, pad, segW, innerH, font, fontSize)
720
+ }
498
721
  x += segW
499
722
  }
500
723
 
501
- // 텍스트
502
- ctx.font = `bold ${10 * scale}px sans-serif`
503
- ctx.textAlign = 'center'
504
- ctx.textBaseline = 'middle'
505
- ctx.fillStyle = '#333333'
506
- ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
724
+ // capacity: 전체 중앙에 usage% 한 개 (2D 와 동일). carrier/abnormal 은 위 세그먼트별 숫자 사용.
725
+ if (gauge.label) {
726
+ ctx.font = font
727
+ ctx.textAlign = 'center'
728
+ ctx.textBaseline = 'middle'
729
+ ctx.fillStyle = '#333333'
730
+ ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
731
+ }
507
732
 
508
733
  // 텍스처 → Plane
509
734
  const texture = new THREE.CanvasTexture(canvas)
@@ -523,62 +748,107 @@ class StockerRealObject3D extends MCSRealObject3D {
523
748
  mat.polygonOffsetUnit = -2
524
749
 
525
750
  this.gaugeMesh = new THREE.Mesh(geo, mat)
751
+ // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움.
526
752
  this.gaugeMesh.position.set(0, depth + 0.5, 0)
527
- this.object3d.add(this.gaugeMesh)
753
+ this.components3D.add(this.gaugeMesh)
528
754
  }
529
755
 
530
- private resolveGauge(data: any, mode: string) {
756
+ private resolveGauge(
757
+ data: any,
758
+ mode: string
759
+ ): { segments: { ratio: number; color: string }[]; borderColor: string; label: string | null; perSegment: boolean } {
531
760
  switch (mode) {
532
761
  case 'carrier': {
533
- const full = Number(data.FULL_CNT) || 0
534
- const empty = Number(data.EMPTY_CNT) || 0
535
- const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0
536
- const total = full + empty + emptyEmpty
537
- const segments = total > 0
538
- ? [
539
- { ratio: full / total, color: '#4AA7FE' },
540
- { ratio: empty / total, color: '#8BDA5B' },
541
- { ratio: emptyEmpty / total, color: '#019D59' },
542
- ]
543
- : [{ ratio: 1, color: '#dddddd' }]
544
- return {
545
- segments,
546
- borderColor: '#4AA7FE',
547
- label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '',
548
- }
762
+ // 세그먼트 비율/색상은 2D 와 공유되는 단일 소스에서 계산 (필드/색상 drift 방지).
763
+ // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득.
764
+ const colors = carrierColorsFromLegend(resolveCarrierLegend(this.component))
765
+ const [f, e, ee] = carrierSegmentsFromData(data, colors)
766
+ const total = f.count + e.count + ee.count
767
+ // 접두사 없는 세그먼트별 숫자(%)로 표기 (capacity·2D 와 동일). label(중앙 한 개) 미사용.
768
+ return total > 0
769
+ ? {
770
+ segments: [f, e, ee].map(s => ({ ratio: s.ratio, color: s.color })),
771
+ borderColor: colors.full,
772
+ label: null,
773
+ perSegment: true,
774
+ }
775
+ : { segments: [{ ratio: 1, color: '#dddddd' }], borderColor: colors.full, label: null, perSegment: false }
549
776
  }
550
777
  case 'abnormal': {
551
- const normal = Number(data.CARRIER_NORMAL) || 0
552
- const abnormal = Number(data.CARRIER_ABNORMAL) || 0
553
- const total = normal + abnormal
554
- const segments = total > 0
555
- ? [
556
- { ratio: normal / total, color: '#8BDA5B' },
557
- { ratio: abnormal / total, color: '#F4B4B4' },
558
- ]
559
- : [{ ratio: 1, color: '#dddddd' }]
560
- return {
561
- segments,
562
- borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B',
563
- label: total > 0 ? `N${normal} A${abnormal}` : '',
564
- }
778
+ const [n, a] = abnormalSegmentsFromData(data)
779
+ const total = n.count + a.count
780
+ return total > 0
781
+ ? {
782
+ segments: [n, a].map(s => ({ ratio: s.ratio, color: s.color })),
783
+ borderColor: a.count > 0 ? ABNORMAL_COLORS.abnormal : ABNORMAL_COLORS.normal,
784
+ label: null,
785
+ perSegment: true,
786
+ }
787
+ : {
788
+ segments: [{ ratio: 1, color: '#dddddd' }],
789
+ borderColor: ABNORMAL_COLORS.normal,
790
+ label: null,
791
+ perSegment: false,
792
+ }
565
793
  }
566
- default: { // capacity
794
+ default: { // capacity — 전체 중앙에 usage% 한 개
567
795
  const cur = Number(data.CURRENTCAPACITY) || 0
568
796
  const max = Number(data.MAXCAPACITY) || 100
569
797
  const fullup = data.FULLUP === 'T'
570
798
  const usage = Math.round((cur / max) * 100)
571
- const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
572
- const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
799
+ const legend = (this.component as any).root?.stockerCapacityLegendTheme || LEGEND_CAPACITY
800
+ const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
573
801
  return {
574
802
  segments: [{ ratio: usage / 100, color }],
575
803
  borderColor: color,
576
804
  label: `${usage}%`,
805
+ perSegment: false,
577
806
  }
578
807
  }
579
808
  }
580
809
  }
581
810
 
811
+ /** 컴포넌트 폰트 설정(fontFamily/bold/italic)으로 CSS font 문자열 조합. size 는 텍스처 스케일 반영값. */
812
+ private gaugeFont(sizePx: number): string {
813
+ const st = (this.component as any).state || {}
814
+ const parts: string[] = []
815
+ if (st.bold) parts.push('bold')
816
+ if (st.italic) parts.push('italic')
817
+ parts.push(`${sizePx}px`)
818
+ parts.push(st.fontFamily || 'sans-serif')
819
+ return parts.join(' ')
820
+ }
821
+
822
+ /** 세그먼트 영역 중심에 숫자(%)만 표기. 좁으면 값% → 값 → 생략 (2D _drawSegmentLabel 과 동일 정책). */
823
+ private drawGaugeSegmentNumber(
824
+ ctx: CanvasRenderingContext2D,
825
+ ratio: number,
826
+ x: number,
827
+ y: number,
828
+ w: number,
829
+ h: number,
830
+ font: string,
831
+ fontSize: number
832
+ ) {
833
+ ctx.font = font
834
+ ctx.textAlign = 'center'
835
+ ctx.textBaseline = 'middle'
836
+ ctx.fillStyle = '#333333'
837
+ const value = `${Math.round(ratio * 100)}`
838
+ const cx = x + w / 2
839
+ const cy = y + h / 2
840
+ if (fontSize <= h && ctx.measureText(`${value}%`).width <= w) {
841
+ ctx.fillText(`${value}%`, cx, cy)
842
+ } else if (fontSize <= h && ctx.measureText(value).width <= w) {
843
+ ctx.fillText(value, cx, cy)
844
+ }
845
+ }
846
+
847
+ update() {
848
+ this.clearGauge()
849
+ super.update()
850
+ }
851
+
582
852
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
583
853
  if ('data' in after) {
584
854
  this.clearGauge()
@@ -594,7 +864,7 @@ class StockerRealObject3D extends MCSRealObject3D {
594
864
 
595
865
  private clearGauge() {
596
866
  if (this.gaugeMesh) {
597
- this.object3d.remove(this.gaugeMesh)
867
+ this.components3D.remove(this.gaugeMesh)
598
868
  this.gaugeMesh.geometry?.dispose()
599
869
  const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
600
870
  mat?.map?.dispose()
@@ -610,16 +880,17 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
610
880
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
611
881
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
612
882
 
613
- // Port: 상부 개방 5면 박스
883
+ // Port: 바닥판 (PlaneGeometry)
614
884
  const PORT_CONFIG: MCS3DConfig = {
615
885
  ...DEFAULTS,
616
- depthRatio: 0.5,
886
+ depthRatio: 0.01,
617
887
  defaultZPosFromParent: true,
618
- opacity: 0.5,
619
- metalness: 0.4,
620
- roughness: 0.6,
888
+ opacity: 0.1,
889
+ metalness: 0.3,
890
+ roughness: 0.8,
621
891
  defaultColor: 0xb0a090,
622
- openTop: true,
892
+ shape: 'plane',
893
+ zPriority: Z_FMSIM.PORT,
623
894
  }
624
895
  registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
625
896
 
@@ -628,140 +899,295 @@ register('Shelf', {
628
899
  metalness: 0.3,
629
900
  roughness: 0.7,
630
901
  defaultColor: 0xaabbcc,
902
+ zPriority: Z_FMSIM.SHELF,
631
903
  })
632
904
 
633
905
  // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
634
906
  const CARRIER_CONFIG: MCS3DConfig = {
635
907
  ...DEFAULTS,
636
908
  depthRatio: 0.5,
637
- metalness: 0.2,
638
- roughness: 0.8,
639
- defaultColor: 0x66bb66,
909
+ metalness: 0,
910
+ roughness: 1,
911
+ defaultColor: 0xf0f0f0,
640
912
  isVisible: (c: Component) => !!(c.state as any).id,
641
913
  sizeRatio: 0.8,
642
914
  texturedTop: true,
915
+ // Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정.
643
916
  }
644
917
 
645
- class CarrierRealObject3D extends MCSRealObject3D {
646
- private _animRaf: number | null = null
647
- private _animStartTime = 0
918
+ /**
919
+ * 적층 시 다음 carrier 의 z-position 이 *_이전 carrier 의 effectiveDepth × N*
920
+ * 만큼 위에 놓인다. N=1 이면 carrier 들이 맞붙어 적층 (기존), N<1 이면 부분
921
+ * overlap (3D box 들이 겹쳐서 적층). 사용자 의도 — carrier 두께는 두꺼워도
922
+ * 적층 시 *_compact 하게 보이도록* — N 을 작게.
923
+ */
924
+ const CARRIER_STACK_STEP_RATIO = 0.9
648
925
 
926
+ class CarrierRealObject3D extends MCSRealObject3D {
649
927
  constructor(component: Component) {
650
928
  super(component, CARRIER_CONFIG)
651
929
  }
652
930
 
653
- build() {
654
- super.build()
655
- this.startCarrierAnimation()
656
- }
657
-
658
931
  /**
659
- * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
- * things-scene의 delta 시스템과 독립적으로 동작.
932
+ * cz override — *carrier 자기 결정* 의 적층 위치.
933
+ * parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth
934
+ * 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고,
935
+ * carrier 가 자기 stack 위치를 알아서 결정.
661
936
  */
662
- private startCarrierAnimation() {
663
- this.stopCarrierAnimation()
937
+ override get cz(): number {
938
+ const base = super.cz
939
+ const parent = this.component.parent as any
940
+ if (!parent?.components) return base
941
+ const siblings = parent.components as any[]
942
+ const idx = siblings.indexOf(this.component)
943
+ if (idx <= 0) return base
944
+ let offset = 0
945
+ for (let i = 0; i < idx; i++) {
946
+ const ro = siblings[i]?._realObject
947
+ const eff = Number(ro?.effectiveDepth)
948
+ const d = Number.isFinite(eff) && eff > 0 ? eff : 1
949
+ offset += d * CARRIER_STACK_STEP_RATIO
950
+ }
951
+ // 적층 방향 — placement (= 부모 holder 타입) 에 따라 결정.
952
+ // OHT 처럼 그리퍼 아래에 매달리는 holder 는 carrier 가 *_아래로_* 누적,
953
+ // 그 외 (Port / Shelf / Shuttle / Vehicle) 는 위로 누적.
954
+ // MCSRealObject3D.syncZPosOffset 의 OHT 분기 (-effectiveDepth + halfD) 와
955
+ // 같은 방향성 일관 적용.
956
+ const parentType = parent?.state?.type
957
+ const dir = parentType === 'OHT' ? -1 : 1
958
+ return base + dir * offset
959
+ }
960
+ }
664
961
 
665
- const comp = this.component as any
666
- if (!comp.app?.isViewMode) return
962
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
667
963
 
668
- const carrierStatus = comp.state?.CARRIERSTATUS
669
- if (!carrierStatus) return
964
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
965
+ // Gauge (용량 게이지) — 얇은 바
966
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
670
967
 
671
- // 애니메이션 설정 가져오기
672
- const config = comp.getAnimationConfig?.(carrierStatus)
673
- if (!config?.animation) return
968
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
969
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
970
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
674
971
 
675
- const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
- if (!type) return
972
+ // Gauge 컴포넌트는 3D에서 표시하지 않음 (부모 STOCKER의 3D 모델에서 직접 게이지 렌더링)
973
+ // null 반환으로 3D 오브젝트 생성을 억제하고, default factory fallback도 방지
974
+ registerRealObjectFactory('StockerCapacityBar', () => undefined as any)
975
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => undefined as any)
976
+ registerRealObjectFactory('ZoneCapacityBar', () => undefined as any)
677
977
 
678
- this._animStartTime = performance.now()
978
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
979
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
980
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
679
981
 
680
- // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
- // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
- const obj = this.object3d
982
+ class PortFlowRealObject3D extends RealObjectGroup {
983
+ private _mesh?: THREE.Mesh
984
+ private _texture?: THREE.CanvasTexture
683
985
 
684
- const animate = () => {
685
- if (!this._ownMesh) return
986
+ get zPriority(): number { return Z_FMSIM.PORT_FLOW }
987
+ get effectiveDepth(): number { return 0 }
988
+ protected get syncZPosOffset(): number {
989
+ // 부모 Port/Conveyor의 zPos와 동일하게
990
+ const parent = (this.component as any).parentObject
991
+ if (parent?.state?.zPos != null) return parent.state.zPos
992
+ return TRANSPORT_SURFACE_HEIGHT
993
+ }
686
994
 
687
- const elapsed = performance.now() - this._animStartTime
688
- const progress = (elapsed % duration) / duration // 0~1 반복
995
+ build() {
996
+ super.build()
689
997
 
690
- switch (type) {
691
- case 'heartbeat': {
692
- const t = progress < 0.5 ? progress * 2 : (1 - progress) * 2
693
- const s = 1 + (animScale - 1) * t
694
- obj.scale.set(s, 1, s)
695
- break
696
- }
697
- case 'rotation': {
698
- obj.rotation.y = progress * Math.PI * 2
699
- break
700
- }
701
- case 'vibration': {
702
- const theta = config.animation.theta ?? 0.26
703
- const current = Math.sin(progress * Math.PI * 2) * theta
704
- obj.rotation.y = current
705
- break
706
- }
707
- case 'fade': {
708
- const alpha = 0.3 + 0.7 * (progress < 0.5 ? progress * 2 : (1 - progress) * 2)
709
- obj.traverse((o: any) => {
710
- if (o.material && !o.material.map) {
711
- o.material.opacity = alpha
712
- o.material.transparent = true
713
- }
714
- })
715
- break
716
- }
998
+ const { width = 10, height = 10 } = this.component.state
999
+ const w = Math.abs(width)
1000
+ const h = Math.abs(height)
1001
+ if (!w || !h) return
1002
+
1003
+ // 2D render를 캔버스에 캡처
1004
+ // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산
1005
+ const { left, top } = this.component.bounds
1006
+ const { fontSize = 24, labelPosition = 'top' } = this.component.state
1007
+ const arrowPad = Math.min(w, h) * 0.4
1008
+
1009
+ // 텍스트 영역 확장
1010
+ let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad
1011
+ if ((this.component as any).hasTextProperty) {
1012
+ switch (labelPosition) {
1013
+ case 'top': padTop = Math.max(padTop, fontSize + 4); break
1014
+ case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break
1015
+ case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break
1016
+ case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break
717
1017
  }
718
-
719
- this._animRaf = requestAnimationFrame(animate)
720
1018
  }
721
1019
 
722
- this._animRaf = requestAnimationFrame(animate)
723
- }
1020
+ const totalW = w + padLeft + padRight
1021
+ const totalH = h + padTop + padBottom
1022
+ const scale = 2
1023
+ const canvas = document.createElement('canvas')
1024
+ canvas.width = totalW * scale
1025
+ canvas.height = totalH * scale
1026
+ const ctx = canvas.getContext('2d')!
724
1027
 
725
- // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
- syncAnimationDelta() {
727
- if (!this._animRaf) {
728
- super.syncAnimationDelta()
729
- }
730
- }
1028
+ ctx.save()
1029
+ ctx.scale(scale, scale)
1030
+ ctx.translate(-left + padLeft, -top + padTop)
1031
+ try {
1032
+ // 화살표 렌더
1033
+ this.component.render(ctx)
1034
+ ctx.fillStyle = this.component.state.strokeStyle || '#666666'
1035
+ ctx.fill()
1036
+ // 텍스트 렌더
1037
+ if ((this.component as any).hasTextProperty && (this.component as any).drawText) {
1038
+ ;(this.component as any).drawText(ctx)
1039
+ }
1040
+ } catch { /* render 실패 시 빈 텍스처 */ }
1041
+ ctx.restore()
731
1042
 
732
- private stopCarrierAnimation() {
733
- if (this._animRaf) {
734
- cancelAnimationFrame(this._animRaf)
735
- this._animRaf = null
736
- }
737
- // group 스케일/회전 초기화
738
- this.object3d.scale.set(1, 1, 1)
739
- this.object3d.rotation.y = 0
1043
+ this._texture = new THREE.CanvasTexture(canvas)
1044
+ this._texture.colorSpace = THREE.SRGBColorSpace
1045
+
1046
+ const geo = new THREE.PlaneGeometry(totalW, totalH)
1047
+ geo.rotateX(-Math.PI / 2)
1048
+
1049
+ const mat = new THREE.MeshBasicMaterial({
1050
+ map: this._texture,
1051
+ alphaTest: 0.1,
1052
+ side: THREE.DoubleSide,
1053
+ })
1054
+
1055
+ this._mesh = new THREE.Mesh(geo, mat)
1056
+ // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤
1057
+ this._mesh.position.x = (padRight - padLeft) / 2
1058
+ this._mesh.position.y = 1
1059
+ this._mesh.position.z = (padBottom - padTop) / 2
1060
+ this.object3d.add(this._mesh)
740
1061
  }
741
1062
 
742
1063
  update() {
743
- this.stopCarrierAnimation()
744
- super.update()
1064
+ if (this._mesh) {
1065
+ this.object3d.remove(this._mesh)
1066
+ this._mesh.geometry.dispose()
1067
+ ;(this._mesh.material as THREE.Material).dispose()
1068
+ this._texture?.dispose()
1069
+ this._mesh = undefined
1070
+ this._texture = undefined
1071
+ }
1072
+ this.build()
1073
+ this.updateTransform()
1074
+ this.updateHidden()
745
1075
  }
746
1076
 
747
- clear() {
748
- this.stopCarrierAnimation()
749
- return super.clear()
1077
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
1078
+ if ('width' in after || 'height' in after || 'orientation' in after ||
1079
+ 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) {
1080
+ this.update()
1081
+ return
1082
+ }
1083
+ super.onchange(after, before)
750
1084
  }
1085
+
1086
+ updateDimension() {}
1087
+ updateAlpha() {}
751
1088
  }
752
1089
 
753
- registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
1090
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
754
1091
 
755
1092
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
756
- // Gauge (용량 게이지) — 얇은 바
1093
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
757
1094
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
758
1095
 
759
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
760
- // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
761
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1096
+ class NodeRealObject3D extends RealObjectGroup {
1097
+ private _mesh?: THREE.Mesh
1098
+ private _texture?: THREE.CanvasTexture
1099
+
1100
+ get zPriority(): number { return Z_FMSIM.NODE }
1101
+ get effectiveDepth(): number { return 0 }
1102
+ protected get syncZPosOffset(): number { return 0 }
1103
+
1104
+ build() {
1105
+ super.build()
1106
+
1107
+ const { cx, cy } = this.component.state
1108
+ const radius = 8
1109
+ const size = radius * 2
1110
+ const scale = 2
1111
+ const pad = 2
1112
+
1113
+ const canvas = document.createElement('canvas')
1114
+ canvas.width = (size + pad * 2) * scale
1115
+ canvas.height = (size + pad * 2) * scale
1116
+ const ctx = canvas.getContext('2d')!
1117
+
1118
+ ctx.save()
1119
+ ctx.scale(scale, scale)
1120
+ ctx.translate(-cx + radius + pad, -cy + radius + pad)
1121
+ try {
1122
+ this.component.render(ctx)
1123
+ const { fillStyle = '#888888' } = this.component.state
1124
+ ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888'
1125
+ ctx.fill()
1126
+ const { strokeStyle } = this.component.state
1127
+ if (strokeStyle && typeof strokeStyle === 'string') {
1128
+ ctx.strokeStyle = strokeStyle
1129
+ ctx.lineWidth = 1
1130
+ ctx.stroke()
1131
+ }
1132
+ } catch { /* render 실패 시 빈 텍스처 */ }
1133
+ ctx.restore()
1134
+
1135
+ this._texture = new THREE.CanvasTexture(canvas)
1136
+ this._texture.colorSpace = THREE.SRGBColorSpace
1137
+
1138
+ const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2)
1139
+ geo.rotateX(-Math.PI / 2)
1140
+
1141
+ const mat = new THREE.MeshBasicMaterial({
1142
+ map: this._texture,
1143
+ alphaTest: 0.1,
1144
+ side: THREE.DoubleSide,
1145
+ })
1146
+
1147
+ this._mesh = new THREE.Mesh(geo, mat)
1148
+ this._mesh.position.y = 1
1149
+ this.object3d.add(this._mesh)
1150
+ }
1151
+
1152
+ update() {
1153
+ if (this._mesh) {
1154
+ this.object3d.remove(this._mesh)
1155
+ this._mesh.geometry.dispose()
1156
+ ;(this._mesh.material as THREE.Material).dispose()
1157
+ this._texture?.dispose()
1158
+ this._mesh = undefined
1159
+ this._texture = undefined
1160
+ }
1161
+ this.build()
1162
+ this.updateTransform()
1163
+ this.updateHidden()
1164
+ }
1165
+
1166
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
1167
+ if ('fillStyle' in after || 'strokeStyle' in after) {
1168
+ this.update()
1169
+ return
1170
+ }
1171
+ super.onchange(after, before)
1172
+ }
1173
+
1174
+ updateDimension() {}
1175
+ updateAlpha() {}
1176
+ }
762
1177
 
763
- // Gauge 컴포넌트 (StockerCapacityBar, ZoneCapacityBar, MCSGaugeCapacityBar)는
764
- // 3D에서 표시하지 않음. 대신 부모(STOCKER 등)의 3D 모델에서 직접 게이지를 렌더링.
765
- registerRealObjectFactory('StockerCapacityBar', () => null as any)
766
- registerRealObjectFactory('MCSGaugeCapacityBar', () => null as any)
767
- registerRealObjectFactory('ZoneCapacityBar', () => null as any)
1178
+ registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component))
1179
+
1180
+ // Scanner — 2D Triangle 실루엣과 정합되는 삼각형 prism.
1181
+ // 이송 표면 높이의 0.2배(=6) 얇은 기둥 — 원래 값 유지, shape 만 'box' → 'triangle'.
1182
+ const SCANNER_CONFIG: MCS3DConfig = {
1183
+ ...DEFAULTS,
1184
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2,
1185
+ defaultZPosFromParent: true,
1186
+ opacity: 1,
1187
+ metalness: 0.3,
1188
+ roughness: 0.7,
1189
+ defaultColor: 0x888888,
1190
+ shape: 'triangle',
1191
+ zPriority: Z_FMSIM.SCANNER,
1192
+ }
1193
+ registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))