@fmsim/machine 2.0.0-beta.6 → 2.0.0-beta.61

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 (90) hide show
  1. package/dist/agv.js +2 -2
  2. package/dist/agv.js.map +1 -1
  3. package/dist/carrier-manager.js +93 -0
  4. package/dist/carrier-manager.js.map +1 -0
  5. package/dist/carrier.js +49 -4
  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.map +1 -1
  12. package/dist/factories/agv-3d.js +8 -9
  13. package/dist/factories/agv-3d.js.map +1 -1
  14. package/dist/factories/conveyor-3d.js +166 -81
  15. package/dist/factories/conveyor-3d.js.map +1 -1
  16. package/dist/factories/crane-3d.js +5 -7
  17. package/dist/factories/crane-3d.js.map +1 -1
  18. package/dist/factories/crane-rail-3d.js +4 -6
  19. package/dist/factories/crane-rail-3d.js.map +1 -1
  20. package/dist/factories/machine-3d.js +363 -132
  21. package/dist/factories/machine-3d.js.map +1 -1
  22. package/dist/factories/oht-3d.js +20 -20
  23. package/dist/factories/oht-3d.js.map +1 -1
  24. package/dist/factories/shuttle-3d.js +2 -5
  25. package/dist/factories/shuttle-3d.js.map +1 -1
  26. package/dist/features/mcs-status-mixin.js +22 -0
  27. package/dist/features/mcs-status-mixin.js.map +1 -1
  28. package/dist/index.js +50 -34
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcs-carrier-holder.js +39 -22
  31. package/dist/mcs-carrier-holder.js.map +1 -1
  32. package/dist/mcs-vehicle.js +8 -3
  33. package/dist/mcs-vehicle.js.map +1 -1
  34. package/dist/node.js +1 -1
  35. package/dist/node.js.map +1 -1
  36. package/dist/oht.js.map +1 -1
  37. package/dist/scanner.js +30 -0
  38. package/dist/scanner.js.map +1 -0
  39. package/dist/scene/root-container-override.js +9 -0
  40. package/dist/scene/root-container-override.js.map +1 -1
  41. package/dist/stocker-abnormal-bar.js +1 -1
  42. package/dist/stocker-abnormal-bar.js.map +1 -1
  43. package/dist/stocker-capacity-bar.js +3 -3
  44. package/dist/stocker-capacity-bar.js.map +1 -1
  45. package/dist/stocker-carrier-bar.js +1 -1
  46. package/dist/stocker-carrier-bar.js.map +1 -1
  47. package/dist/templates/index.js +2 -0
  48. package/dist/templates/index.js.map +1 -1
  49. package/dist/templates/scanner.js +20 -0
  50. package/dist/templates/scanner.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/utils/get-value-on-ranges.js +2 -4
  53. package/dist/utils/get-value-on-ranges.js.map +1 -1
  54. package/dist/zone-capacity-bar.js +3 -3
  55. package/dist/zone-capacity-bar.js.map +1 -1
  56. package/jest.config.js +10 -0
  57. package/package.json +4 -3
  58. package/src/agv.ts +2 -2
  59. package/src/carrier-manager.ts +117 -0
  60. package/src/carrier.ts +48 -2
  61. package/src/conveyor-join.ts +5 -2
  62. package/src/conveyor.ts +8 -0
  63. package/src/crane.ts +1 -1
  64. package/src/factories/agv-3d.ts +9 -10
  65. package/src/factories/conveyor-3d.ts +168 -84
  66. package/src/factories/crane-3d.ts +6 -8
  67. package/src/factories/crane-rail-3d.ts +5 -7
  68. package/src/factories/machine-3d.ts +428 -144
  69. package/src/factories/oht-3d.ts +20 -20
  70. package/src/factories/shuttle-3d.ts +2 -6
  71. package/src/features/mcs-status-mixin.ts +21 -0
  72. package/src/index.ts +56 -34
  73. package/src/mcs-carrier-holder.ts +38 -34
  74. package/src/mcs-vehicle.ts +9 -6
  75. package/src/node.ts +1 -1
  76. package/src/oht.ts +1 -1
  77. package/src/scanner.ts +29 -0
  78. package/src/scene/root-container-override.ts +12 -0
  79. package/src/stocker-abnormal-bar.ts +1 -1
  80. package/src/stocker-capacity-bar.ts +3 -3
  81. package/src/stocker-carrier-bar.ts +1 -1
  82. package/src/templates/index.ts +2 -0
  83. package/src/templates/scanner.ts +20 -0
  84. package/src/utils/get-value-on-ranges.ts +2 -4
  85. package/src/zone-capacity-bar.ts +3 -3
  86. package/translations/en.json +2 -1
  87. package/translations/ja.json +2 -1
  88. package/translations/ko.json +2 -1
  89. package/translations/ms.json +2 -1
  90. package/translations/zh.json +2 -1
