@fmsim/machine 2.0.0-beta.5 → 2.0.0-beta.53

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 (81) 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 +51 -6
  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 +38 -12
  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 +464 -68
  21. package/dist/factories/machine-3d.js.map +1 -1
  22. package/dist/factories/oht-3d.js +52 -26
  23. package/dist/factories/oht-3d.js.map +1 -1
  24. package/dist/factories/shuttle-3d.js +32 -9
  25. package/dist/factories/shuttle-3d.js.map +1 -1
  26. package/dist/index.js +50 -34
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcs-carrier-holder.js +39 -22
  29. package/dist/mcs-carrier-holder.js.map +1 -1
  30. package/dist/mcs-vehicle.js +8 -3
  31. package/dist/mcs-vehicle.js.map +1 -1
  32. package/dist/node.js +1 -1
  33. package/dist/node.js.map +1 -1
  34. package/dist/oht.js.map +1 -1
  35. package/dist/scanner.js +30 -0
  36. package/dist/scanner.js.map +1 -0
  37. package/dist/scene/root-container-override.js +9 -0
  38. package/dist/scene/root-container-override.js.map +1 -1
  39. package/dist/stocker-capacity-bar.js +2 -2
  40. package/dist/stocker-capacity-bar.js.map +1 -1
  41. package/dist/templates/index.js +2 -0
  42. package/dist/templates/index.js.map +1 -1
  43. package/dist/templates/scanner.js +20 -0
  44. package/dist/templates/scanner.js.map +1 -0
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/dist/utils/get-value-on-ranges.js +2 -4
  47. package/dist/utils/get-value-on-ranges.js.map +1 -1
  48. package/dist/zone-capacity-bar.js +2 -2
  49. package/dist/zone-capacity-bar.js.map +1 -1
  50. package/jest.config.js +10 -0
  51. package/package.json +4 -3
  52. package/src/agv.ts +2 -2
  53. package/src/carrier-manager.ts +117 -0
  54. package/src/carrier.ts +50 -4
  55. package/src/conveyor-join.ts +5 -2
  56. package/src/conveyor.ts +8 -0
  57. package/src/crane.ts +1 -1
  58. package/src/factories/agv-3d.ts +38 -13
  59. package/src/factories/conveyor-3d.ts +168 -84
  60. package/src/factories/crane-3d.ts +6 -8
  61. package/src/factories/crane-rail-3d.ts +5 -7
  62. package/src/factories/machine-3d.ts +551 -62
  63. package/src/factories/oht-3d.ts +50 -26
  64. package/src/factories/shuttle-3d.ts +30 -10
  65. package/src/index.ts +56 -34
  66. package/src/mcs-carrier-holder.ts +38 -34
  67. package/src/mcs-vehicle.ts +9 -6
  68. package/src/node.ts +1 -1
  69. package/src/oht.ts +1 -1
  70. package/src/scanner.ts +29 -0
  71. package/src/scene/root-container-override.ts +12 -0
  72. package/src/stocker-capacity-bar.ts +2 -2
  73. package/src/templates/index.ts +2 -0
  74. package/src/templates/scanner.ts +20 -0
  75. package/src/utils/get-value-on-ranges.ts +2 -4
  76. package/src/zone-capacity-bar.ts +2 -2
  77. package/translations/en.json +2 -1
  78. package/translations/ja.json +2 -1
  79. package/translations/ko.json +2 -1
  80. package/translations/ms.json +2 -1
  81. package/translations/zh.json +2 -1
@@ -9,10 +9,11 @@
9
9
  */
10
10
 
11
11
  import * as THREE from 'three'
12
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
12
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
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
 
@@ -30,11 +31,23 @@ interface MCS3DConfig {
30
31
  /** statusColor 미사용 시 기본 색상 */
31
32
  defaultColor: number
32
33
  /** 'box' | 'hexagon' */
33
- shape: 'box' | 'hexagon'
34
+ shape: 'box' | 'hexagon' | 'plane'
34
35
  /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
35
36
  isVisible?: (component: Component) => boolean
36
37
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
37
38
  sizeRatio?: number
39
+ /** 상부 면 제거 (5면 개방 박스) */
40
+ openTop?: boolean
41
+ /** 상판 무반사 (MeshBasicMaterial) */
42
+ matteTop?: boolean
43
+ /** 상판에 2D 렌더링 텍스처 적용 */
44
+ texturedTop?: boolean
45
+ /** 자체 발광 강도 (0~1). 조명 약한 면에서도 색상 유지 */
46
+ emissiveIntensity?: number
47
+ /** 바닥면 유지 (기본 false — 바닥면 제거) */
48
+ keepBottomFace?: boolean
49
+ /** syncZPosOffset 고정값 (설정 시 다른 로직 무시) */
50
+ zPosOverride?: number
38
51
  }
