@fmsim/machine 1.0.87 → 2.0.0-beta.10

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 (84) hide show
  1. package/dist/agv-line.js.map +1 -1
  2. package/dist/agv.js.map +1 -1
  3. package/dist/buffer.js.map +1 -1
  4. package/dist/carrier.js +7 -6
  5. package/dist/carrier.js.map +1 -1
  6. package/dist/conveyor-join.js.map +1 -1
  7. package/dist/conveyor.js +1 -1
  8. package/dist/conveyor.js.map +1 -1
  9. package/dist/crane-rail.js +53 -0
  10. package/dist/crane-rail.js.map +1 -0
  11. package/dist/crane.js.map +1 -1
  12. package/dist/equipment.js.map +1 -1
  13. package/dist/factories/agv-3d.js +148 -0
  14. package/dist/factories/agv-3d.js.map +1 -0
  15. package/dist/factories/conveyor-3d.js +258 -0
  16. package/dist/factories/conveyor-3d.js.map +1 -0
  17. package/dist/factories/crane-3d.js +150 -0
  18. package/dist/factories/crane-3d.js.map +1 -0
  19. package/dist/factories/crane-rail-3d.js +95 -0
  20. package/dist/factories/crane-rail-3d.js.map +1 -0
  21. package/dist/factories/machine-3d.js +627 -0
  22. package/dist/factories/machine-3d.js.map +1 -0
  23. package/dist/factories/oht-3d.js +177 -0
  24. package/dist/factories/oht-3d.js.map +1 -0
  25. package/dist/factories/shuttle-3d.js +162 -0
  26. package/dist/factories/shuttle-3d.js.map +1 -0
  27. package/dist/index.js +38 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcs-carrier-holder.js +6 -1
  30. package/dist/mcs-carrier-holder.js.map +1 -1
  31. package/dist/mcs-machine.js.map +1 -1
  32. package/dist/mcs-unit.js.map +1 -1
  33. package/dist/mcs-vehicle.js +2 -1
  34. package/dist/mcs-vehicle.js.map +1 -1
  35. package/dist/oht-line.js.map +1 -1
  36. package/dist/oht.js.map +1 -1
  37. package/dist/port-flow.js.map +1 -1
  38. package/dist/port.js.map +1 -1
  39. package/dist/scene/root-container-override.js.map +1 -1
  40. package/dist/shelf.js.map +1 -1
  41. package/dist/shuttle.js.map +1 -1
  42. package/dist/stocker-abnormal-bar.js +180 -0
  43. package/dist/stocker-abnormal-bar.js.map +1 -0
  44. package/dist/stocker-capacity-bar.js.map +1 -1
  45. package/dist/stocker-carrier-bar.js +194 -0
  46. package/dist/stocker-carrier-bar.js.map +1 -0
  47. package/dist/stocker.js +1 -1
  48. package/dist/stocker.js.map +1 -1
  49. package/dist/tsconfig.tsbuildinfo +1 -1
  50. package/dist/zone-capacity-bar.js.map +1 -1
  51. package/package.json +6 -6
  52. package/src/agv-line.ts +1 -1
  53. package/src/agv.ts +3 -3
  54. package/src/buffer.ts +1 -1
  55. package/src/carrier.ts +11 -11
  56. package/src/conveyor-join.ts +1 -1
  57. package/src/conveyor.ts +2 -2
  58. package/src/crane-rail.ts +58 -0
  59. package/src/crane.ts +1 -1
  60. package/src/equipment.ts +1 -1
  61. package/src/factories/agv-3d.ts +161 -0
  62. package/src/factories/conveyor-3d.ts +311 -0
  63. package/src/factories/crane-3d.ts +176 -0
  64. package/src/factories/crane-rail-3d.ts +114 -0
  65. package/src/factories/machine-3d.ts +767 -0
  66. package/src/factories/oht-3d.ts +196 -0
  67. package/src/factories/shuttle-3d.ts +183 -0
  68. package/src/index.ts +40 -0
  69. package/src/mcs-carrier-holder.ts +8 -2
  70. package/src/mcs-machine.ts +1 -1
  71. package/src/mcs-unit.ts +1 -1
  72. package/src/mcs-vehicle.ts +3 -3
  73. package/src/oht-line.ts +1 -1
  74. package/src/oht.ts +1 -1
  75. package/src/port-flow.ts +1 -1
  76. package/src/port.ts +1 -1
  77. package/src/scene/root-container-override.ts +2 -2
  78. package/src/shelf.ts +1 -1
  79. package/src/shuttle.ts +1 -1
  80. package/src/stocker-abnormal-bar.ts +242 -0
  81. package/src/stocker-capacity-bar.ts +3 -3
  82. package/src/stocker-carrier-bar.ts +257 -0
  83. package/src/stocker.ts +3 -3
  84. package/src/zone-capacity-bar.ts +2 -2
