@fmsim/machine 2.0.0-beta.3 → 2.0.0-beta.30

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 (56) hide show
  1. package/dist/carrier-manager.js +92 -0
  2. package/dist/carrier-manager.js.map +1 -0
  3. package/dist/carrier.js +51 -6
  4. package/dist/carrier.js.map +1 -1
  5. package/dist/conveyor-join.js +8 -2
  6. package/dist/conveyor-join.js.map +1 -1
  7. package/dist/conveyor.js +8 -0
  8. package/dist/conveyor.js.map +1 -1
  9. package/dist/factories/agv-3d.js +36 -8
  10. package/dist/factories/agv-3d.js.map +1 -1
  11. package/dist/factories/conveyor-3d.js +159 -69
  12. package/dist/factories/conveyor-3d.js.map +1 -1
  13. package/dist/factories/machine-3d.js +604 -82
  14. package/dist/factories/machine-3d.js.map +1 -1
  15. package/dist/factories/oht-3d.js +50 -24
  16. package/dist/factories/oht-3d.js.map +1 -1
  17. package/dist/factories/shuttle-3d.js +32 -6
  18. package/dist/factories/shuttle-3d.js.map +1 -1
  19. package/dist/index.js +50 -34
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcs-carrier-holder.js +39 -22
  22. package/dist/mcs-carrier-holder.js.map +1 -1
  23. package/dist/node.js +1 -1
  24. package/dist/node.js.map +1 -1
  25. package/dist/scanner.js +30 -0
  26. package/dist/scanner.js.map +1 -0
  27. package/dist/scene/root-container-override.js +9 -0
  28. package/dist/scene/root-container-override.js.map +1 -1
  29. package/dist/templates/index.js +2 -0
  30. package/dist/templates/index.js.map +1 -1
  31. package/dist/templates/scanner.js +20 -0
  32. package/dist/templates/scanner.js.map +1 -0
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/jest.config.js +10 -0
  35. package/package.json +4 -3
  36. package/src/carrier-manager.ts +116 -0
  37. package/src/carrier.ts +50 -4
  38. package/src/conveyor-join.ts +5 -2
  39. package/src/conveyor.ts +8 -0
  40. package/src/factories/agv-3d.ts +36 -8
  41. package/src/factories/conveyor-3d.ts +161 -71
  42. package/src/factories/machine-3d.ts +708 -72
  43. package/src/factories/oht-3d.ts +48 -24
  44. package/src/factories/shuttle-3d.ts +30 -6
  45. package/src/index.ts +56 -34
  46. package/src/mcs-carrier-holder.ts +38 -34
  47. package/src/node.ts +1 -1
  48. package/src/scanner.ts +29 -0
  49. package/src/scene/root-container-override.ts +12 -0
  50. package/src/templates/index.ts +2 -0
  51. package/src/templates/scanner.ts +20 -0
  52. package/translations/en.json +2 -1
  53. package/translations/ja.json +2 -1
  54. package/translations/ko.json +2 -1
  55. package/translations/ms.json +2 -1
  56. package/translations/zh.json +2 -1
@@ -9,8 +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, TriangleExtrude } from '@hatiolab/things-scene'
13
14
  import type { Component } from '@hatiolab/things-scene'
15
+ import { LEGEND_CAPACITY } from '../features/mcs-status-default'
16
+ import { getVaueOnRanges } from '../utils/get-value-on-ranges'
14
17
 
15
18
  // ── 설정 타입 ────────────────────────────────────────────
16
19
 
@@ -28,11 +31,23 @@ interface MCS3DConfig {
28
31
  /** statusColor 미사용 시 기본 색상 */
29
32
  defaultColor: number
30
33
  /** 'box' | 'hexagon' */
31
- shape: 'box' | 'hexagon'
34
+ shape: 'box' | 'hexagon' | 'plane'
32
35
  /** mesh 생성 전 가시성 판정 — false 반환 시 mesh 미생성 */
33
36
  isVisible?: (component: Component) => boolean
34
37
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
35
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
36
51
  }
37
52
 