39
52
 
40
53
  /**
@@ -78,37 +91,51 @@ class MCSRealObject3D extends RealObjectGroup {
78
91
  }
79
92
 
80
93
  protected get syncZPosOffset(): number {
94
+ // syncZPosOffset = placement + geometric (center origin = effectiveDepth/2)
95
+ // 중첩 보정(parent shift 상쇄)은 base RealObject.cz가 parent.geometricOffsetY를
96
+ // 빼는 방식으로 처리하므로 여기선 placement + 본인 geometric만 반환
97
+ const halfD = this.effectiveDepth / 2
98
+ // zPosOverride: 고정 높이 (OHTLine 등)
99
+ if (this._config.zPosOverride != null) {
100
+ return this._config.zPosOverride + halfD
101
+ }
81
102
  // Port: 이송 표면 높이에 배치
82
103
  if (this._config.defaultZPosFromParent) {
83
- return TRANSPORT_SURFACE_HEIGHT
104
+ return TRANSPORT_SURFACE_HEIGHT + halfD
84
105
  }
85
- // Carrier: Port 안이면 중앙, 그 외 컨테이너면 상단 위
106
+ // Carrier: 부모 유형에 따라 배치 위치 결정
86
107
  if (this._config.sizeRatio && this._config.sizeRatio < 1) {
87
108
  const parentType = this.component.parent?.state?.type
88
- if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'OHT' || parentType === 'Shuttle') {
89
- return 0
109
+ // OHT: 그리퍼 아래에 매달림 — placement = -effectiveDepth(carrier top이 OHT bottom)
110
+ if (parentType === 'OHT') {
111
+ return -this.effectiveDepth + halfD
112
+ }
113
+ if (parentType === 'Port' || parentType === 'Shelf' || parentType === 'Shuttle') {
114
+ return halfD
90
115
  }
91
- return TRANSPORT_SURFACE_HEIGHT
116
+ return TRANSPORT_SURFACE_HEIGHT + halfD
92
117
  }
93
- return 0
118
+ return halfD
94
119
  }
95
120
 
121
+ // geometric 부분(center origin: depth/2)은 base 기본값 사용 — override 불필요
122
+
96
123
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
97
- private resolveColor(): number {
124
+ protected resolveColor(): number {
98
125
  const comp = this.component as any
99
126
 
100
127
  // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
101
128
  // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
102
129
  try {
103
130
  const sc = comp.statusColor
104
- if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0') return new THREE.Color(sc).getHex()
131
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex()
105
132
  } catch {
106
133
  /* statusColor 미구현 시 무시 */
107
134
  }
108
135
 
109
136
  // 2) state.fillStyle fallback
110
137
  const { fillStyle } = this.component.state
