@fmsim/machine 1.0.88 → 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 (165) hide show
  1. package/dist/agv-line.js.map +1 -1
  2. package/dist/agv.js +6 -2
  3. package/dist/agv.js.map +1 -1
  4. package/dist/buffer.js.map +1 -1
  5. package/dist/carrier-manager.js +183 -0
  6. package/dist/carrier-manager.js.map +1 -0
  7. package/dist/carrier.js +105 -14
  8. package/dist/carrier.js.map +1 -1
  9. package/dist/conveyor-join.js +8 -2
  10. package/dist/conveyor-join.js.map +1 -1
  11. package/dist/conveyor.js +9 -1
  12. package/dist/conveyor.js.map +1 -1
  13. package/dist/crane-rail.js +53 -0
  14. package/dist/crane-rail.js.map +1 -0
  15. package/dist/crane.js +144 -1
  16. package/dist/crane.js.map +1 -1
  17. package/dist/equipment.js.map +1 -1
  18. package/dist/factories/agv-3d.js +147 -0
  19. package/dist/factories/agv-3d.js.map +1 -0
  20. package/dist/factories/conveyor-3d.js +345 -0
  21. package/dist/factories/conveyor-3d.js.map +1 -0
  22. package/dist/factories/crane-3d.js +186 -0
  23. package/dist/factories/crane-3d.js.map +1 -0
  24. package/dist/factories/crane-rail-3d.js +95 -0
  25. package/dist/factories/crane-rail-3d.js.map +1 -0
  26. package/dist/factories/machine-3d.js +973 -0
  27. package/dist/factories/machine-3d.js.map +1 -0
  28. package/dist/factories/oht-3d.js +177 -0
  29. package/dist/factories/oht-3d.js.map +1 -0
  30. package/dist/factories/shuttle-3d.js +159 -0
  31. package/dist/factories/shuttle-3d.js.map +1 -0
  32. package/dist/factories/z-priority-defaults.js +33 -0
  33. package/dist/factories/z-priority-defaults.js.map +1 -0
  34. package/dist/factories/z-priority.js +54 -0
  35. package/dist/factories/z-priority.js.map +1 -0
  36. package/dist/features/conveyor-mixin.js.map +1 -1
  37. package/dist/features/mcs-status-default.js +15 -1
  38. package/dist/features/mcs-status-default.js.map +1 -1
  39. package/dist/features/mcs-status-mixin.js +22 -0
  40. package/dist/features/mcs-status-mixin.js.map +1 -1
  41. package/dist/features/mcs-zone-mixin.js.map +1 -1
  42. package/dist/features/parent-object-mixin.js +15 -7
  43. package/dist/features/parent-object-mixin.js.map +1 -1
  44. package/dist/floating-container.js +5 -0
  45. package/dist/floating-container.js.map +1 -1
  46. package/dist/index.js +58 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/mcs-carrier-holder.js +56 -17
  49. package/dist/mcs-carrier-holder.js.map +1 -1
  50. package/dist/mcs-machine.js +39 -0
  51. package/dist/mcs-machine.js.map +1 -1
  52. package/dist/mcs-unit.js +20 -0
  53. package/dist/mcs-unit.js.map +1 -1
  54. package/dist/mcs-vehicle.js +10 -4
  55. package/dist/mcs-vehicle.js.map +1 -1
  56. package/dist/node.js +10 -3
  57. package/dist/node.js.map +1 -1
  58. package/dist/oht-line.js.map +1 -1
  59. package/dist/oht.js +4 -0
  60. package/dist/oht.js.map +1 -1
  61. package/dist/port-flow.js +4 -0
  62. package/dist/port-flow.js.map +1 -1
  63. package/dist/port.js +4 -0
  64. package/dist/port.js.map +1 -1
  65. package/dist/scanner.js +30 -0
  66. package/dist/scanner.js.map +1 -0
  67. package/dist/scene/root-container-override.js +160 -96
  68. package/dist/scene/root-container-override.js.map +1 -1
  69. package/dist/shelf.js +4 -0
  70. package/dist/shelf.js.map +1 -1
  71. package/dist/shuttle.js.map +1 -1
  72. package/dist/static-container.js +5 -0
  73. package/dist/static-container.js.map +1 -1
  74. package/dist/stocker-abnormal-bar.js +184 -0
  75. package/dist/stocker-abnormal-bar.js.map +1 -0
  76. package/dist/stocker-capacity-bar.js +253 -6
  77. package/dist/stocker-capacity-bar.js.map +1 -1
  78. package/dist/stocker-carrier-bar.js +198 -0
  79. package/dist/stocker-carrier-bar.js.map +1 -0
  80. package/dist/stocker.js +7 -1
  81. package/dist/stocker.js.map +1 -1
  82. package/dist/templates/backdrop.js +30 -0
  83. package/dist/templates/backdrop.js.map +1 -0
  84. package/dist/templates/decal.js +21 -0
  85. package/dist/templates/decal.js.map +1 -0
  86. package/dist/templates/index.js +7 -1
  87. package/dist/templates/index.js.map +1 -1
  88. package/dist/templates/scanner.js +20 -0
  89. package/dist/templates/scanner.js.map +1 -0
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/dist/utils/crane-position.js +83 -0
  92. package/dist/utils/crane-position.js.map +1 -0
  93. package/dist/utils/data-value.js +21 -0
  94. package/dist/utils/data-value.js.map +1 -0
  95. package/dist/utils/fit-gauge-scale.js +28 -0
  96. package/dist/utils/fit-gauge-scale.js.map +1 -0
  97. package/dist/utils/get-value-on-ranges.js +2 -4
  98. package/dist/utils/get-value-on-ranges.js.map +1 -1
  99. package/dist/utils/stocker-gauge.js +78 -0
  100. package/dist/utils/stocker-gauge.js.map +1 -0
  101. package/dist/zone-capacity-bar.js +3 -3
  102. package/dist/zone-capacity-bar.js.map +1 -1
  103. package/icons/backdrop.png +0 -0
  104. package/icons/decal.png +0 -0
  105. package/jest.config.js +10 -0
  106. package/package.json +7 -6
  107. package/src/agv-line.ts +1 -1
  108. package/src/agv.ts +10 -5
  109. package/src/buffer.ts +1 -1
  110. package/src/carrier-manager.ts +223 -0
  111. package/src/carrier.ts +111 -18
  112. package/src/conveyor-join.ts +6 -3
  113. package/src/conveyor.ts +10 -2
  114. package/src/crane-rail.ts +58 -0
  115. package/src/crane.ts +182 -2
  116. package/src/equipment.ts +1 -1
  117. package/src/factories/agv-3d.ts +160 -0
  118. package/src/factories/conveyor-3d.ts +398 -0
  119. package/src/factories/crane-3d.ts +217 -0
  120. package/src/factories/crane-rail-3d.ts +115 -0
  121. package/src/factories/machine-3d.ts +1192 -0
  122. package/src/factories/oht-3d.ts +196 -0
  123. package/src/factories/shuttle-3d.ts +179 -0
  124. package/src/factories/z-priority-defaults.ts +38 -0
  125. package/src/factories/z-priority.ts +60 -0
  126. package/src/features/conveyor-mixin.ts +1 -1
  127. package/src/features/mcs-status-default.ts +15 -1
  128. package/src/features/mcs-status-mixin.ts +26 -4
  129. package/src/features/mcs-zone-mixin.ts +1 -1
  130. package/src/features/parent-object-mixin.ts +17 -8
  131. package/src/floating-container.ts +5 -0
  132. package/src/index.ts +66 -2
  133. package/src/mcs-carrier-holder.ts +58 -29
  134. package/src/mcs-machine.ts +43 -1
  135. package/src/mcs-unit.ts +25 -2
  136. package/src/mcs-vehicle.ts +12 -9
  137. package/src/node.ts +9 -3
  138. package/src/oht-line.ts +1 -1
  139. package/src/oht.ts +7 -2
  140. package/src/port-flow.ts +6 -1
  141. package/src/port.ts +6 -1
  142. package/src/scanner.ts +29 -0
  143. package/src/scene/root-container-override.ts +156 -105
  144. package/src/shelf.ts +6 -1
  145. package/src/shuttle.ts +1 -1
  146. package/src/static-container.ts +5 -0
  147. package/src/stocker-abnormal-bar.ts +247 -0
  148. package/src/stocker-capacity-bar.ts +301 -10
  149. package/src/stocker-carrier-bar.ts +262 -0
  150. package/src/stocker.ts +10 -3
  151. package/src/templates/backdrop.ts +30 -0
  152. package/src/templates/decal.ts +21 -0
  153. package/src/templates/index.ts +7 -1
  154. package/src/templates/scanner.ts +20 -0
  155. package/src/utils/crane-position.ts +105 -0
  156. package/src/utils/data-value.ts +19 -0
  157. package/src/utils/fit-gauge-scale.ts +35 -0
  158. package/src/utils/get-value-on-ranges.ts +2 -4
  159. package/src/utils/stocker-gauge.ts +111 -0
  160. package/src/zone-capacity-bar.ts +5 -5
  161. package/translations/en.json +26 -1
  162. package/translations/ja.json +26 -1
  163. package/translations/ko.json +26 -1
  164. package/translations/ms.json +26 -1
  165. package/translations/zh.json +26 -1