@@ -0,0 +1,767 @@
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 { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
13
+ 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'
16
+
17
+ // ── 설정 타입 ────────────────────────────────────────────
18
+
19
+ interface MCS3DConfig {
20
+ /** state.depth 미설정 시 기본 depth.
21
+ * defaultDepth > 0이면 절대값, 아니면 min(w,h) * depthRatio */
22
+ depthRatio: number
23
+ defaultDepth?: number
24
+ /** true이면 syncZPosOffset = TRANSPORT_SURFACE_HEIGHT */
25
+ defaultZPosFromParent?: boolean
26
+ /** 1.0 = 불투명, 0.35 = 반투명 컨테이너 */
27
+ opacity: number
28
+ metalness: number
29
+ roughness: number
30
+ /** statusColor 미사용 시 기본 색상 */
31
+ defaultColor: number
32
+ /** 'box' | 'hexagon' */
33
+ shape: 'box' | 'hexagon'
34
+ /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
35
+ isVisible?: (component: Component) => boolean
36
+ /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
37
+ sizeRatio?: number
38
+ /** 상부 면 제거 (5면 개방 박스) */
39
+ openTop?: boolean
40
+ /** 상판 무반사 (MeshBasicMaterial) */
41
+ matteTop?: boolean
42
+ /** 상판에 2D 렌더링 텍스처 적용 */
43
+ texturedTop?: boolean
44
+ }
45
+
46
+ /**
47
+ * 포트 기본 높이 (절대값).
48
+ * 모든 이송장치의 상단 높이 = 이 값.
49
+ * 캐리어가 이송장치 간 높이 차 없이 이동할 수 있는 기준 높이.
50
+ */
51
+ export const TRANSPORT_SURFACE_HEIGHT = 30
52
+
53
+ const DEFAULTS: MCS3DConfig = {
54
+ depthRatio: 0.5,
55
+ opacity: 1.0,
56
+ metalness: 0.3,
57
+ roughness: 0.7,
58
+ defaultColor: 0xaabbcc,
59
+ shape: 'box',
60
+ }
61
+
62
+ // ── 3D 오브젝트 ──────────────────────────────────────────
63
+
64
+ class MCSRealObject3D extends RealObjectGroup {
65
+ private _config: MCS3DConfig
66
+
67
+ constructor(component: Component, config: MCS3DConfig) {
68
+ super(component)
69
+ this._config = config
70
+ }
71
+
72
+ // width, height 절대값 (음수 값은 양수로 취급)
73
+ protected get absSize(): { w: number; h: number } {
74
+ const { width = 10, height = 10 } = this.component.state
75
+ return { w: Math.abs(width), h: Math.abs(height) }
76
+ }
77
+
78
+ get effectiveDepth(): number {
79
+ const { depth } = this.component.state
80
+ if (depth) return depth
81
+ if (this._config.defaultDepth) return this._config.defaultDepth
82
+ const { w, h } = this.absSize
83
+ return Math.min(w, h) * this._config.depthRatio
84
+ }
85
+
86
+ protected get syncZPosOffset(): number {
87
+ // Port: 이송 표면 높이에 배치
88
+ if (this._config.defaultZPosFromParent) {
89
+ return TRANSPORT_SURFACE_HEIGHT
90
+ }
91
+ // Carrier: Port 안이면 중앙, 그 외 컨테이너면 상단 위
92
+ if (this._config.sizeRatio && this._config.sizeRatio < 1) {
93
+ const parentType = this.component.parent?.state?.type
94
+ if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'OHT' || parentType === 'Shuttle') {
95
+ return 0
96
+ }
97
+ return TRANSPORT_SURFACE_HEIGHT
98
+ }
99
+ return 0
100
+ }
101
+
102
+ // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
103
+ protected resolveColor(): number {
104
+ const comp = this.component as any
105
+
106
+ // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
107
+ // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
108
+ try {
109
+ const sc = comp.statusColor
110
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0') return new THREE.Color(sc).getHex()
111
+ } catch {
112
+ /* statusColor 미구현 시 무시 */
113
+ }
114
+
115
+ // 2) state.fillStyle fallback
116
+ const { fillStyle } = this.component.state
117
+ if (fillStyle && typeof fillStyle === 'string') {
118
+ try {
119
+ return new THREE.Color(fillStyle).getHex()
120
+ } catch {
121
+ /* 파싱 실패 시 무시 */
122
+ }
123
+ }
124
+
125
+ return this._config.defaultColor
126
+ }
127
+
128
+ // ── Geometry helpers ──
129
+
130
+ /**
131
+ * 바닥면(-Y 방향) 삼각형 제거: z-fighting 근본 해결.
132
+ * 바닥면이 없으면 floor grid와 겹칠 면 자체가 없어진다.
133
+ */
134
+ private removeBottomFace(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
135
+ const posAttr = geometry.getAttribute('position')
136
+ const normAttr = geometry.getAttribute('normal')
137
+ const index = geometry.getIndex()
138
+
139
+ if (!posAttr || !normAttr || !index) return geometry
140
+
141
+ const newIndices: number[] = []
142
+ const normalY = new THREE.Vector3()
143
+
144
+ for (let i = 0; i < index.count; i += 3) {
145
+ const a = index.getX(i)
146
+ const b = index.getX(i + 1)
147
+ const c = index.getX(i + 2)
148
+
149
+ // 세 꼭짓점 노말의 Y 성분 평균으로 방향 판단
150
+ const avgY = (normAttr.getY(a) + normAttr.getY(b) + normAttr.getY(c)) / 3
151
+
152
+ // -Y 방향 삼각형 제거 (threshold -0.9: 거의 아래를 향하는 면만)
153
+ if (avgY > -0.9) {
154
+ newIndices.push(a, b, c)
155
+ }
156
+ }
157
+
158
+ geometry.setIndex(newIndices)
159
+ return geometry
160
+ }
161
+
162
+ // ── Geometry builders ──
163
+
164
+ private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
165
+ const geo = new THREE.BoxGeometry(w, d, h)
166
+ return this.removeBottomFace(geo)
167
+ }
168
+
169
+ private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
170
+ // Buffer의 2D 형태: 좌우 끝이 꺾인 육각형
171
+ const offset = Math.min(w, h) * 0.2
172
+ const hw = w / 2
173
+ const hh = h / 2
174
+
175
+ const shape = new THREE.Shape()
176
+ shape.moveTo(-hw + offset, -hh)
177
+ shape.lineTo(hw - offset, -hh)
178
+ shape.lineTo(hw, 0)
179
+ shape.lineTo(hw - offset, hh)
180
+ shape.lineTo(-hw + offset, hh)
181
+ shape.lineTo(-hw, 0)
182
+ shape.closePath()
183
+
184
+ const geo = new THREE.ExtrudeGeometry(shape, {
185
+ steps: 1,
186
+ depth: d,
187
+ bevelEnabled: false,
188
+ })
189
+
190
+ // 중심 정렬 후 Y-up 좌표계 회전 (extrude 축: +Z → +Y)
191
+ geo.translate(0, 0, -d / 2)
192
+ geo.rotateX(Math.PI / 2)
193
+
194
+ return this.removeBottomFace(geo)
195
+ }
196
+
197
+ // ── Build ──
198
+
199
+ protected _ownMesh?: THREE.Mesh
200
+
201
+ build() {
202
+ super.build()
203
+
204
+ // 가시성 판정 — e.g., Carrier는 데이터 없으면 mesh 미생성
205
+ if (this._config.isVisible && !this._config.isVisible(this.component)) return
206
+
207
+ const { w: rawW, h: rawH } = this.absSize
208
+ const rawDepth = this.effectiveDepth
209
+ if (!rawW || !rawH) return
210
+
211
+ const ratio = this._config.sizeRatio ?? 1.0
212
+ const w = rawW * ratio
213
+ const h = rawH * ratio
214
+ const depth = rawDepth * ratio
215
+
216
+ const color = this.resolveColor()
217
+ const { opacity, metalness, roughness, shape } = this._config
218
+
219
+ const geometry =
220
+ shape === 'hexagon'
221
+ ? this.buildHexagonGeometry(w, depth, h)
222
+ : this.buildBoxGeometry(w, depth, h)
223
+
224
+ const material = new THREE.MeshStandardMaterial({
225
+ color,
226
+ metalness,
227
+ roughness,
228
+ opacity,
229
+ transparent: opacity < 1,
230
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
231
+ depthWrite: opacity >= 0.9,
232
+ })
233
+ // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
234
+ material.polygonOffset = true
235
+ material.polygonOffsetFactor = -1
236
+ material.polygonOffsetUnit = -1
237
+
238
+ // BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5)
239
+ let meshMaterial: THREE.Material | THREE.Material[] = material
240
+ if (shape === 'box') {
241
+ if (this._config.openTop) {
242
+ // 상단면 제거 → 5면 개방 박스
243
+ const invisible = new THREE.MeshBasicMaterial({ visible: false })
244
+ meshMaterial = [material, material, invisible, material, material, material]
245
+ } else if (this._config.texturedTop) {
246
+ // 상판에 2D 렌더링 텍스처
247
+ const topMat = this.buildTopTexture(w, h)
248
+ meshMaterial = [material, material, topMat, material, material, material]
249
+ } else if (this._config.matteTop) {
250
+ // 상판만 무반사
251
+ const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 })
252
+ meshMaterial = [material, material, matte, material, material, material]
253
+ }
254
+ }
255
+
256
+ 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
260
+ this._ownMesh.receiveShadow = true
261
+ this.object3d.add(this._ownMesh)
262
+ }
263
+
264
+ // ── Change handling ──
265
+
266
+ private _updatePending = false
267
+
268
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
269
+ if (
270
+ 'width' in after ||
271
+ 'height' in after ||
272
+ 'depth' in after ||
273
+ 'fillStyle' in after ||
274
+ 'strokeStyle' in after ||
275
+ 'id' in after ||
276
+ 'EMPTYTYPE' in after ||
277
+ 'CARRIERSTATUS' in after
278
+ ) {
279
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
280
+ if (!this._updatePending) {
281
+ this._updatePending = true
282
+ queueMicrotask(() => {
283
+ this._updatePending = false
284
+ this.update()
285
+ })
286
+ }
287
+ return
288
+ }
289
+
290
+ super.onchange(after, before)
291
+ }
292
+
293
+ /**
294
+ * 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용
295
+ */
296
+ protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial {
297
+ const scale = 4
298
+ const cw = this.nextPow2(w * scale)
299
+ const ch = this.nextPow2(h * scale)
300
+ const canvas = document.createElement('canvas')
301
+ canvas.width = cw
302
+ canvas.height = ch
303
+ const ctx = canvas.getContext('2d')!
304
+
305
+ const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0')
306
+ const emptyType = (this.component.state as any)?.EMPTYTYPE || ''
307
+ const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : ''
308
+
309
+ // 실제 사용 영역 (nextPow2 이전 크기)
310
+ const usedW = w * scale
311
+ const usedH = h * scale
312
+
313
+ // 바탕: 캐리어 상태 색상 (사용 영역만)
314
+ ctx.fillStyle = statusColor
315
+ ctx.fillRect(0, 0, usedW, usedH)
316
+
317
+ // 텍스트 (사용 영역 중앙)
318
+ if (text) {
319
+ const fontSize = Math.round(Math.min(usedW, usedH) * 0.7)
320
+ ctx.font = `bold ${fontSize}px Arial`
321
+ ctx.textAlign = 'center'
322
+ ctx.textBaseline = 'middle'
323
+ ctx.fillStyle = '#000000'
324
+ ctx.fillText(text, usedW / 2, usedH / 2)
325
+ }
326
+
327
+ const texture = new THREE.CanvasTexture(canvas)
328
+ texture.offset.set(0, 1 - usedH / ch)
329
+ texture.repeat.set(usedW / cw, usedH / ch)
330
+ texture.colorSpace = THREE.SRGBColorSpace
331
+
332
+ return new THREE.MeshBasicMaterial({
333
+ map: texture,
334
+ transparent: true,
335
+ side: THREE.DoubleSide,
336
+ })
337
+ }
338
+
339
+ private nextPow2(n: number): number {
340
+ if (!(n > 0)) return 1
341
+ let v = Math.ceil(n)
342
+ v--
343
+ v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16
344
+ return v + 1
345
+ }
346
+
347
+ /**
348
+ * 자신의 mesh만 제거 후 rebuild.
349
+ * 기본 update()는 clear()로 object3d의 모든 children을 제거하는데,
350
+ * 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨.
351
+ * 이를 방지하기 위해 자신이 생성한 mesh만 제거한다.
352
+ */
353
+ update() {
354
+ // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
355
+ if (this._ownMesh) {
356
+ this.object3d.remove(this._ownMesh)
357
+ disposeObject3D(this._ownMesh)
358
+ this._ownMesh = undefined
359
+ }
360
+
361
+ this.build()
362
+ this.updateTransform()
363
+ this.updateAlpha()
364
+ this.updateHidden()
365
+ }
366
+
367
+ // RealObjectGroup 기본 동작 비활성화
368
+ updateDimension() {}
369
+
370
+ updateAlpha() {
371
+ const { alpha = 1 } = this.component.state
372
+ const configOpacity = this._config.opacity
373
+ const effective = configOpacity * alpha
374
+
375
+ this.object3d.traverse((o: any) => {
376
+ if (!o.material) return
377
+ const materials = Array.isArray(o.material) ? o.material : [o.material]
378
+ for (const m of materials) {
379
+ if (!m.visible) continue // openTop invisible face 무시
380
+ if (m.map) continue // 텍스처 material은 자체 transparent 유지
381
+ m.opacity = effective
382
+ m.transparent = effective < 1
383
+ }
384
+ })
385
+ }
386
+ }
387
+
388
+ // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
389
+
390
+ function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
391
+ const config: MCS3DConfig = { ...DEFAULTS, ...overrides }
392
+ registerRealObjectFactory(type, (component: Component) => new MCSRealObject3D(component, config))
393
+ }
394
+
395
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
396
+ // Machine (인프라 설비) — 높은 depth, 컨테이너는 반투명
397
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
398
+
399
+ register('Equipment', {
400
+ defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT,
401
+ opacity: 0.75,
402
+ metalness: 0.4,
403
+ roughness: 0.6,
404
+ defaultColor: 0x8899aa,
405
+ })
406
+
407
+ register('Buffer', {
408
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT,
409
+ opacity: 1.0,
410
+ metalness: 0.3,
411
+ roughness: 0.7,
412
+ defaultColor: 0x99aa88,
413
+ shape: 'hexagon',
414
+ })
415
+
416
+ register('AGVLine', {
417
+ depthRatio: 0.12,
418
+ metalness: 0.6,
419
+ roughness: 0.3,
420
+ defaultColor: 0x888899,
421
+ })
422
+
423
+ register('OHTLine', {
424
+ depthRatio: 0.12,
425
+ metalness: 0.6,
426
+ roughness: 0.3,
427
+ defaultColor: 0x888899,
428
+ })
429
+
430
+ // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
431
+ const STOCKER_CONFIG: MCS3DConfig = {
432
+ ...DEFAULTS,
433
+ defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT,
434
+ opacity: 0.75,
435
+ metalness: 0.3,
436
+ roughness: 0.6,
437
+ defaultColor: 0x8899bb,
438
+ }
439
+
440
+ class StockerRealObject3D extends MCSRealObject3D {
441
+ private gaugeMesh?: THREE.Mesh
442
+
443
+ constructor(component: Component) {
444
+ super(component, STOCKER_CONFIG)
445
+ }
446
+
447
+ build() {
448
+ super.build()
449
+ this.buildGauge()
450
+ }
451
+
452
+ private buildGauge() {
453
+ const comp = this.component as any
454
+ const data = comp.data || {}
455
+ const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
456
+
457
+ const gauge = this.resolveGauge(data, gaugeMode)
458
+
459
+ // 테두리는 스토커의 상태 색상 (machine status)
460
+ const statusColor = comp.statusColor
461
+ if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') {
462
+ gauge.borderColor = statusColor
463
+ }
464
+
465
+ const { w, h } = this.absSize
466
+ const depth = this.effectiveDepth
467
+ const isWidthLonger = w >= h
468
+
469
+ // 게이지 캔버스 (고해상도)
470
+ const barW = 130
471
+ const barH = 26
472
+ const scale = 4
473
+ const canvasW = barW * scale
474
+ const canvasH = barH * scale
475
+ const canvas = document.createElement('canvas')
476
+ canvas.width = canvasW
477
+ canvas.height = canvasH
478
+ const ctx = canvas.getContext('2d')!
479
+
480
+ const pad = 12
481
+
482
+ // 배경 + 테두리
483
+ ctx.fillStyle = '#dddddd'
484
+ ctx.fillRect(0, 0, canvasW, canvasH)
485
+ ctx.strokeStyle = gauge.borderColor
486
+ ctx.lineWidth = 12
487
+ ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
488
+
489
+ // 세그먼트 채움 (왼→오)
490
+ const innerW = canvasW - pad * 2
491
+ const innerH = canvasH - pad * 2
492
+ let x = pad
493
+ for (const seg of gauge.segments) {
494
+ const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
495
+ if (segW <= 0) continue
496
+ ctx.fillStyle = seg.color
497
+ ctx.fillRect(x, pad, segW, innerH)
498
+ x += segW
499
+ }
500
+
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)
507
+
508
+ // 텍스처 → Plane
509
+ const texture = new THREE.CanvasTexture(canvas)
510
+ texture.colorSpace = THREE.SRGBColorSpace
511
+
512
+ const geo = new THREE.PlaneGeometry(barW, barH)
513
+ geo.rotateX(-Math.PI / 2) // 수평 (지붕 위)
514
+ if (!isWidthLonger) geo.rotateY(Math.PI / 2)
515
+
516
+ const mat = new THREE.MeshBasicMaterial({
517
+ map: texture,
518
+ transparent: true,
519
+ side: THREE.DoubleSide,
520
+ })
521
+ mat.polygonOffset = true
522
+ mat.polygonOffsetFactor = -2
523
+ mat.polygonOffsetUnit = -2
524
+
525
+ this.gaugeMesh = new THREE.Mesh(geo, mat)
526
+ this.gaugeMesh.position.set(0, depth + 0.5, 0)
527
+ this.object3d.add(this.gaugeMesh)
528
+ }
529
+
530
+ private resolveGauge(data: any, mode: string) {
531
+ switch (mode) {
532
+ 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
+ }
549
+ }
550
+ 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
+ }
565
+ }
566
+ default: { // capacity
567
+ const cur = Number(data.CURRENTCAPACITY) || 0
568
+ const max = Number(data.MAXCAPACITY) || 100
569
+ const fullup = data.FULLUP === 'T'
570
+ 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')
573
+ return {
574
+ segments: [{ ratio: usage / 100, color }],
575
+ borderColor: color,
576
+ label: `${usage}%`,
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
583
+ if ('data' in after) {
584
+ this.clearGauge()
585
+ this.buildGauge()
586
+ }
587
+ super.onchange(after, before)
588
+ }
589
+
590
+ clear() {
591
+ this.clearGauge()
592
+ return super.clear()
593
+ }
594
+
595
+ private clearGauge() {
596
+ if (this.gaugeMesh) {
597
+ this.object3d.remove(this.gaugeMesh)
598
+ this.gaugeMesh.geometry?.dispose()
599
+ const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
600
+ mat?.map?.dispose()
601
+ mat?.dispose()
602
+ this.gaugeMesh = undefined
603
+ }
604
+ }
605
+ }
606
+
607
+ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component))
608
+
609
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
610
+ // Unit (이동체 · 홀더) — 낮은 depth, 불투명
611
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
612
+
613
+ // Port: 상부 개방 5면 박스
614
+ const PORT_CONFIG: MCS3DConfig = {
615
+ ...DEFAULTS,
616
+ depthRatio: 0.5,
617
+ defaultZPosFromParent: true,
618
+ opacity: 0.5,
619
+ metalness: 0.4,
620
+ roughness: 0.6,
621
+ defaultColor: 0xb0a090,
622
+ openTop: true,
623
+ }
624
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
625
+
626
+ register('Shelf', {
627
+ depthRatio: 0.25,
628
+ metalness: 0.3,
629
+ roughness: 0.7,
630
+ defaultColor: 0xaabbcc,
631
+ })
632
+
633
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
634
+ const CARRIER_CONFIG: MCS3DConfig = {
635
+ ...DEFAULTS,
636
+ depthRatio: 0.5,
637
+ metalness: 0.2,
638
+ roughness: 0.8,
639
+ defaultColor: 0x66bb66,
640
+ isVisible: (c: Component) => !!(c.state as any).id,
641
+ sizeRatio: 0.8,
642
+ texturedTop: true,
643
+ }
644
+
645
+ class CarrierRealObject3D extends MCSRealObject3D {
646
+ private _animRaf: number | null = null
647
+ private _animStartTime = 0
648
+
649
+ constructor(component: Component) {
650
+ super(component, CARRIER_CONFIG)
651
+ }
652
+
653
+ build() {
654
+ super.build()
655
+ this.startCarrierAnimation()
656
+ }
657
+
658
+ /**
659
+ * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
+ * things-scene의 delta 시스템과 독립적으로 동작.
661
+ */
662
+ private startCarrierAnimation() {
663
+ this.stopCarrierAnimation()
664
+
665
+ const comp = this.component as any
666
+ if (!comp.app?.isViewMode) return
667
+
668
+ const carrierStatus = comp.state?.CARRIERSTATUS
669
+ if (!carrierStatus) return
670
+
671
+ // 애니메이션 설정 가져오기
672
+ const config = comp.getAnimationConfig?.(carrierStatus)
673
+ if (!config?.animation) return
674
+
675
+ const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
+ if (!type) return
677
+
678
+ this._animStartTime = performance.now()
679
+
680
+ // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
+ // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
+ const obj = this.object3d
683
+
684
+ const animate = () => {
685
+ if (!this._ownMesh) return
686
+
687
+ const elapsed = performance.now() - this._animStartTime
688
+ const progress = (elapsed % duration) / duration // 0~1 반복
689
+
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
+ }
717
+ }
718
+
719
+ this._animRaf = requestAnimationFrame(animate)
720
+ }
721
+
722
+ this._animRaf = requestAnimationFrame(animate)
723
+ }
724
+
725
+ // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
+ syncAnimationDelta() {
727
+ if (!this._animRaf) {
728
+ super.syncAnimationDelta()
729
+ }
730
+ }
731
+
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
740
+ }
741
+
742
+ update() {
743
+ this.stopCarrierAnimation()
744
+ super.update()
745
+ }
746
+
747
+ clear() {
748
+ this.stopCarrierAnimation()
749
+ return super.clear()
750
+ }
751
+ }
752
+
753
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
754
+
755
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
756
+ // Gauge (용량 게이지) — 얇은 바
757
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
758
+
759
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
760
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
761
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
762
+
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)