111
- if (fillStyle && typeof fillStyle === 'string') {
138
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
112
139
  try {
113
140
  return new THREE.Color(fillStyle).getHex()
114
141
  } catch {
@@ -157,7 +184,7 @@ class MCSRealObject3D extends RealObjectGroup {
157
184
 
158
185
  private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
159
186
  const geo = new THREE.BoxGeometry(w, d, h)
160
- return this.removeBottomFace(geo)
187
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
161
188
  }
162
189
 
163
190
  private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
@@ -188,8 +215,16 @@ class MCSRealObject3D extends RealObjectGroup {
188
215
  return this.removeBottomFace(geo)
189
216
  }
190
217
 
218
+ private buildPlaneGeometry(w: number, h: number): THREE.BufferGeometry {
219
+ const geo = new THREE.PlaneGeometry(w, h)
220
+ geo.rotateX(-Math.PI / 2)
221
+ return geo
222
+ }
223
+
191
224
  // ── Build ──
192
225
 
226
+ protected _ownMesh?: THREE.Mesh
227
+
193
228
  build() {
194
229
  super.build()
195
230
 
@@ -209,34 +244,67 @@ class MCSRealObject3D extends RealObjectGroup {
209
244
  const { opacity, metalness, roughness, shape } = this._config
210
245
 
211
246
  const geometry =
212
- shape === 'hexagon'
213
- ? this.buildHexagonGeometry(w, depth, h)
214
- : this.buildBoxGeometry(w, depth, h)
215
-
216
- const material = new THREE.MeshStandardMaterial({
217
- color,
218
- metalness,
219
- roughness,
220
- opacity,
221
- transparent: opacity < 1,
222
- side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
223
- depthWrite: opacity >= 0.9,
224
- })
247
+ shape === 'plane'
248
+ ? this.buildPlaneGeometry(w, h)
249
+ : shape === 'hexagon'
250
+ ? this.buildHexagonGeometry(w, depth, h)
251
+ : this.buildBoxGeometry(w, depth, h)
252
+
253
+ const isPlane = shape === 'plane'
254
+ const material = isPlane
255
+ ? new THREE.MeshBasicMaterial({
256
+ color,
257
+ opacity,
258
+ transparent: opacity < 1,
259
+ side: THREE.DoubleSide,
260
+ })
261
+ : new THREE.MeshStandardMaterial({
262
+ color,
263
+ metalness,
264
+ roughness,
265
+ opacity,
266
+ transparent: opacity < 1,
267
+ side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
268
+ ...(this._config.emissiveIntensity ? { emissive: color, emissiveIntensity: this._config.emissiveIntensity } : {}),
269
+ })
225
270
  // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
226
271
  material.polygonOffset = true
227
272
  material.polygonOffsetFactor = -1
228
273
  material.polygonOffsetUnit = -1
229
274
 
230
- const mesh = new THREE.Mesh(geometry, material)
231
- // sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), 아니면 자체 중심 (depth/2)
232
- mesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
233
- mesh.castShadow = true
234
- mesh.receiveShadow = true
235
- this.object3d.add(mesh)
275
+ // BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5)
276
+ let meshMaterial: THREE.Material | THREE.Material[] = material
277
+ if (shape === 'box') {
278
+ if (this._config.openTop) {
279
+ // 상단면 제거 → 5면 개방 박스
280
+ const invisible = new THREE.MeshBasicMaterial({ visible: false })
281
+ meshMaterial = [material, material, invisible, material, material, material]
282
+ } else if (this._config.texturedTop) {
283
+ // 상판에 2D 렌더링 텍스처
284
+ const topMat = this.buildTopTexture(w, h)
285
+ meshMaterial = [material, material, topMat, material, material, material]
286
+ } else if (this._config.matteTop) {
287
+ // 상판만 무반사
288
+ const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 })
289
+ meshMaterial = [material, material, matte, material, material, material]
290
+ }
291
+ }
292
+
293
+ this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
294
+ // ownGroup 기준(bottom-up): mesh 바닥을 y=0에 맞추고 ownGroup이 -d/2로 shift해
295
+ // center-origin object3d에 정렬됨.
296
+ // box/hexagon (center-local geometry): y=rawDepth/2 (바닥을 0에 맞춤)
297
+ // plane: y=1 (바닥 살짝 띄움)
298
+ this._ownMesh.position.y = isPlane ? 1 : rawDepth / 2
299
+ this._ownMesh.castShadow = !isPlane
300
+ this._ownMesh.receiveShadow = true
301
+ this.components3D.add(this._ownMesh)
236
302
  }
237
303
 
238
304
  // ── Change handling ──
239
305
 
306
+ private _updatePending = false
307
+
240
308
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
241
309
  if (
242
310
  'width' in after ||
@@ -244,18 +312,117 @@ class MCSRealObject3D extends RealObjectGroup {
244
312
  'depth' in after ||
245
313
  'fillStyle' in after ||
246
314
  'strokeStyle' in after ||
247
- 'id' in after // Carrier: CARRIERNAME 변경 시 가시성 재판정
315
+ 'id' in after ||
316
+ 'EMPTYTYPE' in after ||
317
+ 'CARRIERSTATUS' in after
248
318
  ) {
249
- this.update()
319
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
320
+ if (!this._updatePending) {
321
+ this._updatePending = true
322
+ queueMicrotask(() => {
323
+ this._updatePending = false
324
+ this.update()
325
+ })
326
+ }
250
327
  return
251
328
  }
252
329
 
253
330
  super.onchange(after, before)
254
331
  }
255
332
 
256
- // RealObjectGroup 기본 동작 비활성화 (update()에서 일괄 처리)
333
+ /**
334
+ * 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용
335
+ */
336
+ protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial {
337
+ const scale = 4
338
+ const cw = this.nextPow2(w * scale)
339
+ const ch = this.nextPow2(h * scale)
340
+ const canvas = document.createElement('canvas')
341
+ canvas.width = cw
342
+ canvas.height = ch
343
+ const ctx = canvas.getContext('2d')!
344
+
345
+ const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0')
346
+ const emptyType = (this.component.state as any)?.EMPTYTYPE || ''
347
+ const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : ''
348
+
349
+ // 실제 사용 영역 (nextPow2 이전 크기)
350
+ const usedW = w * scale
351
+ const usedH = h * scale
352
+
353
+ // 바탕: 캐리어 상태 색상 (사용 영역만)
354
+ ctx.fillStyle = statusColor
355
+ ctx.fillRect(0, 0, usedW, usedH)
356
+
357
+ // 텍스트 (사용 영역 중앙)
358
+ if (text) {
359
+ const fontSize = Math.round(Math.min(usedW, usedH) * 0.7)
360
+ ctx.font = `bold ${fontSize}px Arial`
361
+ ctx.textAlign = 'center'
362
+ ctx.textBaseline = 'middle'
363
+ ctx.fillStyle = '#000000'
364
+ ctx.fillText(text, usedW / 2, usedH / 2)
365
+ }
366
+
367
+ const texture = new THREE.CanvasTexture(canvas)
368
+ texture.offset.set(0, 1 - usedH / ch)
369
+ texture.repeat.set(usedW / cw, usedH / ch)
370
+ texture.colorSpace = THREE.SRGBColorSpace
371
+
372
+ return new THREE.MeshBasicMaterial({
373
+ map: texture,
374
+ transparent: true,
375
+ side: THREE.DoubleSide,
376
+ })
377
+ }
378
+
379
+ private nextPow2(n: number): number {
380
+ if (!(n > 0)) return 1
381
+ let v = Math.ceil(n)
382
+ v--
383
+ v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16
384
+ return v + 1
385
+ }
386
+
387
+ /**
388
+ * 자신의 mesh만 제거 후 rebuild.
389
+ * 기본 update()는 clear()로 object3d의 모든 children을 제거하는데,
390
+ * 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨.
391
+ * 이를 방지하기 위해 자신이 생성한 mesh만 제거한다.
392
+ */
393
+ update() {
394
+ // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
395
+ if (this._ownMesh) {
396
+ this.components3D.remove(this._ownMesh)
397
+ disposeObject3D(this._ownMesh)
398
+ this._ownMesh = undefined
399
+ }
400
+
401
+ this.build()
402
+ this.updateTransform()
403
+ this.updateAlpha()
404
+ this.updateHidden()
405
+ }
406
+
407
+ // RealObjectGroup 기본 동작 비활성화
257
408
  updateDimension() {}
258
- updateAlpha() {}
409
+
410
+ updateAlpha() {
411
+ const state = this.component.state
412
+ // alpha가 명시적으로 설정되어 있으면 그 값을 사용, 없으면 config 기본값 적용
413
+ const effective = ('alpha' in state) ? (state.alpha as number) : this._config.opacity
414
+
415
+ this.object3d.traverse((o: any) => {
416
+ if (!o.material) return
417
+ const materials = Array.isArray(o.material) ? o.material : [o.material]
418
+ for (const m of materials) {
419
+ if (!m.visible) continue // openTop invisible face 무시
420
+ if (m.map) continue // 텍스처 material은 자체 transparent 유지
421
+ m.opacity = effective
422
+ m.transparent = effective < 1
423
+ }
424
+ })
425
+ }
259
426
  }