@@ -9,10 +9,11 @@
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
15
  import { LEGEND_CAPACITY } from '../features/mcs-status-default'
15
- import { getVaueOnRanges } from '../utils/get-value-on-ranges'
16
+ import { getValueOnRanges } from '../utils/get-value-on-ranges'
16
17
 
17
18
  // ── 설정 타입 ────────────────────────────────────────────
18
19
 
@@ -29,8 +30,8 @@ interface MCS3DConfig {
29
30
  roughness: number
30
31
  /** statusColor 미사용 시 기본 색상 */
31
32
  defaultColor: number
32
- /** 'box' | 'hexagon' */
33
- shape: 'box' | 'hexagon'
33
+ /** 'box' | 'hexagon' | 'plane' | 'triangle' */
34
+ shape: 'box' | 'hexagon' | 'plane' | 'triangle'
34
35
  /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
35
36
  isVisible?: (component: Component) => boolean
36
37
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
@@ -41,6 +42,12 @@ interface MCS3DConfig {
41
42
  matteTop?: boolean
42
43
  /** 상판에 2D 렌더링 텍스처 적용 */
43
44
  texturedTop?: boolean
45
+ /** 자체 발광 강도 (0~1). 조명 약한 면에서도 색상 유지 */
46
+ emissiveIntensity?: number
47
+ /** 바닥면 유지 (기본 false — 바닥면 제거) */
48
+ keepBottomFace?: boolean
49
+ /** syncZPosOffset 고정값 (설정 시 다른 로직 무시) */
50
+ zPosOverride?: number
44
51
  }
45
52
 
46
53
  /**
@@ -69,9 +76,14 @@ class MCSRealObject3D extends RealObjectGroup {
69
76
  this._config = config
70
77
  }
71
78
 
72
- // width, height 절대값 (음수 값은 양수로 취급)
79
+ // width, height 절대값 (음수 값은 양수로 취급).
80
+ // `resizable:false` 컴포넌트(예: Scanner)는 state.width/height 가 없고
81
+ // bounds 만 있을 수 있으므로 bounds 를 fallback 으로 사용.
73
82
  protected get absSize(): { w: number; h: number } {
74
- const { width = 10, height = 10 } = this.component.state
83
+ const state = (this.component.state || {}) as any
84
+ const bounds = (this.component.bounds || {}) as any
85
+ const width = state.width ?? bounds.width ?? 10
86
+ const height = state.height ?? bounds.height ?? 10
75
87
  return { w: Math.abs(width), h: Math.abs(height) }
76
88
  }
77
89
 
@@ -84,21 +96,35 @@ class MCSRealObject3D extends RealObjectGroup {
84
96
  }
85
97
 
86
98
  protected get syncZPosOffset(): number {
99
+ // syncZPosOffset = placement + geometric (center origin = effectiveDepth/2)
100
+ // 중첩 보정(parent shift 상쇄)은 base RealObject.cz가 parent.geometricOffsetY를
101
+ // 빼는 방식으로 처리하므로 여기선 placement + 본인 geometric만 반환
102
+ const halfD = this.effectiveDepth / 2
103
+ // zPosOverride: 고정 높이 (OHTLine 등)
104
+ if (this._config.zPosOverride != null) {
105
+ return this._config.zPosOverride + halfD
106
+ }
87
107
  // Port: 이송 표면 높이에 배치
88
108
  if (this._config.defaultZPosFromParent) {
89
- return TRANSPORT_SURFACE_HEIGHT
109
+ return TRANSPORT_SURFACE_HEIGHT + halfD
90
110
  }
91
- // Carrier: Port 안이면 중앙, 그 외 컨테이너면 상단 위
111
+ // Carrier: 부모 유형에 따라 배치 위치 결정
92
112
  if (this._config.sizeRatio && this._config.sizeRatio < 1) {
93
113
  const parentType = this.component.parent?.state?.type
94
- if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'OHT' || parentType === 'Shuttle') {
95
- return 0
114
+ // OHT: 그리퍼 아래에 매달림 — placement = -effectiveDepth(carrier top이 OHT bottom)
115
+ if (parentType === 'OHT') {
116
+ return -this.effectiveDepth + halfD
117
+ }
118
+ if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'Shuttle') {
119
+ return halfD
96
120
  }
97
- return TRANSPORT_SURFACE_HEIGHT
121
+ return TRANSPORT_SURFACE_HEIGHT + halfD
98
122
  }
99
- return 0
123
+ return halfD
100
124
  }
101
125
 
126
+ // geometric 부분(center origin: depth/2)은 base 기본값 사용 — override 불필요
127
+
102
128
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
103
129
  protected resolveColor(): number {
104
130
  const comp = this.component as any
@@ -107,14 +133,14 @@ class MCSRealObject3D extends RealObjectGroup {
107
133
  // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
108
134
  try {
109
135
  const sc = comp.statusColor
110
- if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0') return new THREE.Color(sc).getHex()
136
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex()
111
137
  } catch {
112
138
  /* statusColor 미구현 시 무시 */
113
139
  }
