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

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 +8 -9
  14. package/dist/factories/agv-3d.js.map +1 -1
  15. package/dist/factories/conveyor-3d.js +168 -81
  16. package/dist/factories/conveyor-3d.js.map +1 -1
  17. package/dist/factories/crane-3d.js +48 -12
  18. package/dist/factories/crane-3d.js.map +1 -1
  19. package/dist/factories/crane-rail-3d.js +6 -6
  20. package/dist/factories/crane-rail-3d.js.map +1 -1
  21. package/dist/factories/machine-3d.js +527 -181
  22. package/dist/factories/machine-3d.js.map +1 -1
  23. package/dist/factories/oht-3d.js +20 -20
  24. package/dist/factories/oht-3d.js.map +1 -1
  25. package/dist/factories/shuttle-3d.js +2 -5
  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 +39 -0
  46. package/dist/mcs-machine.js.map +1 -1
  47. package/dist/mcs-unit.js +20 -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 +9 -10
  107. package/src/factories/conveyor-3d.ts +171 -84
  108. package/src/factories/crane-3d.ts +53 -12
  109. package/src/factories/crane-rail-3d.ts +8 -7
  110. package/src/factories/machine-3d.ts +616 -191
  111. package/src/factories/oht-3d.ts +20 -20
  112. package/src/factories/shuttle-3d.ts +2 -6
  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 +42 -0
  124. package/src/mcs-unit.ts +24 -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 * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
12
13
  import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } 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,19 +303,31 @@ 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
- })
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
+ const material = isPlane
316
+ ? new THREE.MeshBasicMaterial({
317
+ color,
318
+ opacity,
319
+ transparent: opacity < 1,
320
+ side: THREE.DoubleSide,
321
+ })
322
+ : new THREE.MeshStandardMaterial({
323
+ color,
324
+ metalness,
325
+ roughness,
326
+ opacity,
327
+ transparent: opacity < 1,
328
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
329
+ ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}),
330
+ })
233
331
  // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
234
332
  material.polygonOffset = true
235
333
  material.polygonOffsetFactor = -1
@@ -254,11 +352,14 @@ class MCSRealObject3D extends RealObjectGroup {
254
352
  }
255
353
 
256
354
  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
355
+ // ownGroup 기준(bottom-up): mesh 바닥을 y=0에 맞추고 ownGroup이 -d/2로 shift해
356
+ // center-origin object3d에 정렬됨.
357
+ // box/hexagon (center-local geometry): y=rawDepth/2 (바닥을 0에 맞춤)
358
+ // plane: y=1 (바닥 살짝 띄움)
359
+ this._ownMesh.position.y = isPlane ? 1 : rawDepth / 2
360
+ this._ownMesh.castShadow = !isPlane
260
361
  this._ownMesh.receiveShadow = true
261
- this.object3d.add(this._ownMesh)
362
+ this.components3D.add(this._ownMesh)
262
363
  }
263
364
 
264
365
  // ── Change handling ──