38
53
  /**
@@ -62,7 +77,7 @@ class MCSRealObject3D extends RealObjectGroup {
62
77
  }
63
78
 
64
79
  // width, height 절대값 (음수 값은 양수로 취급)
65
- private get absSize(): { w: number; h: number } {
80
+ protected get absSize(): { w: number; h: number } {
66
81
  const { width = 10, height = 10 } = this.component.state
67
82
  return { w: Math.abs(width), h: Math.abs(height) }
68
83
  }
@@ -76,14 +91,22 @@ class MCSRealObject3D extends RealObjectGroup {
76
91
  }
77
92
 
78
93
  protected get syncZPosOffset(): number {
94
+ // zPosOverride: 고정 높이 (OHTLine 등)
95
+ if (this._config.zPosOverride != null) {
96
+ return this._config.zPosOverride
97
+ }
79
98
  // Port: 이송 표면 높이에 배치
80
99
  if (this._config.defaultZPosFromParent) {
81
100
  return TRANSPORT_SURFACE_HEIGHT
82
101
  }
83
- // Carrier: Port 안이면 중앙, 그 외 컨테이너면 상단 위
102
+ // Carrier: 부모 유형에 따라 배치 위치 결정
84
103
  if (this._config.sizeRatio && this._config.sizeRatio < 1) {
85
104
  const parentType = this.component.parent?.state?.type
86
- 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') {
87
110
  return 0
88
111
  }
89
112
  return TRANSPORT_SURFACE_HEIGHT
@@ -92,21 +115,21 @@ class MCSRealObject3D extends RealObjectGroup {
92
115
  }
93
116
 
94
117
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
95
- private resolveColor(): number {
118
+ protected resolveColor(): number {
96
119
  const comp = this.component as any
97
120
 
98
121
  // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
99
122
  // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
100
123
  try {
101
124
  const sc = comp.statusColor
102
- 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()
103
126
  } catch {
104
127
  /* statusColor 미구현 시 무시 */
105
128
  }
106
129
 
107
130
  // 2) state.fillStyle fallback
108
131
  const { fillStyle } = this.component.state
109
- if (fillStyle && typeof fillStyle === 'string') {
132
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
110
133
  try {
111
134
  return new THREE.Color(fillStyle).getHex()
112
135
  } catch {
@@ -155,7 +178,7 @@ class MCSRealObject3D extends RealObjectGroup {
155
178
 
156
179
  private buildBoxGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
157
180
  const geo = new THREE.BoxGeometry(w, d, h)
158
- return this.removeBottomFace(geo)
181
+ return this._config.keepBottomFace ? geo : this.removeBottomFace(geo)
159
182
  }
160
183
 
161
184
  private buildHexagonGeometry(w: number, d: number, h: number): THREE.BufferGeometry {
@@ -186,8 +209,16 @@ class MCSRealObject3D extends RealObjectGroup {
186
209
  return this.removeBottomFace(geo)
187
210
  }
188
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
+
189
218
  // ── Build ──
190
219
 
220
+ protected _ownMesh?: THREE.Mesh
221
+
191
222
  build() {
192
223
  super.build()
193
224
 
@@ -207,34 +238,64 @@ class MCSRealObject3D extends RealObjectGroup {
207
238
  const { opacity, metalness, roughness, shape } = this._config
208
239
 
209
240
  const geometry =
210
- shape === 'hexagon'
211
- ? this.buildHexagonGeometry(w, depth, h)
212
- : this.buildBoxGeometry(w, depth, h)
213
-
214
- const material = new THREE.MeshStandardMaterial({
215
- color,
216
- metalness,
217
- roughness,
218
- opacity,
219
- transparent: opacity < 1,
220
- side: opacity < 1 ? THREE.DoubleSide : THREE.FrontSide,
221
- depthWrite: opacity >= 0.9,
222
- })
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
+ })
223
264
  // z-fighting 방지: 바닥면(floor grid)보다 항상 앞에 렌더링
224
265
  material.polygonOffset = true
225
266
  material.polygonOffsetFactor = -1
226
267
  material.polygonOffsetUnit = -1
227
268
 
228
- const mesh = new THREE.Mesh(geometry, material)
229
- // sizeRatio < 1이면 부모 중심에 배치 (rawDepth/2), 아니면 자체 중심 (depth/2)
230
- mesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
231
- mesh.castShadow = true
232
- mesh.receiveShadow = true
233
- 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)
234
293
  }
235
294
 
236
295
  // ── Change handling ──
237
296
 
297
+ private _updatePending = false
298
+
238
299
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
239
300
  if (
240
301
  'width' in after ||
@@ -242,18 +303,117 @@ class MCSRealObject3D extends RealObjectGroup {
242
303
  'depth' in after ||
243
304
  'fillStyle' in after ||
244
305
  'strokeStyle' in after ||
245
- 'id' in after // Carrier: CARRIERNAME 변경 시 가시성 재판정
306
+ 'id' in after ||
307
+ 'EMPTYTYPE' in after ||
308
+ 'CARRIERSTATUS' in after
246
309
  ) {
247
- this.update()
310
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
311
+ if (!this._updatePending) {
312
+ this._updatePending = true
313
+ queueMicrotask(() => {
314
+ this._updatePending = false
315
+ this.update()
316
+ })
317
+ }
248
318
  return
249
319
  }
250
320
 
251
321
  super.onchange(after, before)
252
322
  }
253
323
 
254
- // 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 기본 동작 비활성화
255
399
  updateDimension() {}
256
- 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
+ }
257
417
  }