114
140
 
115
141
  // 2) state.fillStyle fallback
116
142
  const { fillStyle } = this.component.state
117
- if (fillStyle && typeof fillStyle === 'string') {
143
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
118
144
  try {
119
145
  return new THREE.Color(fillStyle).getHex()
120
146
  } catch {
@@ -163,7 +189,7 @@ class MCSRealObject3D extends RealObjectGroup {
163
189
 
164
190
  private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
165
191
  const geo = new THREE.BoxGeometry(w, d, h)
166
- return this.removeBottomFace(geo)
192
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
167
193
  }
168
194
 
169
195
  private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
@@ -194,6 +220,43 @@ class MCSRealObject3D extends RealObjectGroup {
194
220
  return this.removeBottomFace(geo)
195
221
  }
196
222
 
223
+ private buildPlaneGeometry(w: number, h: number): THREE.BufferGeometry {
224
+ const geo = new THREE.PlaneGeometry(w, h)
225
+ geo.rotateX(-Math.PI / 2)
226
+ return geo
227
+ }
228
+
229
+ /**
230
+ * 삼각형 prism — 2D Triangle 의 꼭짓점(state.x1/y1, x2/y2, x3/y3) 을 그대로 사용.
231
+ * things-scene 의 TriangleExtrude 와 동일한 방식 — 각 Scanner 인스턴스의
232
+ * 실제 정점 배치(방향, 비대칭 등) 가 3D 에 반영된다.
233
+ *
234
+ * `geometry.center()` 로 로컬 원점을 bounding-box 중심으로 맞추므로
235
+ * 후속 placement 와 정합.
236
+ */
237
+ private buildTriangleGeometry(d: number): THREE.BufferGeometry {
238
+ const state = (this.component.state || {}) as any
239
+ const { x1 = 0, y1 = 0, x2 = 0, y2 = 0, x3 = 0, y3 = 0 } = state
240
+
241
+ const shape = new THREE.Shape()
242
+ shape.moveTo(x1, y1)
243
+ shape.lineTo(x2, y2)
244
+ shape.lineTo(x3, y3)
245
+ shape.closePath()
246
+
247
+ const geo = new THREE.ExtrudeGeometry(shape, {
248
+ steps: 1,
249
+ depth: d,
250
+ bevelEnabled: false,
251
+ })
252
+
253
+ // bounding box 중심으로 원점 정렬 후 Y-up 좌표계로 회전
254
+ geo.center()
255
+ geo.rotateX(Math.PI / 2)
256
+
257
+ return this.removeBottomFace(geo)
258
+ }
259
+
197
260
  // ── Build ──
198
261
 
199
262
  protected _ownMesh?: THREE.Mesh
@@ -217,19 +280,31 @@ class MCSRealObject3D extends RealObjectGroup {
217
280
  const { opacity, metalness, roughness, shape } = this._config
218
281
 
219
282
  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
- })
283
+ shape === 'plane'
284
+ ? this.buildPlaneGeometry(w, h)
285
+ : shape === 'hexagon'
286
+ ? this.buildHexagonGeometry(w, depth, h)
287
+ : shape === 'triangle'
288
+ ? this.buildTriangleGeometry(depth)
289
+ : this.buildBoxGeometry(w, depth, h)
290
+
291
+ const isPlane = shape === 'plane'
292
+ const material = isPlane
293
+ ? new THREE.MeshBasicMaterial({
294
+ color,
295
+ opacity,
296
+ transparent: opacity < 1,
297
+ side: THREE.DoubleSide,
298
+ })
299
+ : new THREE.MeshStandardMaterial({
300
+ color,
301
+ metalness,
302
+ roughness,
303
+ opacity,
304
+ transparent: opacity < 1,
305
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
306
+ ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}),
307
+ })
233
308
  // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
