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

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