258
418
 
259
419
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
@@ -269,7 +429,7 @@ function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
269
429
 
270
430
  register('Equipment', {
271
431
  defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT,
272
- opacity: 0.5,
432
+ opacity: 0.75,
273
433
  metalness: 0.4,
274
434
  roughness: 0.6,
275
435
  defaultColor: 0x8899aa,
@@ -277,7 +437,7 @@ register('Equipment', {
277
437
 
278
438
  register('Buffer', {
279
439
  defaultDepth: TRANSPORT_SURFACE_HEIGHT,
280
- opacity: 0.5,
440
+ opacity: 1.0,
281
441
  metalness: 0.3,
282
442
  roughness: 0.7,
283
443
  defaultColor: 0x99aa88,
@@ -285,39 +445,302 @@ register('Buffer', {
285
445
  })
286
446
 
287
447
  register('AGVLine', {
288
- depthRatio: 0.12,
448
+ depthRatio: 0,
449
+ defaultDepth: 0.1,
289
450
  metalness: 0.6,
290
451
  roughness: 0.3,
291
452
  defaultColor: 0x888899,
292
453
  })
293
454
 
294
- register('OHTLine', {
295
- depthRatio: 0.12,
296
- metalness: 0.6,
297
- roughness: 0.3,
298
- defaultColor: 0x888899,
299
- })
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
+ }
300
521
 
301
- register('STOCKER', {
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))
548
+
549
+ // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
550
+ const STOCKER_CONFIG: MCS3DConfig = {
551
+ ...DEFAULTS,
302
552
  defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT,
303
- opacity: 0.35,
553
+ opacity: 0.75,
304
554
  metalness: 0.3,
305
555
  roughness: 0.6,
306
556
  defaultColor: 0x8899bb,
307
- })
557
+ }
558
+
559
+ class StockerRealObject3D extends MCSRealObject3D {
560
+ private gaugeMesh?: THREE.Mesh
561
+
562
+ constructor(component: Component) {
563
+ super(component, STOCKER_CONFIG)
564
+ }
565
+
566
+ build() {
567
+ super.build()
568
+ this.buildGauge()
569
+ }
570
+
571
+ private buildGauge() {
572
+ const comp = this.component as any
573
+ const data = comp.data || {}
574
+ const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
575
+
576
+ const gauge = this.resolveGauge(data, gaugeMode)
577
+
578
+ // 테두리는 스토커의 상태 색상 (machine status)
579
+ const statusColor = comp.statusColor
580
+ if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') {
581
+ gauge.borderColor = statusColor
582
+ }
583
+
584
+ const { w, h } = this.absSize
585
+ const depth = this.effectiveDepth
586
+ const isWidthLonger = w >= h
587
+
588
+ // 게이지 캔버스 (고해상도)
589
+ const barW = 130
590
+ const barH = 26
591
+ const scale = 4
592
+ const canvasW = barW * scale
593
+ const canvasH = barH * scale
594
+ const canvas = document.createElement('canvas')
595
+ canvas.width = canvasW
596
+ canvas.height = canvasH
597
+ const ctx = canvas.getContext('2d')!
598
+
599
+ const pad = 12
600
+
601
+ // 배경 + 테두리
602
+ ctx.fillStyle = '#dddddd'
603
+ ctx.fillRect(0, 0, canvasW, canvasH)
604
+ ctx.strokeStyle = gauge.borderColor
605
+ ctx.lineWidth = 12
606
+ ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
607
+
608
+ // 세그먼트 채움 (왼→오)
609
+ const innerW = canvasW - pad * 2
610
+ const innerH = canvasH - pad * 2
611
+ let x = pad
612
+ for (const seg of gauge.segments) {
613
+ const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
614
+ if (segW <= 0) continue
615
+ ctx.fillStyle = seg.color
616
+ ctx.fillRect(x, pad, segW, innerH)
617
+ x += segW
618
+ }
619
+
620
+ // 텍스트
621
+ ctx.font = `bold ${10 * scale}px sans-serif`
622
+ ctx.textAlign = 'center'
623
+ ctx.textBaseline = 'middle'
624
+ ctx.fillStyle = '#333333'
625
+ ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
626
+
627
+ // 텍스처 → Plane
628
+ const texture = new THREE.CanvasTexture(canvas)
629
+ texture.colorSpace = THREE.SRGBColorSpace
630
+
631
+ const geo = new THREE.PlaneGeometry(barW, barH)
632
+ geo.rotateX(-Math.PI / 2) // 수평 (지붕 위)
633
+ if (!isWidthLonger) geo.rotateY(Math.PI / 2)
634
+
635
+ const mat = new THREE.MeshBasicMaterial({
636
+ map: texture,
637
+ transparent: true,
638
+ side: THREE.DoubleSide,
639
+ })
640
+ mat.polygonOffset = true
641
+ mat.polygonOffsetFactor = -2
642
+ mat.polygonOffsetUnit = -2
643
+
644
+ this.gaugeMesh = new THREE.Mesh(geo, mat)
645
+ this.gaugeMesh.position.set(0, depth + 0.5, 0)
646
+ this.object3d.add(this.gaugeMesh)
647
+ }
648
+
649
+ private resolveGauge(data: any, mode: string) {
650
+ switch (mode) {
651
+ case 'carrier': {
652
+ const full = Number(data.FULL_CNT) || 0
653
+ const empty = Number(data.EMPTY_CNT) || 0
654
+ const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0
655
+ const total = full + empty + emptyEmpty
656
+ const segments = total > 0
657
+ ? [
658
+ { ratio: full / total, color: '#4AA7FE' },
659
+ { ratio: empty / total, color: '#8BDA5B' },
660
+ { ratio: emptyEmpty / total, color: '#019D59' },
661
+ ]
662
+ : [{ ratio: 1, color: '#dddddd' }]
663
+ return {
664
+ segments,
665
+ borderColor: '#4AA7FE',
666
+ label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '',
667
+ }
668
+ }
669
+ case 'abnormal': {
670
+ const normal = Number(data.CARRIER_NORMAL) || 0
671
+ const abnormal = Number(data.CARRIER_ABNORMAL) || 0
672
+ const total = normal + abnormal
673
+ const segments = total > 0
674
+ ? [
675
+ { ratio: normal / total, color: '#8BDA5B' },
676
+ { ratio: abnormal / total, color: '#F4B4B4' },
677
+ ]
678
+ : [{ ratio: 1, color: '#dddddd' }]
679
+ return {
680
+ segments,
681
+ borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B',
682
+ label: total > 0 ? `N${normal} A${abnormal}` : '',
683
+ }
684
+ }
685
+ default: { // capacity
686
+ const cur = Number(data.CURRENTCAPACITY) || 0
687
+ const max = Number(data.MAXCAPACITY) || 100
688
+ const fullup = data.FULLUP === 'T'
689
+ const usage = Math.round((cur / max) * 100)
690
+ const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
691
+ const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
692
+ return {
693
+ segments: [{ ratio: usage / 100, color }],
694
+ borderColor: color,
695
+ label: `${usage}%`,
696
+ }
697
+ }
698
+ }
699
+ }
700
+
701
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
702
+ if ('data' in after) {
703
+ this.clearGauge()
704
+ this.buildGauge()
705
+ }
706
+ super.onchange(after, before)
707
+ }
708
+
709
+ clear() {
710
+ this.clearGauge()
711
+ return super.clear()
712
+ }
713
+
714
+ private clearGauge() {
715
+ if (this.gaugeMesh) {
716
+ this.object3d.remove(this.gaugeMesh)
717
+ this.gaugeMesh.geometry?.dispose()
718
+ const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
719
+ mat?.map?.dispose()
720
+ mat?.dispose()
721
+ this.gaugeMesh = undefined
722
+ }
723
+ }
724
+ }
725
+
726
+ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component))
308
727
 