260
427
 
261
428
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
@@ -287,18 +454,106 @@ register('Buffer', {
287
454
  })
288
455
 
289
456
  register('AGVLine', {
290
- depthRatio: 0.12,
457
+ depthRatio: 0,
458
+ defaultDepth: 0.1,
291
459
  metalness: 0.6,
292
460
  roughness: 0.3,
293
461
  defaultColor: 0x888899,
294
462
  })
295
463
 
296
- register('OHTLine', {
297
- depthRatio: 0.12,
298
- metalness: 0.6,
299
- roughness: 0.3,
300
- defaultColor: 0x888899,
301
- })
464
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
465
+ // OHTLine — 천장 I-beam 레일
466
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
467
+
468
+ const OHT_LINE_RAIL_COLOR = 0xcccccc
469
+ const OHT_LINE_HEIGHT = 7 * TRANSPORT_SURFACE_HEIGHT // OHT 상단과 일치
470
+
471
+ class OHTLineRealObject3D extends RealObjectGroup {
472
+ private _mesh?: THREE.Mesh
473
+
474
+ get effectiveDepth(): number { return 0 }
475
+ protected get syncZPosOffset(): number { return OHT_LINE_HEIGHT }
476
+
477
+ build() {
478
+ super.build()
479
+
480
+ const { width = 10, height = 10 } = this.component.state
481
+ const w = Math.abs(width)
482
+ const h = Math.abs(height)
483
+ if (!w || !h) return
484
+
485
+ // I-beam 치수: 이송 방향 = 긴 변, 폭은 2D의 1/2
486
+ const isVertical = w < h
487
+ const shortSide = isVertical ? w : h // 2D 원본 짧은 쪽 폭
488
+ const beltW = shortSide * 0.25 // I-beam 폭: 원본의 1/4
489
+ const beamH = Math.max(beltW * 0.5, 3) // I-beam 전체 높이
490
+ const inset = shortSide * 0.5 // 양끝 여백: 원본 폭의 1/2
491
+ const railLen = (isVertical ? h : w) - inset * 2
492
+ const flangeH = beamH * 0.2 // 플랜지 두께
493
+ const flangeW = beltW * 0.8 // 플랜지 폭
494
+ const webH = beamH - flangeH * 2 // 웹 높이
495
+ const webW = Math.max(flangeW * 0.25, 1) // 웹 두께
496
+
497
+ const mat = new THREE.MeshStandardMaterial({
498
+ color: OHT_LINE_RAIL_COLOR,
499
+ metalness: 0.5,
500
+ roughness: 0.4,
501
+ })
502
+
503
+ const geoms: THREE.BufferGeometry[] = []
504
+
505
+ // 상부 플랜지
506
+ const topFlange = isVertical
507
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
508
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
509
+ topFlange.translate(0, -flangeH / 2, 0)
510
+ geoms.push(topFlange)
511
+
512
+ // 웹 (수직판)
513
+ const web = isVertical
514
+ ? new THREE.BoxGeometry(webW, webH, railLen)
515
+ : new THREE.BoxGeometry(railLen, webH, webW)
516
+ web.translate(0, -flangeH - webH / 2, 0)
517
+ geoms.push(web)
518
+
519
+ // 하부 플랜지
520
+ const botFlange = isVertical
521
+ ? new THREE.BoxGeometry(flangeW, flangeH, railLen)
522
+ : new THREE.BoxGeometry(railLen, flangeH, flangeW)
523
+ botFlange.translate(0, -flangeH - webH - flangeH / 2, 0)
524
+ geoms.push(botFlange)
525
+
526
+ this._mesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(geoms), mat)
527
+ this._mesh.castShadow = true
528
+ this.object3d.add(this._mesh)
529
+ }
530
+
531
+ update() {
532
+ if (this._mesh) {
533
+ this.object3d.remove(this._mesh)
534
+ this._mesh.geometry.dispose()
535
+ ;(this._mesh.material as THREE.Material).dispose()
536
+ this._mesh = undefined
537
+ }
538
+ this.build()
539
+ this.updateTransform()
540
+ this.updateHidden()
541
+ }
542
+
543
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
544
+ if ('width' in after || 'height' in after || 'depth' in after ||
545
+ 'fillStyle' in after || 'strokeStyle' in after) {
546
+ this.update()
547
+ return
548
+ }
549
+ super.onchange(after, before)
550
+ }
551
+
552
+ updateDimension() {}
553
+ updateAlpha() {}
554
+ }
555
+
556
+ registerRealObjectFactory('OHTLine', (component: Component) => new OHTLineRealObject3D(component))
302
557
 