@@ -353,7 +454,7 @@ class MCSRealObject3D extends RealObjectGroup {
353
454
  update() {
354
455
  // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
355
456
  if (this._ownMesh) {
356
- this.object3d.remove(this._ownMesh)
457
+ this.components3D.remove(this._ownMesh)
357
458
  disposeObject3D(this._ownMesh)
358
459
  this._ownMesh = undefined
359
460
  }
@@ -368,9 +469,9 @@ class MCSRealObject3D extends RealObjectGroup {
368
469
  updateDimension() {}
369
470
 
370
471
  updateAlpha() {
371
- const { alpha = 1 } = this.component.state
372
- const configOpacity = this._config.opacity
373
- const effective = configOpacity * alpha
472
+ const state = this.component.state
473
+ // alpha가 명시적으로 설정되어 있으면 그 값을 사용, 없으면 config 기본값 적용
474
+ const effective = ('alpha' in state) ? (state.alpha as number) : this._config.opacity
374
475
 
375
476
  this.object3d.traverse((o: any) => {
376
477
  if (!o.material) return
@@ -385,6 +486,20 @@ class MCSRealObject3D extends RealObjectGroup {
385
486
  }
386
487
  }
387
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
499
+ }
500
+ return 0
501
+ }
502
+
388
503
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
389
504
 
390
505
  function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
@@ -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,112 @@ 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
+ const mat = new THREE.MeshStandardMaterial({
577
+ color: OHT_LINE_RAIL_COLOR,
578
+ metalness: 0.5,
579
+ roughness: 0.4,
580
+ })
581
+
582
+ const geoms: THREE.BufferGeometry[] = []
583
+
584
+ // 상부 플랜지
585
+ const topFlange = isVertical
586
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
587
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
588
+ topFlange.translate(0, -flangeH / 2, 0)
589
+ geoms.push(topFlange)
590
+
591
+ // 웹 (수직판)
592
+ const web = isVertical
593
+ ? new THREE.BoxGeometry(webW, webH, railLen)
594
+ : new THREE.BoxGeometry(railLen, webH, webW)
595
+ web.translate(0, -flangeH - webH / 2, 0)
596
+ geoms.push(web)
597
+
598
+ // 하부 플랜지
599
+ const botFlange = isVertical
600
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
601
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
602
+ botFlange.translate(0, -flangeH - webH - flangeH / 2, 0)
603
+ geoms.push(botFlange)
604
+
605
+ this._mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(geoms), mat)
606
+ this._mesh.castShadow = true
607
+ this.object3d.add(this._mesh)
608
+ }
609
+
610
+ update() {
611
+ if (this._mesh) {
612
+ this.object3d.remove(this._mesh)
613
+ this._mesh.geometry.dispose()
614
+ ;(this._mesh.material as THREE.Material).dispose()
615
+ this._mesh = undefined
616
+ }
617
+ this.build()
618
+ this.updateTransform()
619
+ this.updateHidden()
620
+ }
621
+
622
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
623
+ if ('width' in after || 'height' in after || 'depth' in after ||
624
+ 'fillStyle' in after || 'strokeStyle' in after) {
625
+ this.update()
626
+ return
627
+ }
628
+ super.onchange(after, before)
629
+ }
630
+
631
+ updateDimension() {}
632
+ updateAlpha() {}
633
+ }
634
+
635
+ registerRealObjectFactory('OHTLine', (component: Component) => new OHTLineRealObject3D(component))
429
636
 
430
637
  // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