234
309
  material.polygonOffset = true
235
310
  material.polygonOffsetFactor = -1
@@ -254,11 +329,14 @@ class MCSRealObject3D extends RealObjectGroup {
254
329
  }
255
330
 
256
331
  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
332
+ // ownGroup 기준(bottom-up): mesh 바닥을 y=0에 맞추고 ownGroup이 -d/2로 shift해
333
+ // center-origin object3d에 정렬됨.
334
+ // box/hexagon (center-local geometry): y=rawDepth/2 (바닥을 0에 맞춤)
335
+ // plane: y=1 (바닥 살짝 띄움)
336
+ this._ownMesh.position.y = isPlane ? 1 : rawDepth / 2
337
+ this._ownMesh.castShadow = !isPlane
260
338
  this._ownMesh.receiveShadow = true
261
- this.object3d.add(this._ownMesh)
339
+ this.components3D.add(this._ownMesh)
262
340
  }
263
341
 
264
342
  // ── Change handling ──
@@ -353,7 +431,7 @@ class MCSRealObject3D extends RealObjectGroup {
353
431
  update() {
354
432
  // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
355
433
  if (this._ownMesh) {
356
- this.object3d.remove(this._ownMesh)
434
+ this.components3D.remove(this._ownMesh)
357
435
  disposeObject3D(this._ownMesh)
358
436
  this._ownMesh = undefined
359
437
  }
@@ -368,9 +446,9 @@ class MCSRealObject3D extends RealObjectGroup {
368
446
  updateDimension() {}
369
447
 
370
448
  updateAlpha() {
371
- const { alpha = 1 } = this.component.state
372
- const configOpacity = this._config.opacity
373
- const effective = configOpacity * alpha
449
+ const state = this.component.state
450
+ // alpha가 명시적으로 설정되어 있으면 그 값을 사용, 없으면 config 기본값 적용
451
+ const effective = ('alpha' in state) ? (state.alpha as number) : this._config.opacity
374
452
 
375
453
  this.object3d.traverse((o: any) => {
376
454
  if (!o.material) return
@@ -414,18 +492,106 @@ register('Buffer', {
414
492
  })
415
493
 
416
494
  register('AGVLine', {
417
- depthRatio: 0.12,
495
+ depthRatio: 0,
496
+ defaultDepth: 0.1,
418
497
  metalness: 0.6,
419
498
  roughness: 0.3,
420
499
  defaultColor: 0x888899,
421
500
  })
422
501
 
423
- register('OHTLine', {
424
- depthRatio: 0.12,
425
- metalness: 0.6,
426
- roughness: 0.3,
427
- defaultColor: 0x888899,
428
- })
502
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
503
+ // OHTLine — 천장 I-beam 레일
504
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
505
+
506
+ const OHT_LINE_RAIL_COLOR = 0xcccccc
507
+ const OHT_LINE_HEIGHT = 7 * TRANSPORT_SURFACE_HEIGHT // OHT 상단과 일치
508
+
509
+ class OHTLineRealObject3D extends RealObjectGroup {
510
+ private _mesh?: THREE.Mesh
511
+
512
+ get effectiveDepth(): number { return 0 }
513
+ protected get syncZPosOffset(): number { return OHT_LINE_HEIGHT }
514
+
515
+ build() {
516
+ super.build()
517
+
518
+ const { width = 10, height = 10 } = this.component.state
519
+ const w = Math.abs(width)
520
+ const h = Math.abs(height)
521
+ if (!w || !h) return
522
+
523
+ // I-beam 치수: 이송 방향 = 긴 변, 폭은 2D의 1/2
524
+ const isVertical = w < h
525
+ const shortSide = isVertical ? w : h // 2D 원본 짧은 쪽 폭
526
+ const beltW = shortSide * 0.25 // I-beam 폭: 원본의 1/4
527
+ const beamH = Math.max(beltW * 0.5, 3) // I-beam 전체 높이
528
+ const inset = shortSide * 0.5 // 양끝 여백: 원본 폭의 1/2
529
+ const railLen = (isVertical ? h : w) - inset * 2
530
+ const flangeH = beamH * 0.2 // 플랜지 두께
531
+ const flangeW = beltW * 0.8 // 플랜지 폭
532
+ const webH = beamH - flangeH * 2 // 웹 높이
533
+ const webW = Math.max(flangeW * 0.25, 1) // 웹 두께
534
+
535
+ const mat = new THREE.MeshStandardMaterial({
536
+ color: OHT_LINE_RAIL_COLOR,
537
+ metalness: 0.5,
538
+ roughness: 0.4,
539
+ })
540
+
541
+ const geoms: THREE.BufferGeometry[] = []
542
+
543
+ // 상부 플랜지
544
+ const topFlange = isVertical
545
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
546
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
547
+ topFlange.translate(0, -flangeH / 2, 0)
548
+ geoms.push(topFlange)
549
+
550
+ // 웹 (수직판)
551
+ const web = isVertical
552
+ ? new THREE.BoxGeometry(webW, webH, railLen)
553
+ : new THREE.BoxGeometry(railLen, webH, webW)
554
+ web.translate(0, -flangeH - webH / 2, 0)
555
+ geoms.push(web)
556
+
557
+ // 하부 플랜지
558
+ const botFlange = isVertical
559
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
560
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
561
+ botFlange.translate(0, -flangeH - webH - flangeH / 2, 0)
562
+ geoms.push(botFlange)
563
+
564
+ this._mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(geoms), mat)
565
+ this._mesh.castShadow = true
566
+ this.object3d.add(this._mesh)
567
+ }
568
+
569
+ update() {
570
+ if (this._mesh) {
571
+ this.object3d.remove(this._mesh)
572
+ this._mesh.geometry.dispose()
573
+ ;(this._mesh.material as THREE.Material).dispose()
574
+ this._mesh = undefined
575
+ }
576
+ this.build()
577
+ this.updateTransform()
578
+ this.updateHidden()
579
+ }
580
+
581
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
582
+ if ('width' in after || 'height' in after || 'depth' in after ||
583
+ 'fillStyle' in after || 'strokeStyle' in after) {
584
+ this.update()
585
+ return
586
+ }
587
+ super.onchange(after, before)
588
+ }
589
+
590
+ updateDimension() {}
591
+ updateAlpha() {}
592
+ }
593
+
594
+ registerRealObjectFactory('OHTLine', (component: Component) => new OHTLineRealObject3D(component))
429
595
 
430
596
  // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
431
597
  const STOCKER_CONFIG: MCS3DConfig = {
@@ -523,8 +689,9 @@ class StockerRealObject3D extends MCSRealObject3D {
523
689
  mat.polygonOffsetUnit = -2
524
690
 
525
691
  this.gaugeMesh = new THREE.Mesh(geo, mat)
692
+ // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움.
526
693
  this.gaugeMesh.position.set(0, depth + 0.5, 0)
527
- this.object3d.add(this.gaugeMesh)
694
+ this.components3D.add(this.gaugeMesh)
528
695
  }
529
696
 
530
697
  private resolveGauge(data: any, mode: string) {
@@ -569,7 +736,7 @@ class StockerRealObject3D extends MCSRealObject3D {
569
736
  const fullup = data.FULLUP === 'T'
570
737
  const usage = Math.round((cur / max) * 100)
571
738
  const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
572
- const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
739
+ const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
573
740
  return {
574
741
  segments: [{ ratio: usage / 100, color }],
575
742
  borderColor: color,
@@ -579,6 +746,11 @@ class StockerRealObject3D extends MCSRealObject3D {
579
746
  }
580
747
  }
581
748
 
749
+ update() {
750
+ this.clearGauge()
751
+ super.update()
752
+ }
753
+
582
754
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
583
755
  if ('data' in after) {
584
756
  this.clearGauge()
@@ -594,7 +766,7 @@ class StockerRealObject3D extends MCSRealObject3D {
594
766
 
595
767
  private clearGauge() {
596
768
  if (this.gaugeMesh) {
597
- this.object3d.remove(this.gaugeMesh)
769
+ this.components3D.remove(this.gaugeMesh)
598
770
  this.gaugeMesh.geometry?.dispose()
599
771
  const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
600
772
  mat?.map?.dispose()
@@ -610,16 +782,16 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
610
782
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
611
783
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
612
784
 
613
- // Port: 상부 개방 5면 박스
785
+ // Port: 바닥판 (PlaneGeometry)
614
786
  const PORT_CONFIG: MCS3DConfig = {
615
787
  ...DEFAULTS,
616
- depthRatio: 0.5,
788
+ depthRatio: 0.01,
617
789
  defaultZPosFromParent: true,
618
- opacity: 0.5,
619
- metalness: 0.4,
620
- roughness: 0.6,
790
+ opacity: 0.1,
791
+ metalness: 0.3,
792
+ roughness: 0.8,
621
793
  defaultColor: 0xb0a090,
622
- openTop: true,
794
+ shape: 'plane',
623
795
  }
624
796
  registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
625
797
 
@@ -634,134 +806,246 @@ register('Shelf', {
634
806
  const CARRIER_CONFIG: MCS3DConfig = {
635
807
  ...DEFAULTS,
636
808
  depthRatio: 0.5,
637
- metalness: 0.2,
638
- roughness: 0.8,
639
- defaultColor: 0x66bb66,
809
+ metalness: 0,
810
+ roughness: 1,
811
+ defaultColor: 0xf0f0f0,
640
812
  isVisible: (c: Component) => !!(c.state as any).id,
641
813
  sizeRatio: 0.8,
642
814
  texturedTop: true,
643
815
  }
644
816
 
645
817
  class CarrierRealObject3D extends MCSRealObject3D {
646
- private _animRaf: number | null = null
647
- private _animStartTime = 0
648
-
649
818
  constructor(component: Component) {
650
819
  super(component, CARRIER_CONFIG)
651
820
  }
821
+ }
652
822
 
653
- build() {
654
- super.build()
655
- this.startCarrierAnimation()
656
- }
823
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
657
824
 
658
- /**
659
- * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
- * things-scene의 delta 시스템과 독립적으로 동작.
661
- */
662
- private startCarrierAnimation() {
663
- this.stopCarrierAnimation()
825
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
826
+ // Gauge (용량 게이지) — 얇은 바
827
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
664
828
 
665
- const comp = this.component as any
666
- if (!comp.app?.isViewMode) return
829
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
830
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
831
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
667
832
 
668
- const carrierStatus = comp.state?.CARRIERSTATUS
669
- if (!carrierStatus) return
833
+ // Gauge 컴포넌트는 3D에서 표시하지 않음 (부모 STOCKER의 3D 모델에서 직접 게이지 렌더링)
834
+ // null 반환으로 3D 오브젝트 생성을 억제하고, default factory fallback도 방지
835
+ registerRealObjectFactory('StockerCapacityBar', () => undefined as any)
836
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => undefined as any)
837
+ registerRealObjectFactory('ZoneCapacityBar', () => undefined as any)
670
838
 
671
- // 애니메이션 설정 가져오기
672
- const config = comp.getAnimationConfig?.(carrierStatus)
673
- if (!config?.animation) return
839
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
840
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
841
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
674
842
 
675
- const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
- if (!type) return
843
+ class PortFlowRealObject3D extends RealObjectGroup {
844
+ private _mesh?: THREE.Mesh
845
+ private _texture?: THREE.CanvasTexture
677
846
 
678
- this._animStartTime = performance.now()
847
+ get effectiveDepth(): number { return 0 }
848
+ protected get syncZPosOffset(): number {
849
+ // 부모 Port/Conveyor의 zPos와 동일하게
850
+ const parent = (this.component as any).parentObject
851
+ if (parent?.state?.zPos != null) return parent.state.zPos
852
+ return TRANSPORT_SURFACE_HEIGHT
853
+ }
679
854
 
680
- // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
- // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
- const obj = this.object3d
855
+ build() {
856
+ super.build()
683
857
 
684
- const animate = () => {
685
- if (!this._ownMesh) return
858
+ const { width = 10, height = 10 } = this.component.state
859
+ const w = Math.abs(width)
860
+ const h = Math.abs(height)
861
+ if (!w || !h) return
862
+
863
+ // 2D render를 캔버스에 캡처
864
+ // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산
865
+ const { left, top } = this.component.bounds
866
+ const { fontSize = 24, labelPosition = 'top' } = this.component.state
867
+ const arrowPad = Math.min(w, h) * 0.4
868
+
869
+ // 텍스트 영역 확장
870
+ let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad
871
+ if ((this.component as any).hasTextProperty) {
872
+ switch (labelPosition) {
873
+ case 'top': padTop = Math.max(padTop, fontSize + 4); break
874
+ case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break
875
+ case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break
876
+ case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break
877
+ }
878
+ }
686
879
 
687
- const elapsed = performance.now() - this._animStartTime
688
- const progress = (elapsed % duration) / duration // 0~1 반복
880
+ const totalW = w + padLeft + padRight
881
+ const totalH = h + padTop + padBottom
882
+ const scale = 2
883
+ const canvas = document.createElement('canvas')
884
+ canvas.width = totalW * scale
885
+ canvas.height = totalH * scale
886
+ const ctx = canvas.getContext('2d')!
689
887
 
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
- }
888
+ ctx.save()
889
+ ctx.scale(scale, scale)
890
+ ctx.translate(-left + padLeft, -top + padTop)
891
+ try {
892
+ // 화살표 렌더
893
+ this.component.render(ctx)
894
+ ctx.fillStyle = this.component.state.strokeStyle || '#666666'
895
+ ctx.fill()
896
+ // 텍스트 렌더
897
+ if ((this.component as any).hasTextProperty && (this.component as any).drawText) {
898
+ ;(this.component as any).drawText(ctx)
717
899
  }
900
+ } catch { /* render 실패 시 빈 텍스처 */ }
901
+ ctx.restore()
718
902
 
719
- this._animRaf = requestAnimationFrame(animate)
720
- }
903
+ this._texture = new THREE.CanvasTexture(canvas)
904
+ this._texture.colorSpace = THREE.SRGBColorSpace
721
905
 
722
- this._animRaf = requestAnimationFrame(animate)
723
- }
906
+ const geo = new THREE.PlaneGeometry(totalW, totalH)
907
+ geo.rotateX(-Math.PI / 2)
724
908
 
725
- // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
- syncAnimationDelta() {
727
- if (!this._animRaf) {
728
- super.syncAnimationDelta()
729
- }
730
- }
909
+ const mat = new THREE.MeshBasicMaterial({
910
+ map: this._texture,
911
+ alphaTest: 0.1,
912
+ side: THREE.DoubleSide,
913
+ })
731
914
 
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
915
+ this._mesh = new THREE.Mesh(geo, mat)
916
+ // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤
917
+ this._mesh.position.x = (padRight - padLeft) / 2
918
+ this._mesh.position.y = 1
919
+ this._mesh.position.z = (padBottom - padTop) / 2
920
+ this.object3d.add(this._mesh)
740
921
  }
741
922
 
742
923
  update() {
743
- this.stopCarrierAnimation()
744
- super.update()
924
+ if (this._mesh) {
925
+ this.object3d.remove(this._mesh)
926
+ this._mesh.geometry.dispose()
927
+ ;(this._mesh.material as THREE.Material).dispose()
928
+ this._texture?.dispose()
929
+ this._mesh = undefined
930
+ this._texture = undefined
931
+ }
932
+ this.build()
933
+ this.updateTransform()
934
+ this.updateHidden()
745
935
  }
746
936
 
747
- clear() {
748
- this.stopCarrierAnimation()
749
- return super.clear()
937
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
938
+ if ('width' in after || 'height' in after || 'orientation' in after ||
939
+ 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) {
940
+ this.update()
941
+ return
942
+ }
943
+ super.onchange(after, before)
750
944
  }
945
+
946
+ updateDimension() {}
947
+ updateAlpha() {}
751
948
  }
752
949
 
753
- registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
950
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
754
951
 
755
952
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
756
- // Gauge (용량 게이지) — 얇은 바
953
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
757
954
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
758
955
 
759
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
760
- // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
761
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
956
+ class NodeRealObject3D extends RealObjectGroup {
957
+ private _mesh?: THREE.Mesh
958
+ private _texture?: THREE.CanvasTexture
959
+
960
+ get effectiveDepth(): number { return 0 }
961
+ protected get syncZPosOffset(): number { return 0 }
962
+
963
+ build() {
964
+ super.build()
965
+
966
+ const { cx, cy } = this.component.state
967
+ const radius = 8
968
+ const size = radius * 2
969
+ const scale = 2
970
+ const pad = 2
971
+
972
+ const canvas = document.createElement('canvas')
973
+ canvas.width = (size + pad * 2) * scale
974
+ canvas.height = (size + pad * 2) * scale
975
+ const ctx = canvas.getContext('2d')!
976
+
977
+ ctx.save()
978
+ ctx.scale(scale, scale)
979
+ ctx.translate(-cx + radius + pad, -cy + radius + pad)
980
+ try {
981
+ this.component.render(ctx)
982
+ const { fillStyle = '#888888' } = this.component.state
983
+ ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888'
984
+ ctx.fill()
985
+ const { strokeStyle } = this.component.state
986
+ if (strokeStyle && typeof strokeStyle === 'string') {
987
+ ctx.strokeStyle = strokeStyle
988
+ ctx.lineWidth = 1
989
+ ctx.stroke()
990
+ }
991
+ } catch { /* render 실패 시 빈 텍스처 */ }
992
+ ctx.restore()
993
+
994
+ this._texture = new THREE.CanvasTexture(canvas)
995
+ this._texture.colorSpace = THREE.SRGBColorSpace
996
+
997
+ const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2)
998
+ geo.rotateX(-Math.PI / 2)
999
+
1000
+ const mat = new THREE.MeshBasicMaterial({
1001
+ map: this._texture,
1002
+ alphaTest: 0.1,
1003
+ side: THREE.DoubleSide,
1004
+ })
1005
+
1006
+ this._mesh = new THREE.Mesh(geo, mat)
1007
+ this._mesh.position.y = 1
1008
+ this.object3d.add(this._mesh)
1009
+ }
1010
+
1011
+ update() {
1012
+ if (this._mesh) {
1013
+ this.object3d.remove(this._mesh)
1014
+ this._mesh.geometry.dispose()
1015
+ ;(this._mesh.material as THREE.Material).dispose()
1016
+ this._texture?.dispose()
1017
+ this._mesh = undefined
1018
+ this._texture = undefined
1019
+ }
1020
+ this.build()
1021
+ this.updateTransform()
1022
+ this.updateHidden()
1023
+ }
762
1024
 
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)
1025
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
1026
+ if ('fillStyle' in after || 'strokeStyle' in after) {
1027
+ this.update()
1028
+ return
1029
+ }
1030
+ super.onchange(after, before)
1031
+ }
1032
+
1033
+ updateDimension() {}
1034
+ updateAlpha() {}
1035
+ }
1036
+
1037
+ registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component))
1038
+
1039
+ // Scanner — 2D Triangle 실루엣과 정합되는 삼각형 prism.
1040
+ // 이송 표면 높이의 0.2배(=6) 얇은 기둥 — 원래 값 유지, shape 만 'box' → 'triangle'.
1041
+ const SCANNER_CONFIG: MCS3DConfig = {
1042
+ ...DEFAULTS,
1043
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2,
1044
+ defaultZPosFromParent: true,
1045
+ opacity: 1,
1046
+ metalness: 0.3,
1047
+ roughness: 0.7,
1048
+ defaultColor: 0x888888,
1049
+ shape: 'triangle',
1050
+ }
1051
+ registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))