@@ -0,0 +1,1192 @@
1
+ /**
2
+ * MCS 설비 컴포넌트 3D 팩토리
3
+ *
4
+ * 10,000+ 인스턴스를 위한 최소 폴리곤 설계:
5
+ * - 컴포넌트당 단일 Mesh (draw call 최소화)
6
+ * - BoxGeometry / ExtrudeGeometry(hexagon) 만 사용
7
+ * - Machine: 높은 depth, 컨테이너는 반투명
8
+ * - Unit: 낮은 depth, 불투명
9
+ */
10
+
11
+ import * as THREE from 'three'
12
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
13
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
14
+ import type { Component } from '@hatiolab/things-scene'
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'
25
+
26
+ // ── 설정 타입 ────────────────────────────────────────────
27
+
28
+ interface MCS3DConfig {
29
+ /** state.depth 미설정 시 기본 depth.
30
+ * defaultDepth > 0이면 절대값, 아니면 min(w,h) * depthRatio */
31
+ depthRatio: number
32
+ defaultDepth?: number
33
+ /** true이면 syncZPosOffset = TRANSPORT_SURFACE_HEIGHT */
34
+ defaultZPosFromParent?: boolean
35
+ /** 1.0 = 불투명, 0.35 = 반투명 컨테이너 */
36
+ opacity: number
37
+ metalness: number
38
+ roughness: number
39
+ /** statusColor 미사용 시 기본 색상 */
40
+ defaultColor: number
41
+ /** 'box' | 'hexagon' | 'plane' | 'triangle' */
42
+ shape: 'box' | 'hexagon' | 'plane' | 'triangle'
43
+ /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
44
+ isVisible?: (component: Component) => boolean
45
+ /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
46
+ sizeRatio?: number
47
+ /** 상부 면 제거 (5면 개방 박스) */
48
+ openTop?: boolean
49
+ /** 상판 무반사 (MeshBasicMaterial) */
50
+ matteTop?: boolean
51
+ /** 상판에 2D 렌더링 텍스처 적용 */
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
61
+ }
62
+
63
+ /**
64
+ * 포트 기본 높이 (절대값).
65
+ * 모든 이송장치의 상단 높이 = 이 값.
66
+ * 캐리어가 이송장치 간 높이 차 없이 이동할 수 있는 기준 높이.
67
+ */
68
+ export const TRANSPORT_SURFACE_HEIGHT = 30
69
+
70
+ const DEFAULTS: MCS3DConfig = {
71
+ depthRatio: 0.5,
72
+ opacity: 1.0,
73
+ metalness: 0.3,
74
+ roughness: 0.7,
75
+ defaultColor: 0xaabbcc,
76
+ shape: 'box',
77
+ }
78
+
79
+ // ── 3D 오브젝트 ──────────────────────────────────────────
80
+
81
+ class MCSRealObject3D extends RealObjectGroup {
82
+ private _config: MCS3DConfig
83
+
84
+ constructor(component: Component, config: MCS3DConfig) {
85
+ super(component)
86
+ this._config = config
87
+ }
88
+
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.
100
+ protected get absSize(): { w: number; h: number } {
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)
105
+ return { w: Math.abs(width), h: Math.abs(height) }
106
+ }
107
+
108
+ get effectiveDepth(): number {
109
+ const { depth } = this.component.state
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
+ }
116
+ if (this._config.defaultDepth) return this._config.defaultDepth
117
+ const { w, h } = this.absSize
118
+ return Math.min(w, h) * this._config.depthRatio
119
+ }
120
+
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
+ }
130
+ // Port: 이송 표면 높이에 배치
131
+ if (this._config.defaultZPosFromParent) {
132
+ return TRANSPORT_SURFACE_HEIGHT + halfD
133
+ }
134
+ // Carrier: 부모 유형에 따라 배치 위치 결정
135
+ if (this._config.sizeRatio && this._config.sizeRatio < 1) {
136
+ const parentType = this.component.parent?.state?.type
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
143
+ }
144
+ return TRANSPORT_SURFACE_HEIGHT + halfD
145
+ }
146
+ return halfD
147
+ }
148
+
149
+ // geometric 부분(center origin: depth/2)은 base 기본값 사용 — override 불필요
150
+
151
+ // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
152
+ protected resolveColor(): number {
153
+ const comp = this.component as any
154
+
155
+ // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
156
+ // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
157
+ try {
158
+ const sc = comp.statusColor
159
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex()
160
+ } catch {
161
+ /* statusColor 미구현 시 무시 */
162
+ }
163
+
164
+ // 2) state.fillStyle fallback
165
+ const { fillStyle } = this.component.state
166
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
167
+ try {
168
+ return new THREE.Color(fillStyle).getHex()
169
+ } catch {
170
+ /* 파싱 실패 시 무시 */
171
+ }
172
+ }
173
+
174
+ return this._config.defaultColor
175
+ }
176
+
177
+ // ── Geometry helpers ──
178
+
179
+ /**
180
+ * 바닥면(-Y 방향) 삼각형 제거: z-fighting 근본 해결.
181
+ * 바닥면이 없으면 floor grid와 겹칠 면 자체가 없어진다.
182
+ */
183
+ private removeBottomFace(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
184
+ const posAttr = geometry.getAttribute('position')
185
+ const normAttr = geometry.getAttribute('normal')
186
+ const index = geometry.getIndex()
187
+
188
+ if (!posAttr || !normAttr || !index) return geometry
189
+
190
+ const newIndices: number[] = []
191
+ const normalY = new THREE.Vector3()
192
+
193
+ for (let i = 0; i < index.count; i += 3) {
194
+ const a = index.getX(i)
195
+ const b = index.getX(i + 1)
196
+ const c = index.getX(i + 2)
197
+
198
+ // 세 꼭짓점 노말의 Y 성분 평균으로 방향 판단
199
+ const avgY = (normAttr.getY(a) + normAttr.getY(b) + normAttr.getY(c)) / 3
200
+
201
+ // -Y 방향 삼각형 제거 (threshold -0.9: 거의 아래를 향하는 면만)
202
+ if (avgY > -0.9) {
203
+ newIndices.push(a, b, c)
204
+ }
205
+ }
206
+
207
+ geometry.setIndex(newIndices)
208
+ return geometry
209
+ }
210
+
211
+ // ── Geometry builders ──
212
+
213
+ private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
214
+ const geo = new THREE.BoxGeometry(w, d, h)
215
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
216
+ }
217
+
218
+ private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
219
+ // Buffer의 2D 형태: 좌우 끝이 꺾인 육각형
220
+ const offset = Math.min(w, h) * 0.2
221
+ const hw = w / 2
222
+ const hh = h / 2
223
+
224
+ const shape = new THREE.Shape()
225
+ shape.moveTo(-hw + offset, -hh)
226
+ shape.lineTo(hw - offset, -hh)
227
+ shape.lineTo(hw, 0)
228
+ shape.lineTo(hw - offset, hh)
229
+ shape.lineTo(-hw + offset, hh)
230
+ shape.lineTo(-hw, 0)
231
+ shape.closePath()
232
+
233
+ const geo = new THREE.ExtrudeGeometry(shape, {
234
+ steps: 1,
235
+ depth: d,
236
+ bevelEnabled: false,
237
+ })
238
+
239
+ // 중심 정렬 후 Y-up 좌표계 회전 (extrude 축: +Z → +Y)
240
+ geo.translate(0, 0, -d / 2)
241
+ geo.rotateX(Math.PI / 2)
242
+
243
+ return this.removeBottomFace(geo)
244
+ }
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
+
283
+ // ── Build ──
284
+
285
+ protected _ownMesh?: THREE.Mesh
286
+
287
+ build() {
288
+ super.build()
289
+
290
+ // 가시성 판정 — e.g., Carrier는 데이터 없으면 mesh 미생성
291
+ if (this._config.isVisible && !this._config.isVisible(this.component)) return
292
+
293
+ const { w: rawW, h: rawH } = this.absSize
294
+ const rawDepth = this.effectiveDepth
295
+ if (!rawW || !rawH) return
296
+
297
+ const ratio = this._config.sizeRatio ?? 1.0
298
+ const w = rawW * ratio
299
+ const h = rawH * ratio
300
+ const depth = rawDepth * ratio
301
+
302
+ const color = this.resolveColor()
303
+ const { opacity, metalness, roughness, shape } = this._config
304
+
305
+ const geometry =
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
+ })
331
+ // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
332
+ material.polygonOffset = true
333
+ material.polygonOffsetFactor = -1
334
+ material.polygonOffsetUnit = -1
335
+
336
+ // BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5)
337
+ let meshMaterial: THREE.Material | THREE.Material[] = material
338
+ if (shape === 'box') {
339
+ if (this._config.openTop) {
340
+ // 상단면 제거 → 5면 개방 박스
341
+ const invisible = new THREE.MeshBasicMaterial({ visible: false })
342
+ meshMaterial = [material, material, invisible, material, material, material]
343
+ } else if (this._config.texturedTop) {
344
+ // 상판에 2D 렌더링 텍스처
345
+ const topMat = this.buildTopTexture(w, h)
346
+ meshMaterial = [material, material, topMat, material, material, material]
347
+ } else if (this._config.matteTop) {
348
+ // 상판만 무반사
349
+ const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 })
350
+ meshMaterial = [material, material, matte, material, material, material]
351
+ }
352
+ }
353
+
354
+ this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
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
361
+ this._ownMesh.receiveShadow = true
362
+ this.components3D.add(this._ownMesh)
363
+ }
364
+
365
+ // ── Change handling ──
366
+
367
+ private _updatePending = false
368
+
369
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
370
+ if (
371
+ 'width' in after ||
372
+ 'height' in after ||
373
+ 'depth' in after ||
374
+ 'fillStyle' in after ||
375
+ 'strokeStyle' in after ||
376
+ 'id' in after ||
377
+ 'EMPTYTYPE' in after ||
378
+ 'CARRIERSTATUS' in after
379
+ ) {
380
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
381
+ if (!this._updatePending) {
382
+ this._updatePending = true
383
+ queueMicrotask(() => {
384
+ this._updatePending = false
385
+ this.update()
386
+ })
387
+ }
388
+ return
389
+ }
390
+
391
+ super.onchange(after, before)
392
+ }
393
+
394
+ /**
395
+ * 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용
396
+ */
397
+ protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial {
398
+ const scale = 4
399
+ const cw = this.nextPow2(w * scale)
400
+ const ch = this.nextPow2(h * scale)
401
+ const canvas = document.createElement('canvas')
402
+ canvas.width = cw
403
+ canvas.height = ch
404
+ const ctx = canvas.getContext('2d')!
405
+
406
+ const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0')
407
+ const emptyType = (this.component.state as any)?.EMPTYTYPE || ''
408
+ const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : ''
409
+
410
+ // 실제 사용 영역 (nextPow2 이전 크기)
411
+ const usedW = w * scale
412
+ const usedH = h * scale
413
+
414
+ // 바탕: 캐리어 상태 색상 (사용 영역만)
415
+ ctx.fillStyle = statusColor
416
+ ctx.fillRect(0, 0, usedW, usedH)
417
+
418
+ // 텍스트 (사용 영역 중앙)
419
+ if (text) {
420
+ const fontSize = Math.round(Math.min(usedW, usedH) * 0.7)
421
+ ctx.font = `bold ${fontSize}px Arial`
422
+ ctx.textAlign = 'center'
423
+ ctx.textBaseline = 'middle'
424
+ ctx.fillStyle = '#000000'
425
+ ctx.fillText(text, usedW / 2, usedH / 2)
426
+ }
427
+
428
+ const texture = new THREE.CanvasTexture(canvas)
429
+ texture.offset.set(0, 1 - usedH / ch)
430
+ texture.repeat.set(usedW / cw, usedH / ch)
431
+ texture.colorSpace = THREE.SRGBColorSpace
432
+
433
+ return new THREE.MeshBasicMaterial({
434
+ map: texture,
435
+ transparent: true,
436
+ side: THREE.DoubleSide,
437
+ })
438
+ }
439
+
440
+ private nextPow2(n: number): number {
441
+ if (!(n > 0)) return 1
442
+ let v = Math.ceil(n)
443
+ v--
444
+ v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16
445
+ return v + 1
446
+ }
447
+
448
+ /**
449
+ * 자신의 mesh만 제거 후 rebuild.
450
+ * 기본 update()는 clear()로 object3d의 모든 children을 제거하는데,
451
+ * 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨.
452
+ * 이를 방지하기 위해 자신이 생성한 mesh만 제거한다.
453
+ */
454
+ update() {
455
+ // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
456
+ if (this._ownMesh) {
457
+ this.components3D.remove(this._ownMesh)
458
+ disposeObject3D(this._ownMesh)
459
+ this._ownMesh = undefined
460
+ }
461
+
462
+ this.build()
463
+ this.updateTransform()
464
+ this.updateAlpha()
465
+ this.updateHidden()
466
+ }
467
+
468
+ // RealObjectGroup 기본 동작 비활성화
469
+ updateDimension() {}
470
+
471
+ updateAlpha() {
472
+ const state = this.component.state
473
+ // alpha가 명시적으로 설정되어 있으면 그 값을 사용, 없으면 config 기본값 적용
474
+ const effective = ('alpha' in state) ? (state.alpha as number) : this._config.opacity
475
+
476
+ this.object3d.traverse((o: any) => {
477
+ if (!o.material) return
478
+ const materials = Array.isArray(o.material) ? o.material : [o.material]
479
+ for (const m of materials) {
480
+ if (!m.visible) continue // openTop invisible face 무시
481
+ if (m.map) continue // 텍스처 material은 자체 transparent 유지
482
+ m.opacity = effective
483
+ m.transparent = effective < 1
484
+ }
485
+ })
486
+ }
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
499
+ }
500
+ return 0
501
+ }
502
+
503
+ // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
504
+
505
+ function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
506
+ const config: MCS3DConfig = { ...DEFAULTS, ...overrides }
507
+ registerRealObjectFactory(type, (component: Component) => new MCSRealObject3D(component, config))
508
+ }
509
+
510
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
511
+ // Machine (인프라 설비) — 높은 depth, 컨테이너는 반투명
512
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
513
+
514
+ register('Equipment', {
515
+ defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT,
516
+ opacity: 0.75,
517
+ metalness: 0.4,
518
+ roughness: 0.6,
519
+ defaultColor: 0x8899aa,
520
+ zPriority: Z_FMSIM.EQUIPMENT,
521
+ })
522
+
523
+ register('Buffer', {
524
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT,
525
+ opacity: 1.0,
526
+ metalness: 0.3,
527
+ roughness: 0.7,
528
+ defaultColor: 0x99aa88,
529
+ shape: 'hexagon',
530
+ zPriority: Z_FMSIM.BUFFER,
531
+ })
532
+
533
+ register('AGVLine', {
534
+ depthRatio: 0,
535
+ defaultDepth: 0.1,
536
+ metalness: 0.6,
537
+ roughness: 0.3,
538
+ defaultColor: 0x888899,
539
+ zPriority: Z_FMSIM.AGV_LINE,
540
+ })
541
+
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))
636
+
637
+ // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
638
+ const STOCKER_CONFIG: MCS3DConfig = {
639
+ ...DEFAULTS,
640
+ defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT,
641
+ opacity: 0.75,
642
+ metalness: 0.3,
643
+ roughness: 0.6,
644
+ defaultColor: 0x8899bb,
645
+ zPriority: Z_FMSIM.STOCKER,
646
+ }
647
+
648
+ class StockerRealObject3D extends MCSRealObject3D {
649
+ private gaugeMesh?: THREE.Mesh
650
+
651
+ constructor(component: Component) {
652
+ super(component, STOCKER_CONFIG)
653
+ }
654
+
655
+ build() {
656
+ super.build()
657
+ this.buildGauge()
658
+ }
659
+
660
+ private buildGauge() {
661
+ const comp = this.component as any
662
+ const data = comp.data || {}
663
+ const gaugeMode = comp.root?.stockerGaugeMode || 'capacity'
664
+
665
+ const gauge = this.resolveGauge(data, gaugeMode)
666
+
667
+ // 테두리는 스토커의 상태 색상 (machine status)
668
+ const statusColor = comp.statusColor
669
+ if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') {
670
+ gauge.borderColor = statusColor
671
+ }
672
+
673
+ const { w, h } = this.absSize
674
+ const depth = this.effectiveDepth
675
+ const isWidthLonger = w >= h
676
+
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
688
+ const scale = 4
689
+ const canvasW = MAX_BAR_W * scale
690
+ const canvasH = MAX_BAR_H * scale
691
+ const canvas = document.createElement('canvas')
692
+ canvas.width = canvasW
693
+ canvas.height = canvasH
694
+ const ctx = canvas.getContext('2d')!
695
+
696
+ const pad = 12
697
+
698
+ // 배경 + 테두리
699
+ ctx.fillStyle = '#dddddd'
700
+ ctx.fillRect(0, 0, canvasW, canvasH)
701
+ ctx.strokeStyle = gauge.borderColor
702
+ ctx.lineWidth = 12
703
+ ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
704
+
705
+ // 세그먼트 채움 (왼→오) + carrier/abnormal 은 세그먼트별 숫자(%) 표기 (2D 와 동일)
706
+ const innerW = canvasW - pad * 2
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)
711
+ let x = pad
712
+ for (const seg of gauge.segments) {
713
+ const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
714
+ if (segW <= 0) continue
715
+ ctx.fillStyle = seg.color
716
+ ctx.fillRect(x, pad, segW, innerH)
717
+ if (gauge.perSegment) {
718
+ this.drawGaugeSegmentNumber(ctx, seg.ratio, x, pad, segW, innerH, font, fontSize)
719
+ }
720
+ x += segW
721
+ }
722
+
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
+ }
731
+
732
+ // 텍스처 → Plane
733
+ const texture = new THREE.CanvasTexture(canvas)
734
+ texture.colorSpace = THREE.SRGBColorSpace
735
+
736
+ const geo = new THREE.PlaneGeometry(barW, barH)
737
+ geo.rotateX(-Math.PI / 2) // 수평 (지붕 위)
738
+ if (!isWidthLonger) geo.rotateY(Math.PI / 2)
739
+
740
+ const mat = new THREE.MeshBasicMaterial({
741
+ map: texture,
742
+ transparent: true,
743
+ side: THREE.DoubleSide,
744
+ })
745
+ mat.polygonOffset = true
746
+ mat.polygonOffsetFactor = -2
747
+ mat.polygonOffsetUnit = -2
748
+
749
+ this.gaugeMesh = new THREE.Mesh(geo, mat)
750
+ // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움.
751
+ this.gaugeMesh.position.set(0, depth + 0.5, 0)
752
+ this.components3D.add(this.gaugeMesh)
753
+ }
754
+
755
+ private resolveGauge(
756
+ data: any,
757
+ mode: string
758
+ ): { segments: { ratio: number; color: string }[]; borderColor: string; label: string | null; perSegment: boolean } {
759
+ switch (mode) {
760
+ case 'carrier': {
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 }
775
+ }
776
+ case 'abnormal': {
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
+ }
792
+ }
793
+ default: { // capacity — 전체 중앙에 usage% 한 개
794
+ const cur = Number(data.CURRENTCAPACITY) || 0
795
+ const max = Number(data.MAXCAPACITY) || 100
796
+ const fullup = data.FULLUP === 'T'
797
+ const usage = Math.round((cur / max) * 100)
798
+ const legend = (this.component as any).root?.stockerCapacityLegendTheme || LEGEND_CAPACITY
799
+ const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
800
+ return {
801
+ segments: [{ ratio: usage / 100, color }],
802
+ borderColor: color,
803
+ label: `${usage}%`,
804
+ perSegment: false,
805
+ }
806
+ }
807
+ }
808
+ }
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
+
851
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
852
+ if ('data' in after) {
853
+ this.clearGauge()
854
+ this.buildGauge()
855
+ }
856
+ super.onchange(after, before)
857
+ }
858
+
859
+ clear() {
860
+ this.clearGauge()
861
+ return super.clear()
862
+ }
863
+
864
+ private clearGauge() {
865
+ if (this.gaugeMesh) {
866
+ this.components3D.remove(this.gaugeMesh)
867
+ this.gaugeMesh.geometry?.dispose()
868
+ const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
869
+ mat?.map?.dispose()
870
+ mat?.dispose()
871
+ this.gaugeMesh = undefined
872
+ }
873
+ }
874
+ }
875
+
876
+ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component))
877
+
878
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
879
+ // Unit (이동체 · 홀더) — 낮은 depth, 불투명
880
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
881
+
882
+ // Port: 바닥판 (PlaneGeometry)
883
+ const PORT_CONFIG: MCS3DConfig = {
884
+ ...DEFAULTS,
885
+ depthRatio: 0.01,
886
+ defaultZPosFromParent: true,
887
+ opacity: 0.1,
888
+ metalness: 0.3,
889
+ roughness: 0.8,
890
+ defaultColor: 0xb0a090,
891
+ shape: 'plane',
892
+ zPriority: Z_FMSIM.PORT,
893
+ }
894
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
895
+
896
+ register('Shelf', {
897
+ depthRatio: 0.25,
898
+ metalness: 0.3,
899
+ roughness: 0.7,
900
+ defaultColor: 0xaabbcc,
901
+ zPriority: Z_FMSIM.SHELF,
902
+ })
903
+
904
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
905
+ const CARRIER_CONFIG: MCS3DConfig = {
906
+ ...DEFAULTS,
907
+ depthRatio: 0.5,
908
+ metalness: 0,
909
+ roughness: 1,
910
+ defaultColor: 0xf0f0f0,
911
+ isVisible: (c: Component) => !!(c.state as any).id,
912
+ sizeRatio: 0.8,
913
+ texturedTop: true,
914
+ // Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정.
915
+ }
916
+
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
924
+
925
+ class CarrierRealObject3D extends MCSRealObject3D {
926
+ constructor(component: Component) {
927
+ super(component, CARRIER_CONFIG)
928
+ }
929
+
930
+ /**
931
+ * cz override — *carrier 자기 결정* 의 적층 위치.
932
+ * parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth
933
+ * 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고,
934
+ * carrier 가 자기 stack 위치를 알아서 결정.
935
+ */
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
+ }
960
+
961
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
962
+
963
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
964
+ // Gauge (용량 게이지) — 얇은 바
965
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
966
+
967
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
968
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
969
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
970
+
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)
976
+
977
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
978
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
979
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
980
+
981
+ class PortFlowRealObject3D extends RealObjectGroup {
982
+ private _mesh?: THREE.Mesh
983
+ private _texture?: THREE.CanvasTexture
984
+
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
+ }
993
+
994
+ build() {
995
+ super.build()
996
+
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
1016
+ }
1017
+ }
1018
+
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')!
1026
+
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()
1041
+
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)
1060
+ }
1061
+
1062
+ 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()
1074
+ }
1075
+
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)
1083
+ }
1084
+
1085
+ updateDimension() {}
1086
+ updateAlpha() {}
1087
+ }
1088
+
1089
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
1090
+
1091
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1092
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
1093
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1094
+
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
+ }
1176
+
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))