431
638
  const STOCKER_CONFIG: MCS3DConfig = {
@@ -435,6 +642,7 @@ const STOCKER_CONFIG: MCS3DConfig = {
435
642
  metalness: 0.3,
436
643
  roughness: 0.6,
437
644
  defaultColor: 0x8899bb,
645
+ zPriority: Z_FMSIM.STOCKER,
438
646
  }
439
647
 
440
648
  class StockerRealObject3D extends MCSRealObject3D {
@@ -452,7 +660,7 @@ class StockerRealObject3D extends MCSRealObject3D {
452
660
  private buildGauge() {
453
661
  const comp = this.component as any
454
662
  const data = comp.data || {}
455
- const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
663
+ const gaugeMode = comp.root?.stockerGaugeMode || 'capacity'
456
664
 
457
665
  const gauge = this.resolveGauge(data, gaugeMode)
458
666
 
@@ -466,12 +674,20 @@ class StockerRealObject3D extends MCSRealObject3D {
466
674
  const depth = this.effectiveDepth
467
675
  const isWidthLonger = w >= h
468
676
 
469
- // 게이지 캔버스 (고해상도)
470
- const barW = 130
471
- const barH = 26
677
+ // 게이지 최대 크기 (스토커가 충분히 크면 이 크기 그대로 사용).
678
+ const MAX_BAR_W = 130
679
+ const MAX_BAR_H = 26
680
+
681
+ // 스토커 지붕 footprint 에 맞춰 비례 축소 (확대는 안 함 — 현재 크기가 상한).
682
+ const fit = fitGaugeScale(w, h, MAX_BAR_W, MAX_BAR_H)
683
+
684
+ // PlaneGeometry 의 월드 크기는 clamp, 텍스처 캔버스 해상도는 최대 크기 기준 고정
685
+ // (축소되어도 선명도 유지).
686
+ const barW = MAX_BAR_W * fit
687
+ const barH = MAX_BAR_H * fit
472
688
  const scale = 4
473
- const canvasW = barW * scale
474
- const canvasH = barH * scale
689
+ const canvasW = MAX_BAR_W * scale
690
+ const canvasH = MAX_BAR_H * scale
475
691
  const canvas = document.createElement('canvas')
476
692
  canvas.width = canvasW
477
693
  canvas.height = canvasH
@@ -486,24 +702,32 @@ class StockerRealObject3D extends MCSRealObject3D {
486
702
  ctx.lineWidth = 12
487
703
  ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
488
704
 
489
- // 세그먼트 채움 (왼→오)
705
+ // 세그먼트 채움 (왼→오) + carrier/abnormal 은 세그먼트별 숫자(%) 표기 (2D 와 동일)
490
706
  const innerW = canvasW - pad * 2
491
707
  const innerH = canvasH - pad * 2
708
+ // 컴포넌트 폰트 설정(fontFamily/bold/italic) 을 그대로, 크기만 텍스처 scale 배 (2D 와 상대 크기 일치).
709
+ const fontSize = (Number((this.component as any).state?.fontSize) || 15) * scale
710
+ const font = this.gaugeFont(fontSize)
492
711
  let x = pad
493
712
  for (const seg of gauge.segments) {
494
713
  const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
495
714
  if (segW <= 0) continue
496
715
  ctx.fillStyle = seg.color
497
716
  ctx.fillRect(x, pad, segW, innerH)
717
+ if (gauge.perSegment) {
718
+ this.drawGaugeSegmentNumber(ctx, seg.ratio, x, pad, segW, innerH, font, fontSize)
719
+ }
498
720
  x += segW
499
721
  }
500
722
 
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)
723
+ // capacity: 전체 중앙에 usage% 한 개 (2D 와 동일). carrier/abnormal 은 위 세그먼트별 숫자 사용.
724
+ if (gauge.label) {
725
+ ctx.font = font
726
+ ctx.textAlign = 'center'
727
+ ctx.textBaseline = 'middle'
728
+ ctx.fillStyle = '#333333'
729
+ ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
730
+ }
507
731
 
508
732
  // 텍스처 → Plane
509
733
  const texture = new THREE.CanvasTexture(canvas)
@@ -523,62 +747,107 @@ class StockerRealObject3D extends MCSRealObject3D {
523
747
  mat.polygonOffsetUnit = -2
524
748
 
525
749
  this.gaugeMesh = new THREE.Mesh(geo, mat)
750
+ // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움.
526
751
  this.gaugeMesh.position.set(0, depth + 0.5, 0)
527
- this.object3d.add(this.gaugeMesh)
752
+ this.components3D.add(this.gaugeMesh)
528
753
  }
529
754
 
530
- private resolveGauge(data: any, mode: string) {
755
+ private resolveGauge(
756
+ data: any,
757
+ mode: string
758
+ ): { segments: { ratio: number; color: string }[]; borderColor: string; label: string | null; perSegment: boolean } {
531
759
  switch (mode) {
532
760
  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
- }
761
+ // 세그먼트 비율/색상은 2D 와 공유되는 단일 소스에서 계산 (필드/색상 drift 방지).
762
+ // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득.
763
+ const colors = carrierColorsFromLegend(resolveCarrierLegend(this.component))
764
+ const [f, e, ee] = carrierSegmentsFromData(data, colors)
765
+ const total = f.count + e.count + ee.count
766
+ // 접두사 없는 세그먼트별 숫자(%)로 표기 (capacity·2D 와 동일). label(중앙 한 개) 미사용.
767
+ return total > 0
768
+ ? {
769
+ segments: [f, e, ee].map(s => ({ ratio: s.ratio, color: s.color })),
770
+ borderColor: colors.full,
771
+ label: null,
772
+ perSegment: true,
773
+ }
774
+ : { segments: [{ ratio: 1, color: '#dddddd' }], borderColor: colors.full, label: null, perSegment: false }
549
775
  }
550
776
  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
- }
777
+ const [n, a] = abnormalSegmentsFromData(data)
778
+ const total = n.count + a.count
779
+ return total > 0
780
+ ? {
781
+ segments: [n, a].map(s => ({ ratio: s.ratio, color: s.color })),
782
+ borderColor: a.count > 0 ? ABNORMAL_COLORS.abnormal : ABNORMAL_COLORS.normal,
783
+ label: null,
784
+ perSegment: true,
785
+ }
786
+ : {
787
+ segments: [{ ratio: 1, color: '#dddddd' }],
788
+ borderColor: ABNORMAL_COLORS.normal,
789
+ label: null,
790
+ perSegment: false,
791
+ }
565
792
  }
566
- default: { // capacity
793
+ default: { // capacity — 전체 중앙에 usage% 한 개
567
794
  const cur = Number(data.CURRENTCAPACITY) || 0
568
795
  const max = Number(data.MAXCAPACITY) || 100
569
796
  const fullup = data.FULLUP === 'T'
570
797
  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')
798
+ const legend = (this.component as any).root?.stockerCapacityLegendTheme || LEGEND_CAPACITY
799
+ const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
573
800
  return {
574
801
  segments: [{ ratio: usage / 100, color }],
575
802
  borderColor: color,
576
803
  label: `${usage}%`,
804
+ perSegment: false,
577
805
  }
578
806
  }
579
807
  }
580
808
  }
581
809
 
810
+ /** 컴포넌트 폰트 설정(fontFamily/bold/italic)으로 CSS font 문자열 조합. size 는 텍스처 스케일 반영값. */
811
+ private gaugeFont(sizePx: number): string {
812
+ const st = (this.component as any).state || {}
813
+ const parts: string[] = []
814
+ if (st.bold) parts.push('bold')
815
+ if (st.italic) parts.push('italic')
816
+ parts.push(`${sizePx}px`)
817
+ parts.push(st.fontFamily || 'sans-serif')
818
+ return parts.join(' ')
819
+ }
820
+
821
+ /** 세그먼트 영역 중심에 숫자(%)만 표기. 좁으면 값% → 값 → 생략 (2D _drawSegmentLabel 과 동일 정책). */
822
+ private drawGaugeSegmentNumber(
823
+ ctx: CanvasRenderingContext2D,
824
+ ratio: number,
825
+ x: number,
826
+ y: number,
827
+ w: number,
828
+ h: number,
829
+ font: string,
830
+ fontSize: number
831
+ ) {
832
+ ctx.font = font
833
+ ctx.textAlign = 'center'
834
+ ctx.textBaseline = 'middle'
835
+ ctx.fillStyle = '#333333'
836
+ const value = `${Math.round(ratio * 100)}`
837
+ const cx = x + w / 2
838
+ const cy = y + h / 2
839
+ if (fontSize <= h && ctx.measureText(`${value}%`).width <= w) {
840
+ ctx.fillText(`${value}%`, cx, cy)
841
+ } else if (fontSize <= h && ctx.measureText(value).width <= w) {
842
+ ctx.fillText(value, cx, cy)
843
+ }
844
+ }
845
+
846
+ update() {
847
+ this.clearGauge()
848
+ super.update()
849
+ }
850
+
582
851
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
583
852
  if ('data' in after) {
584
853
  this.clearGauge()
@@ -594,7 +863,7 @@ class StockerRealObject3D extends MCSRealObject3D {
594
863
 
595
864
  private clearGauge() {
596
865
  if (this.gaugeMesh) {
597
- this.object3d.remove(this.gaugeMesh)
866
+ this.components3D.remove(this.gaugeMesh)
598
867
  this.gaugeMesh.geometry?.dispose()
599
868
  const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
600
869
  mat?.map?.dispose()
@@ -610,16 +879,17 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
610
879
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
611
880
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
612
881
 
613
- // Port: 상부 개방 5면 박스
882
+ // Port: 바닥판 (PlaneGeometry)
614
883
  const PORT_CONFIG: MCS3DConfig = {
615
884
  ...DEFAULTS,
616
- depthRatio: 0.5,
885
+ depthRatio: 0.01,
617
886
  defaultZPosFromParent: true,
618
- opacity: 0.5,
619
- metalness: 0.4,
620
- roughness: 0.6,
887
+ opacity: 0.1,
888
+ metalness: 0.3,
889
+ roughness: 0.8,
621
890
  defaultColor: 0xb0a090,
622
- openTop: true,
891
+ shape: 'plane',
892
+ zPriority: Z_FMSIM.PORT,
623
893
  }
624
894
  registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
625
895
 
@@ -628,140 +898,295 @@ register('Shelf', {
628
898
  metalness: 0.3,
629
899
  roughness: 0.7,
630
900
  defaultColor: 0xaabbcc,
901
+ zPriority: Z_FMSIM.SHELF,
631
902
  })
632
903
 
633
904
  // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
634
905
  const CARRIER_CONFIG: MCS3DConfig = {
635
906
  ...DEFAULTS,
636
907
  depthRatio: 0.5,
637
- metalness: 0.2,
638
- roughness: 0.8,
639
- defaultColor: 0x66bb66,
908
+ metalness: 0,
909
+ roughness: 1,
910
+ defaultColor: 0xf0f0f0,
640
911
  isVisible: (c: Component) => !!(c.state as any).id,
641
912
  sizeRatio: 0.8,
642
913
  texturedTop: true,
914
+ // Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정.
643
915
  }
644
916
 
645
- class CarrierRealObject3D extends MCSRealObject3D {
646
- private _animRaf: number | null = null
647
- private _animStartTime = 0
917
+ /**
918
+ * 적층 시 다음 carrier 의 z-position 이 *_이전 carrier 의 effectiveDepth × N*
919
+ * 만큼 위에 놓인다. N=1 이면 carrier 들이 맞붙어 적층 (기존), N<1 이면 부분
920
+ * overlap (3D box 들이 겹쳐서 적층). 사용자 의도 — carrier 두께는 두꺼워도
921
+ * 적층 시 *_compact 하게 보이도록* — N 을 작게.
922
+ */
923
+ const CARRIER_STACK_STEP_RATIO = 0.9
648
924
 
925
+ class CarrierRealObject3D extends MCSRealObject3D {
649
926
  constructor(component: Component) {
650
927
  super(component, CARRIER_CONFIG)
651
928
  }
652
929
 
653
- build() {
654
- super.build()
655
- this.startCarrierAnimation()
656
- }
657
-
658
930
  /**
659
- * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
- * things-scene의 delta 시스템과 독립적으로 동작.
931
+ * cz override — *carrier 자기 결정* 의 적층 위치.
932
+ * parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth
933
+ * 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고,
934
+ * carrier 가 자기 stack 위치를 알아서 결정.
661
935
  */
662
- private startCarrierAnimation() {
663
- this.stopCarrierAnimation()
936
+ override get cz(): number {
937
+ const base = super.cz
938
+ const parent = this.component.parent as any
939
+ if (!parent?.components) return base
940
+ const siblings = parent.components as any[]
941
+ const idx = siblings.indexOf(this.component)
942
+ if (idx <= 0) return base
943
+ let offset = 0
944
+ for (let i = 0; i < idx; i++) {
945
+ const ro = siblings[i]?._realObject
946
+ const eff = Number(ro?.effectiveDepth)
947
+ const d = Number.isFinite(eff) && eff > 0 ? eff : 1
948
+ offset += d * CARRIER_STACK_STEP_RATIO
949
+ }
950
+ // 적층 방향 — placement (= 부모 holder 타입) 에 따라 결정.
951
+ // OHT 처럼 그리퍼 아래에 매달리는 holder 는 carrier 가 *_아래로_* 누적,
952
+ // 그 외 (Port / Shelf / Shuttle / Vehicle) 는 위로 누적.
953
+ // MCSRealObject3D.syncZPosOffset 의 OHT 분기 (-effectiveDepth + halfD) 와
954
+ // 같은 방향성 일관 적용.
955
+ const parentType = parent?.state?.type
956
+ const dir = parentType === 'OHT' ? -1 : 1
957
+ return base + dir * offset
958
+ }
959
+ }
664
960
 
665
- const comp = this.component as any
666
- if (!comp.app?.isViewMode) return
961
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
667
962
 
668
- const carrierStatus = comp.state?.CARRIERSTATUS
669
- if (!carrierStatus) return
963
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
964
+ // Gauge (용량 게이지) — 얇은 바
965
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
670
966
 
671
- // 애니메이션 설정 가져오기
672
- const config = comp.getAnimationConfig?.(carrierStatus)
673
- if (!config?.animation) return
967
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
968
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
969
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
674
970
 
675
- const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
- if (!type) return
971
+ // Gauge 컴포넌트는 3D에서 표시하지 않음 (부모 STOCKER의 3D 모델에서 직접 게이지 렌더링)
972
+ // null 반환으로 3D 오브젝트 생성을 억제하고, default factory fallback도 방지
973
+ registerRealObjectFactory('StockerCapacityBar', () => undefined as any)
974
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => undefined as any)
975
+ registerRealObjectFactory('ZoneCapacityBar', () => undefined as any)
677
976
 
678
- this._animStartTime = performance.now()
977
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
978
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
979
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
679
980
 
680
- // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
- // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
- const obj = this.object3d
981
+ class PortFlowRealObject3D extends RealObjectGroup {
982
+ private _mesh?: THREE.Mesh
983
+ private _texture?: THREE.CanvasTexture
683
984
 
684
- const animate = () => {
685
- if (!this._ownMesh) return
985
+ get zPriority(): number { return Z_FMSIM.PORT_FLOW }
986
+ get effectiveDepth(): number { return 0 }
987
+ protected get syncZPosOffset(): number {
988
+ // 부모 Port/Conveyor의 zPos와 동일하게
989
+ const parent = (this.component as any).parentObject
990
+ if (parent?.state?.zPos != null) return parent.state.zPos
991
+ return TRANSPORT_SURFACE_HEIGHT
992
+ }
686
993
 
687
- const elapsed = performance.now() - this._animStartTime
688
- const progress = (elapsed % duration) / duration // 0~1 반복
994
+ build() {
995
+ super.build()
689
996
 
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
- }
997
+ const { width = 10, height = 10 } = this.component.state
998
+ const w = Math.abs(width)
999
+ const h = Math.abs(height)
1000
+ if (!w || !h) return
1001
+
1002
+ // 2D render를 캔버스에 캡처
1003
+ // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산
1004
+ const { left, top } = this.component.bounds
1005
+ const { fontSize = 24, labelPosition = 'top' } = this.component.state
1006
+ const arrowPad = Math.min(w, h) * 0.4
1007
+
1008
+ // 텍스트 영역 확장
1009
+ let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad
1010
+ if ((this.component as any).hasTextProperty) {
1011
+ switch (labelPosition) {
1012
+ case 'top': padTop = Math.max(padTop, fontSize + 4); break
1013
+ case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break
1014
+ case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break
1015
+ case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break
717
1016
  }
718
-
719
- this._animRaf = requestAnimationFrame(animate)
720
1017
  }
721
1018
 
722
- this._animRaf = requestAnimationFrame(animate)
723
- }
1019
+ const totalW = w + padLeft + padRight
1020
+ const totalH = h + padTop + padBottom
1021
+ const scale = 2
1022
+ const canvas = document.createElement('canvas')
1023
+ canvas.width = totalW * scale
1024
+ canvas.height = totalH * scale
1025
+ const ctx = canvas.getContext('2d')!
724
1026
 
725
- // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
- syncAnimationDelta() {
727
- if (!this._animRaf) {
728
- super.syncAnimationDelta()
729
- }
730
- }
1027
+ ctx.save()
1028
+ ctx.scale(scale, scale)
1029
+ ctx.translate(-left + padLeft, -top + padTop)
1030
+ try {
1031
+ // 화살표 렌더
1032
+ this.component.render(ctx)
1033
+ ctx.fillStyle = this.component.state.strokeStyle || '#666666'
1034
+ ctx.fill()
1035
+ // 텍스트 렌더
1036
+ if ((this.component as any).hasTextProperty && (this.component as any).drawText) {
1037
+ ;(this.component as any).drawText(ctx)
1038
+ }
1039
+ } catch { /* render 실패 시 빈 텍스처 */ }
1040
+ ctx.restore()
731
1041
 
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
1042
+ this._texture = new THREE.CanvasTexture(canvas)
1043
+ this._texture.colorSpace = THREE.SRGBColorSpace
1044
+
1045
+ const geo = new THREE.PlaneGeometry(totalW, totalH)
1046
+ geo.rotateX(-Math.PI / 2)
1047
+
1048
+ const mat = new THREE.MeshBasicMaterial({
1049
+ map: this._texture,
1050
+ alphaTest: 0.1,
1051
+ side: THREE.DoubleSide,
1052
+ })
1053
+
1054
+ this._mesh = new THREE.Mesh(geo, mat)
1055
+ // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤
1056
+ this._mesh.position.x = (padRight - padLeft) / 2
1057
+ this._mesh.position.y = 1
1058
+ this._mesh.position.z = (padBottom - padTop) / 2
1059
+ this.object3d.add(this._mesh)
740
1060
  }
741
1061
 
742
1062
  update() {
743
- this.stopCarrierAnimation()
744
- super.update()
1063
+ if (this._mesh) {
1064
+ this.object3d.remove(this._mesh)
1065
+ this._mesh.geometry.dispose()
1066
+ ;(this._mesh.material as THREE.Material).dispose()
1067
+ this._texture?.dispose()
1068
+ this._mesh = undefined
1069
+ this._texture = undefined
1070
+ }
1071
+ this.build()
1072
+ this.updateTransform()
1073
+ this.updateHidden()
745
1074
  }
746
1075
 
747
- clear() {
748
- this.stopCarrierAnimation()
749
- return super.clear()
1076
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
1077
+ if ('width' in after || 'height' in after || 'orientation' in after ||
1078
+ 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) {
1079
+ this.update()
1080
+ return
1081
+ }
1082
+ super.onchange(after, before)
750
1083
  }
1084
+
1085
+ updateDimension() {}
1086
+ updateAlpha() {}
751
1087
  }
752
1088
 
753
- registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
1089
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
754
1090
 
755
1091
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
756
- // Gauge (용량 게이지) — 얇은 바
1092
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
757
1093
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
758
1094
 
759
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
760
- // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
761
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1095
+ class NodeRealObject3D extends RealObjectGroup {
1096
+ private _mesh?: THREE.Mesh
1097
+ private _texture?: THREE.CanvasTexture
1098
+
1099
+ get zPriority(): number { return Z_FMSIM.NODE }
1100
+ get effectiveDepth(): number { return 0 }
1101
+ protected get syncZPosOffset(): number { return 0 }
1102
+
1103
+ build() {
1104
+ super.build()
1105
+
1106
+ const { cx, cy } = this.component.state
1107
+ const radius = 8
1108
+ const size = radius * 2
1109
+ const scale = 2
1110
+ const pad = 2
1111
+
1112
+ const canvas = document.createElement('canvas')
1113
+ canvas.width = (size + pad * 2) * scale
1114
+ canvas.height = (size + pad * 2) * scale
1115
+ const ctx = canvas.getContext('2d')!
1116
+
1117
+ ctx.save()
1118
+ ctx.scale(scale, scale)
1119
+ ctx.translate(-cx + radius + pad, -cy + radius + pad)
1120
+ try {
1121
+ this.component.render(ctx)
1122
+ const { fillStyle = '#888888' } = this.component.state
1123
+ ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888'
1124
+ ctx.fill()
1125
+ const { strokeStyle } = this.component.state
1126
+ if (strokeStyle && typeof strokeStyle === 'string') {
1127
+ ctx.strokeStyle = strokeStyle
1128
+ ctx.lineWidth = 1
1129
+ ctx.stroke()
1130
+ }
1131
+ } catch { /* render 실패 시 빈 텍스처 */ }
1132
+ ctx.restore()
1133
+
1134
+ this._texture = new THREE.CanvasTexture(canvas)
1135
+ this._texture.colorSpace = THREE.SRGBColorSpace
1136
+
1137
+ const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2)
1138
+ geo.rotateX(-Math.PI / 2)
1139
+
1140
+ const mat = new THREE.MeshBasicMaterial({
1141
+ map: this._texture,
1142
+ alphaTest: 0.1,
1143
+ side: THREE.DoubleSide,
1144
+ })
1145
+
1146
+ this._mesh = new THREE.Mesh(geo, mat)
1147
+ this._mesh.position.y = 1
1148
+ this.object3d.add(this._mesh)
1149
+ }
1150
+
1151
+ update() {
1152
+ if (this._mesh) {
1153
+ this.object3d.remove(this._mesh)
1154
+ this._mesh.geometry.dispose()
1155
+ ;(this._mesh.material as THREE.Material).dispose()
1156
+ this._texture?.dispose()
1157
+ this._mesh = undefined
1158
+ this._texture = undefined
1159
+ }
1160
+ this.build()
1161
+ this.updateTransform()
1162
+ this.updateHidden()
1163
+ }
1164
+
1165
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
1166
+ if ('fillStyle' in after || 'strokeStyle' in after) {
1167
+ this.update()
1168
+ return
1169
+ }
1170
+ super.onchange(after, before)
1171
+ }
1172
+
1173
+ updateDimension() {}
1174
+ updateAlpha() {}
1175
+ }
762
1176
 
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)
1177
+ registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component))
1178
+
1179
+ // Scanner — 2D Triangle 실루엣과 정합되는 삼각형 prism.
1180
+ // 이송 표면 높이의 0.2배(=6) 얇은 기둥 — 원래 값 유지, shape 만 'box' → 'triangle'.
1181
+ const SCANNER_CONFIG: MCS3DConfig = {
1182
+ ...DEFAULTS,
1183
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2,
1184
+ defaultZPosFromParent: true,
1185
+ opacity: 1,
1186
+ metalness: 0.3,
1187
+ roughness: 0.7,
1188
+ defaultColor: 0x888888,
1189
+ shape: 'triangle',
1190
+ zPriority: Z_FMSIM.SCANNER,
1191
+ }
1192
+ registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))