309
728
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
310
729
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
311
730
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
312
731
 
313
- register('Port', {
314
- depthRatio: 0.5,
732
+ // Port: 바닥판 (PlaneGeometry)
733
+ const PORT_CONFIG: MCS3DConfig = {
734
+ ...DEFAULTS,
735
+ depthRatio: 0.01,
315
736
  defaultZPosFromParent: true,
316
- opacity: 0.5,
317
- metalness: 0.4,
318
- roughness: 0.6,
737
+ opacity: 0.1,
738
+ metalness: 0.3,
739
+ roughness: 0.8,
319
740
  defaultColor: 0xb0a090,
320
- })
741
+ shape: 'plane',
742
+ }
743
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
321
744
 
322
745
  register('Shelf', {
323
746
  depthRatio: 0.25,
@@ -326,36 +749,249 @@ register('Shelf', {
326
749
  defaultColor: 0xaabbcc,
327
750
  })
328
751
 
329
- register('Carrier', {
752
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
753
+ const CARRIER_CONFIG: MCS3DConfig = {
754
+ ...DEFAULTS,
330
755
  depthRatio: 0.5,
331
- metalness: 0.2,
332
- roughness: 0.8,
756
+ metalness: 0,
757
+ roughness: 1,
333
758
  defaultColor: 0x66bb66,
334
759
  isVisible: (c: Component) => !!(c.state as any).id,
335
760
  sizeRatio: 0.8,
336
- })
761
+ texturedTop: true,
762
+ }
763
+
764
+ class CarrierRealObject3D extends MCSRealObject3D {
765
+ constructor(component: Component) {
766
+ super(component, CARRIER_CONFIG)
767
+ }
768
+ }
769
+
770
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
337
771
 
338
772
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
339
773
  // Gauge (용량 게이지) — 얇은 바
340
774
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
341
775
 
342
- register('ZoneCapacityBar', {
343
- depthRatio: 0.15,
344
- metalness: 0.2,
345
- roughness: 0.8,
346
- defaultColor: 0x44aa44,
347
- })
776
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
777
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
778
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
348
779
 
349
- register('StockerCapacityBar', {
350
- depthRatio: 0.15,
351
- metalness: 0.2,
352
- roughness: 0.8,
353
- defaultColor: 0x44aa44,
354
- })
780
+ // Gauge 컴포넌트 (StockerCapacityBar, ZoneCapacityBar, MCSGaugeCapacityBar)는
781
+ // 3D에서 표시하지 않음. 대신 부모(STOCKER 등)의 3D 모델에서 직접 게이지를 렌더링.
782
+ registerRealObjectFactory('StockerCapacityBar', () => null as any)
783
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => null as any)
784
+ registerRealObjectFactory('ZoneCapacityBar', () => null as any)
355
785
 
356
- register('MCSGaugeCapacityBar', {
357
- depthRatio: 0.15,
358
- metalness: 0.2,
359
- roughness: 0.8,
360
- defaultColor: 0x44aa44,
361
- })
786
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
787
+ // PortFlow — 2D render를 Plane 텍스처로 3D 표시
788
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
789
+
790
+ class PortFlowRealObject3D extends RealObjectGroup {
791
+ private _mesh?: THREE.Mesh
792
+ private _texture?: THREE.CanvasTexture
793
+
794
+ get effectiveDepth(): number { return 0 }
795
+ protected get syncZPosOffset(): number {
796
+ // 부모 Port/Conveyor의 zPos와 동일하게
797
+ const parent = (this.component as any).parentObject
798
+ if (parent?.state?.zPos != null) return parent.state.zPos
799
+ return TRANSPORT_SURFACE_HEIGHT
800
+ }
801
+
802
+ build() {
803
+ super.build()
804
+
805
+ const { width = 10, height = 10 } = this.component.state
806
+ const w = Math.abs(width)
807
+ const h = Math.abs(height)
808
+ if (!w || !h) return
809
+
810
+ // 2D render를 캔버스에 캡처
811
+ // 화살표 날개 + 텍스트 영역이 bounds 밖으로 나가므로 전체 영역 계산
812
+ const { left, top } = this.component.bounds
813
+ const { fontSize = 24, labelPosition = 'top' } = this.component.state
814
+ const arrowPad = Math.min(w, h) * 0.4
815
+
816
+ // 텍스트 영역 확장
817
+ let padLeft = arrowPad, padRight = arrowPad, padTop = arrowPad, padBottom = arrowPad
818
+ if ((this.component as any).hasTextProperty) {
819
+ switch (labelPosition) {
820
+ case 'top': padTop = Math.max(padTop, fontSize + 4); break
821
+ case 'bottom': padBottom = Math.max(padBottom, fontSize + 4); break
822
+ case 'left': padLeft = Math.max(padLeft, fontSize * 4 + 4); break
823
+ case 'right': padRight = Math.max(padRight, fontSize * 4 + 4); break
824
+ }
825
+ }
826
+
827
+ const totalW = w + padLeft + padRight
828
+ const totalH = h + padTop + padBottom
829
+ const scale = 2
830
+ const canvas = document.createElement('canvas')
831
+ canvas.width = totalW * scale
832
+ canvas.height = totalH * scale
833
+ const ctx = canvas.getContext('2d')!
834
+
835
+ ctx.save()
836
+ ctx.scale(scale, scale)
837
+ ctx.translate(-left + padLeft, -top + padTop)
838
+ try {
839
+ // 화살표 렌더
840
+ this.component.render(ctx)
841
+ ctx.fillStyle = this.component.state.strokeStyle || '#666666'
842
+ ctx.fill()
843
+ // 텍스트 렌더
844
+ if ((this.component as any).hasTextProperty && (this.component as any).drawText) {
845
+ ;(this.component as any).drawText(ctx)
846
+ }
847
+ } catch { /* render 실패 시 빈 텍스처 */ }
848
+ ctx.restore()
849
+
850
+ this._texture = new THREE.CanvasTexture(canvas)
851
+ this._texture.colorSpace = THREE.SRGBColorSpace
852
+
853
+ const geo = new THREE.PlaneGeometry(totalW, totalH)
854
+ geo.rotateX(-Math.PI / 2)
855
+
856
+ const mat = new THREE.MeshBasicMaterial({
857
+ map: this._texture,
858
+ alphaTest: 0.1,
859
+ side: THREE.DoubleSide,
860
+ })
861
+
862
+ this._mesh = new THREE.Mesh(geo, mat)
863
+ // 비대칭 패딩 보정: 플레인 센터를 컴포넌트 bounds 센터에 맞춤
864
+ this._mesh.position.x = (padRight - padLeft) / 2
865
+ this._mesh.position.y = 1
866
+ this._mesh.position.z = (padBottom - padTop) / 2
867
+ this.object3d.add(this._mesh)
868
+ }
869
+
870
+ update() {
871
+ if (this._mesh) {
872
+ this.object3d.remove(this._mesh)
873
+ this._mesh.geometry.dispose()
874
+ ;(this._mesh.material as THREE.Material).dispose()
875
+ this._texture?.dispose()
876
+ this._mesh = undefined
877
+ this._texture = undefined
878
+ }
879
+ this.build()
880
+ this.updateTransform()
881
+ this.updateHidden()
882
+ }
883
+
884
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
885
+ if ('width' in after || 'height' in after || 'orientation' in after ||
886
+ 'direction' in after || 'strokeStyle' in after || 'INOUTTYPE' in after) {
887
+ this.update()
888
+ return
889
+ }
890
+ super.onchange(after, before)
891
+ }
892
+
893
+ updateDimension() {}
894
+ updateAlpha() {}
895
+ }
896
+
897
+ registerRealObjectFactory('PortFlow', (component: Component) => new PortFlowRealObject3D(component))
898
+
899
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
900
+ // Node — 2D render를 바닥 Plane 텍스처로 3D 표시
901
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
902
+
903
+ class NodeRealObject3D extends RealObjectGroup {
904
+ private _mesh?: THREE.Mesh
905
+ private _texture?: THREE.CanvasTexture
906
+
907
+ get effectiveDepth(): number { return 0 }
908
+ protected get syncZPosOffset(): number { return 0 }
909
+
910
+ build() {
911
+ super.build()
912
+
913
+ const { cx, cy } = this.component.state
914
+ const radius = 8
915
+ const size = radius * 2
916
+ const scale = 2
917
+ const pad = 2
918
+
919
+ const canvas = document.createElement('canvas')
920
+ canvas.width = (size + pad * 2) * scale
921
+ canvas.height = (size + pad * 2) * scale
922
+ const ctx = canvas.getContext('2d')!
923
+
924
+ ctx.save()
925
+ ctx.scale(scale, scale)
926
+ ctx.translate(-cx + radius + pad, -cy + radius + pad)
927
+ try {
928
+ this.component.render(ctx)
929
+ const { fillStyle = '#888888' } = this.component.state
930
+ ctx.fillStyle = typeof fillStyle === 'string' ? fillStyle : '#888888'
931
+ ctx.fill()
932
+ const { strokeStyle } = this.component.state
933
+ if (strokeStyle && typeof strokeStyle === 'string') {
934
+ ctx.strokeStyle = strokeStyle
935
+ ctx.lineWidth = 1
936
+ ctx.stroke()
937
+ }
938
+ } catch { /* render 실패 시 빈 텍스처 */ }
939
+ ctx.restore()
940
+
941
+ this._texture = new THREE.CanvasTexture(canvas)
942
+ this._texture.colorSpace = THREE.SRGBColorSpace
943
+
944
+ const geo = new THREE.PlaneGeometry(size + pad * 2, size + pad * 2)
945
+ geo.rotateX(-Math.PI / 2)
946
+
947
+ const mat = new THREE.MeshBasicMaterial({
948
+ map: this._texture,
949
+ alphaTest: 0.1,
950
+ side: THREE.DoubleSide,
951
+ })
952
+
953
+ this._mesh = new THREE.Mesh(geo, mat)
954
+ this._mesh.position.y = 1
955
+ this.object3d.add(this._mesh)
956
+ }
957
+
958
+ update() {
959
+ if (this._mesh) {
960
+ this.object3d.remove(this._mesh)
961
+ this._mesh.geometry.dispose()
962
+ ;(this._mesh.material as THREE.Material).dispose()
963
+ this._texture?.dispose()
964
+ this._mesh = undefined
965
+ this._texture = undefined
966
+ }
967
+ this.build()
968
+ this.updateTransform()
969
+ this.updateHidden()
970
+ }
971
+
972
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
973
+ if ('fillStyle' in after || 'strokeStyle' in after) {
974
+ this.update()
975
+ return
976
+ }
977
+ super.onchange(after, before)
978
+ }
979
+
980
+ updateDimension() {}
981
+ updateAlpha() {}
982
+ }
983
+
984
+ registerRealObjectFactory('Node', (component: Component) => new NodeRealObject3D(component))
985
+
986
+ // Scanner — 이송 표면 높이의 기둥 형태
987
+ const SCANNER_CONFIG: MCS3DConfig = {
988
+ ...DEFAULTS,
989
+ defaultDepth: TRANSPORT_SURFACE_HEIGHT * 0.2,
990
+ defaultZPosFromParent: true,
991
+ opacity: 1,
992
+ metalness: 0.3,
993
+ roughness: 0.7,
994
+ defaultColor: 0x888888,
995
+ shape: 'box',
996
+ }
997
+ registerRealObjectFactory('Scanner', (component: Component) => new MCSRealObject3D(component, SCANNER_CONFIG))