303
558
  // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
304
559
  const STOCKER_CONFIG: MCS3DConfig = {
@@ -396,8 +651,9 @@ class StockerRealObject3D extends MCSRealObject3D {
396
651
  mat.polygonOffsetUnit = -2
397
652
 
398
653
  this.gaugeMesh = new THREE.Mesh(geo, mat)
654
+ // ownGroup 기준(bottom-up): 볼륨 상단 = +depth. 그 위로 0.5 띄움.
399
655
  this.gaugeMesh.position.set(0, depth + 0.5, 0)
400
- this.object3d.add(this.gaugeMesh)
656
+ this.components3D.add(this.gaugeMesh)
401
657
  }
402
658
 
403
659
  private resolveGauge(data: any, mode: string) {
@@ -442,7 +698,7 @@ class StockerRealObject3D extends MCSRealObject3D {
442
698
  const fullup = data.FULLUP === 'T'
443
699
  const usage = Math.round((cur / max) * 100)
444
700
  const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
445
- const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
701
+ const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
446
702
  return {
447
703
  segments: [{ ratio: usage / 100, color }],
448
704
  borderColor: color,
@@ -452,6 +708,11 @@ class StockerRealObject3D extends MCSRealObject3D {
452
708
  }
453
709
  }
454
710
 
711
+ update() {
712
+ this.clearGauge()
713
+ super.update()
714
+ }
715
+
455
716
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
456
717
  if ('data' in after) {
457
718
  this.clearGauge()
@@ -467,7 +728,7 @@ class StockerRealObject3D extends MCSRealObject3D {
467
728
 
468
729
  private clearGauge() {
469
730
  if (this.gaugeMesh) {
470
- this.object3d.remove(this.gaugeMesh)
731
+ this.components3D.remove(this.gaugeMesh)
471
732
  this.gaugeMesh.geometry?.dispose()
472
733
  const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
473
734
  mat?.map?.dispose()
@@ -483,14 +744,18 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
483
744
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
484
745
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
485
746
 
486
- register('Port', {
487
- depthRatio: 0.5,
747
+ // Port: 바닥판 (PlaneGeometry)
748
+ const PORT_CONFIG: MCS3DConfig = {
749
+ ...DEFAULTS,
750
+ depthRatio: 0.01,
488
751
  defaultZPosFromParent: true,
489
- opacity: 0.5,
490
- metalness: 0.4,
491
- roughness: 0.6,
752
+ opacity: 0.1,
753
+ metalness: 0.3,
754
+ roughness: 0.8,
492
755
  defaultColor: 0xb0a090,
493
- })
756
+ shape: 'plane',
757
+ }
758
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
494
759
 
495
760
  register('Shelf', {
496
761
  depthRatio: 0.25,
@@ -499,14 +764,25 @@ register('Shelf', {
499
764
  defaultColor: 0xaabbcc,
500
765
  })
501
766
 
502
- register('Carrier', {
767
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
768
+ const CARRIER_CONFIG: MCS3DConfig = {
769
+ ...DEFAULTS,
503
770
  depthRatio: 0.5,
504
- metalness: 0.2,
505
- roughness: 0.8,
506
- defaultColor: 0x66bb66,
771
+ metalness: 0,
772
+ roughness: 1,
773
+ defaultColor: 0xf0f0f0,
507
774
  isVisible: (c: Component) => !!(c.state as any).id,
508
775
  sizeRatio: 0.8,
509
- })
776
+ texturedTop: true,
777
+ }
778
+
779
+ class CarrierRealObject3D extends MCSRealObject3D {
780
+ constructor(component: Component) {
781
+ super(component, CARRIER_CONFIG)
782
+ }
783
+ }
784
+
785
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
510
786
 
511
787
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
512
788
  // Gauge (용량 게이지) — 얇은 바
@@ -516,8 +792,221 @@ register('Carrier', {
516
792
  // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
517
793
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
518
794
 
519
- // Gauge 컴포넌트 (StockerCapacityBar, ZoneCapacityBar, MCSGaugeCapacityBar)는
520
- // 3D에서 표시하지 않음. 대신 부모(STOCKER 등)의 3D 모델에서 직접 게이지를 렌더링.
521
- registerRealObjectFactory('StockerCapacityBar', () => null as any)
522
- registerRealObjectFactory('MCSGaugeCapacityBar', () => null as any)
523
- registerRealObjectFactory('ZoneCapacityBar', () => null as any)
795
+ // Gauge 컴포넌트는 3D에서 표시하지 않음 (부모 STOCKER의 3D 모델에서 직접 게이지 렌더링)
796
+ // null 반환으로 3D 오브젝트 생성을 억제하고, default factory fallback도 방지
797
+ registerRealObjectFactory('StockerCapacityBar', () => undefined as any)
798
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => undefined as any)
799
+ registerRealObjectFactory('ZoneCapacityBar', () => undefined as any)
800
+
801
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
802
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
803
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
804
+
805
+ class PortFlowRealObject3D extends RealObjectGroup {
806
+ private _mesh?: THREE.Mesh
807
+ private _texture?: THREE.CanvasTexture
808
+
809
+ get effectiveDepth(): number { return 0 }
810
+ protected get syncZPosOffset(): number {
811
+ // 부모 Port/Conveyor의 zPos와 동일하게
812
+ const parent = (this.component as any).parentObject
813
+ if (parent?.state?.zPos != null) return parent.state.zPos
814
+ return TRANSPORT_SURFACE_HEIGHT
815
+ }
816
+
817
+ build() {
818
+ super.build()
819
+
820
+ const { width = 10, height = 10 } = this.component.state
821
+ const w = Math.abs(width)
822
+ const h = Math.abs(height)
823
+ if (!w || !h) return
824
+
825
+ // 2D render를 캔버스에 캡처
826
+ // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산
827
+ const { left, top } = this.component.bounds
828
+ const { fontSize = 24, labelPosition = 'top' } = this.component.state
829
+ const arrowPad = Math.min(w, h) * 0.4
830
+
831
+ // 텍스트 영역 확장
832
+ let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad
833
+ if ((this.component as any).hasTextProperty) {
834
+ switch (labelPosition) {
835
+ case 'top': padTop = Math.max(padTop, fontSize + 4); break
836
+ case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break
837
+ case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break
838
+ case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break
839
+ }
840
+ }
841
+
842
+ const totalW = w + padLeft + padRight
843
+ const totalH = h + padTop + padBottom
844
+ const scale = 2
845
+ const canvas = document.createElement('canvas')
846
+ canvas.width = totalW * scale
847
+ canvas.height = totalH * scale
848
+ const ctx = canvas.getContext('2d')!
849
+
850
+ ctx.save()
851
+ ctx.scale(scale, scale)
852
+ ctx.translate(-left + padLeft, -top + padTop)
853
+ try {
854
+ // 화살표 렌더
855
+ this.component.render(ctx)
856
+ ctx.fillStyle = this.component.state.strokeStyle || '#666666'
857
+ ctx.fill()
858
+ // 텍스트 렌더
859
+ if ((this.component as any).hasTextProperty && (this.component as any).drawText) {
860
+ ;(this.component as any).drawText(ctx)
861
+ }
862
+ } catch { /* render 실패 시 빈 텍스처 */ }
863
+ ctx.restore()
864
+
865
+ this._texture = new THREE.CanvasTexture(canvas)
866
+ this._texture.colorSpace = THREE.SRGBColorSpace
867
+
868
+ const geo = new THREE.PlaneGeometry(totalW, totalH)
869
+ geo.rotateX(-Math.PI / 2)
870
+
871
+ const mat = new THREE.MeshBasicMaterial({
872
+ map: this._texture,
873
+ alphaTest: 0.1,
874
+ side: THREE.DoubleSide,
875
+ })
876
+
877
+ this._mesh = new THREE.Mesh(geo, mat)
878
+ // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤
879
+ this._mesh.position.x = (padRight - padLeft) / 2
880
+ this._mesh.position.y = 1
881
+ this._mesh.position.z = (padBottom - padTop) / 2
882
+ this.object3d.add(this._mesh)
883
+ }
884
+
885
+ update() {
886
+ if (this._mesh) {
887
+ this.object3d.remove(this._mesh)
888
+ this._mesh.geometry.dispose()
889
+ ;(this._mesh.material as THREE.Material).dispose()
890
+ this._texture?.dispose()
891
+ this._mesh = undefined
892
+ this._texture = undefined
893
+ }
894
+ this.build()
895
+ this.updateTransform()
896
+ this.updateHidden()
897
+ }
898
+
899
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
900
+ if ('width' in after || 'height' in after || 'orientation' in after ||
901
+ 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) {
902
+ this.update()
903
+ return
904
+ }
905
+ super.onchange(after, before)
906
+ }
907
+
908
+ updateDimension() {}
909
+ updateAlpha() {}
910
+ }
911
+
912
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
913
+
914
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
915
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
916
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
917
+
918
+ class NodeRealObject3D extends RealObjectGroup {
919
+ private _mesh?: THREE.Mesh
920
+ private _texture?: THREE.CanvasTexture
921
+
922
+ get effectiveDepth(): number { return 0 }
923
+ protected get syncZPosOffset(): number { return 0 }
924
+
925
+ build() {
926
+ super.build()
927
+
928
+ const { cx, cy } = this.component.state
929
+ const radius = 8
930
+ const size = radius * 2
931
+ const scale = 2
932
+ const pad = 2
933
+
934
+ const canvas = document.createElement('canvas')
935
+ canvas.width = (size + pad * 2) * scale
936
+ canvas.height = (size + pad * 2) * scale
937
+ const ctx = canvas.getContext('2d')!
938
+
939
+ ctx.save()
940
+ ctx.scale(scale, scale)
941
+ ctx.translate(-cx + radius + pad, -cy + radius + pad)
942
+ try {
943
+ this.component.render(ctx)
944
+ const { fillStyle = '#888888' } = this.component.state
945
+ ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888'
946
+ ctx.fill()
947
+ const { strokeStyle } = this.component.state
948
+ if (strokeStyle && typeof strokeStyle === 'string') {
949
+ ctx.strokeStyle = strokeStyle
950
+ ctx.lineWidth = 1
951
+ ctx.stroke()
952
+ }
953
+ } catch { /* render 실패 시 빈 텍스처 */ }
954
+ ctx.restore()
955
+
956
+ this._texture = new THREE.CanvasTexture(canvas)
957
+ this._texture.colorSpace = THREE.SRGBColorSpace
958
+
959
+ const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2)
960
+ geo.rotateX(-Math.PI / 2)
961
+
962
+ const mat = new THREE.MeshBasicMaterial({
963
+ map: this._texture,
964
+ alphaTest: 0.1,
965
+ side: THREE.DoubleSide,
966
+ })
967
+
968
+ this._mesh = new THREE.Mesh(geo, mat)
969
+ this._mesh.position.y = 1
970
+ this.object3d.add(this._mesh)
971
+ }
972
+
973
+ update() {
974
+ if (this._mesh) {
975
+ this.object3d.remove(this._mesh)
976
+ this._mesh.geometry.dispose()
977
+ ;(this._mesh.material as THREE.Material).dispose()
978
+ this._texture?.dispose()
979
+ this._mesh = undefined
980
+ this._texture = undefined
981
+ }
982
+ this.build()
983
+ this.updateTransform()
984
+ this.updateHidden()
985
+ }
986
+
987
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
988
+ if ('fillStyle' in after || 'strokeStyle' in after) {
989
+ this.update()
990
+ return
991
+ }
992
+ super.onchange(after, before)
993
+ }
994
+
995
+ updateDimension() {}
996
+ updateAlpha() {}
997
+ }
998
+
999
+ registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component))
1000
+
1001
+ // Scanner — 이송 표면 높이의 기둥 형태
1002
+ const SCANNER_CONFIG: MCS3DConfig = {
1003
+ ...DEFAULTS,
1004
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2,
1005
+ defaultZPosFromParent: true,
1006
+ opacity: 1,
1007
+ metalness: 0.3,
1008
+ roughness: 0.7,
1009
+ defaultColor: 0x888888,
1010
+ shape: 'box',
1011
+ }
1012
+ registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))