@fmsim/machine 2.0.0-beta.7 → 2.0.0-beta.75

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