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

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.
@@ -9,8 +9,10 @@
9
9
  */
10
10
 
11
11
  import * as THREE from 'three'
12
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
12
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
13
13
  import type { Component } from '@hatiolab/things-scene'
14
+ import { LEGEND_CAPACITY } from '../features/mcs-status-default'
15
+ import { getVaueOnRanges } from '../utils/get-value-on-ranges'
14
16
 
15
17
  // ── 설정 타입 ────────────────────────────────────────────
16
18
 
@@ -33,6 +35,12 @@ interface MCS3DConfig {
33
35
  isVisible?: (component: Component) => boolean
34
36
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
35
37
  sizeRatio?: number
38
+ /** 상부 면 제거 (5면 개방 박스) */
39
+ openTop?: boolean
40
+ /** 상판 무반사 (MeshBasicMaterial) */
41
+ matteTop?: boolean
42
+ /** 상판에 2D 렌더링 텍스처 적용 */
43
+ texturedTop?: boolean
36
44
  }
37
45
 
38
46
  /**
@@ -62,7 +70,7 @@ class MCSRealObject3D extends RealObjectGroup {
62
70
  }
63
71
 
64
72
  // width, height 절대값 (음수 값은 양수로 취급)
65
- private get absSize(): { w: number; h: number } {
73
+ protected get absSize(): { w: number; h: number } {
66
74
  const { width = 10, height = 10 } = this.component.state
67
75
  return { w: Math.abs(width), h: Math.abs(height) }
68
76
  }
@@ -92,7 +100,7 @@ class MCSRealObject3D extends RealObjectGroup {
92
100
  }
93
101
 
94
102
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
95
- private resolveColor(): number {
103
+ protected resolveColor(): number {
96
104
  const comp = this.component as any
97
105
 
98
106
  // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
@@ -188,6 +196,8 @@ class MCSRealObject3D extends RealObjectGroup {
188
196
 
189
197
  // ── Build ──
190
198
 
199
+ protected _ownMesh?: THREE.Mesh
200
+
191
201
  build() {
192
202
  super.build()
193
203
 
@@ -225,16 +235,36 @@ class MCSRealObject3D extends RealObjectGroup {
225
235
  material.polygonOffsetFactor = -1
226
236
  material.polygonOffsetUnit = -1
227
237
 
228
- const mesh = new THREE.Mesh(geometry, material)
238
+ // BoxGeometry material groups: +x(0), -x(1), +y(2), -y(3), +z(4), -z(5)
239
+ let meshMaterial: THREE.Material | THREE.Material[] = material
240
+ if (shape === 'box') {
241
+ if (this._config.openTop) {
242
+ // 상단면 제거 → 5면 개방 박스
243
+ const invisible = new THREE.MeshBasicMaterial({ visible: false })
244
+ meshMaterial = [material, material, invisible, material, material, material]
245
+ } else if (this._config.texturedTop) {
246
+ // 상판에 2D 렌더링 텍스처
247
+ const topMat = this.buildTopTexture(w, h)
248
+ meshMaterial = [material, material, topMat, material, material, material]
249
+ } else if (this._config.matteTop) {
250
+ // 상판만 무반사
251
+ const matte = new THREE.MeshBasicMaterial({ color, opacity, transparent: opacity < 1 })
252
+ meshMaterial = [material, material, matte, material, material, material]
253
+ }
254
+ }
255
+
256
+ this._ownMesh = new THREE.Mesh(geometry, meshMaterial)
229
257
  // 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)
258
+ this._ownMesh.position.y = ratio < 1 ? rawDepth / 2 : depth / 2
259
+ this._ownMesh.castShadow = true
260
+ this._ownMesh.receiveShadow = true
261
+ this.object3d.add(this._ownMesh)
234
262
  }
235
263
 
236
264
  // ── Change handling ──
237
265
 
266
+ private _updatePending = false
267
+
238
268
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
239
269
  if (
240
270
  'width' in after ||
@@ -242,18 +272,117 @@ class MCSRealObject3D extends RealObjectGroup {
242
272
  'depth' in after ||
243
273
  'fillStyle' in after ||
244
274
  'strokeStyle' in after ||
245
- 'id' in after // Carrier: CARRIERNAME 변경 시 가시성 재판정
275
+ 'id' in after ||
276
+ 'EMPTYTYPE' in after ||
277
+ 'CARRIERSTATUS' in after
246
278
  ) {
247
- this.update()
279
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
280
+ if (!this._updatePending) {
281
+ this._updatePending = true
282
+ queueMicrotask(() => {
283
+ this._updatePending = false
284
+ this.update()
285
+ })
286
+ }
248
287
  return
249
288
  }
250
289
 
251
290
  super.onchange(after, before)
252
291
  }
253
292
 
254
- // RealObjectGroup 기본 동작 비활성화 (update()에서 일괄 처리)
293
+ /**
294
+ * 상판 텍스처: 컴포넌트의 2D render()를 캔버스에 그려서 텍스처로 사용
295
+ */
296
+ protected buildTopTexture(w: number, h: number): THREE.MeshBasicMaterial {
297
+ const scale = 4
298
+ const cw = this.nextPow2(w * scale)
299
+ const ch = this.nextPow2(h * scale)
300
+ const canvas = document.createElement('canvas')
301
+ canvas.width = cw
302
+ canvas.height = ch
303
+ const ctx = canvas.getContext('2d')!
304
+
305
+ const statusColor = '#' + this.resolveColor().toString(16).padStart(6, '0')
306
+ const emptyType = (this.component.state as any)?.EMPTYTYPE || ''
307
+ const text = emptyType === 'FULL' ? 'F' : emptyType === 'EMPTY' ? 'E' : emptyType === 'EMPTYEMPTY' ? 'X' : ''
308
+
309
+ // 실제 사용 영역 (nextPow2 이전 크기)
310
+ const usedW = w * scale
311
+ const usedH = h * scale
312
+
313
+ // 바탕: 캐리어 상태 색상 (사용 영역만)
314
+ ctx.fillStyle = statusColor
315
+ ctx.fillRect(0, 0, usedW, usedH)
316
+
317
+ // 텍스트 (사용 영역 중앙)
318
+ if (text) {
319
+ const fontSize = Math.round(Math.min(usedW, usedH) * 0.7)
320
+ ctx.font = `bold ${fontSize}px Arial`
321
+ ctx.textAlign = 'center'
322
+ ctx.textBaseline = 'middle'
323
+ ctx.fillStyle = '#000000'
324
+ ctx.fillText(text, usedW / 2, usedH / 2)
325
+ }
326
+
327
+ const texture = new THREE.CanvasTexture(canvas)
328
+ texture.offset.set(0, 1 - usedH / ch)
329
+ texture.repeat.set(usedW / cw, usedH / ch)
330
+ texture.colorSpace = THREE.SRGBColorSpace
331
+
332
+ return new THREE.MeshBasicMaterial({
333
+ map: texture,
334
+ transparent: true,
335
+ side: THREE.DoubleSide,
336
+ })
337
+ }
338
+
339
+ private nextPow2(n: number): number {
340
+ if (!(n > 0)) return 1
341
+ let v = Math.ceil(n)
342
+ v--
343
+ v |= v >> 1; v |= v >> 2; v |= v >> 4; v |= v >> 8; v |= v >> 16
344
+ return v + 1
345
+ }
346
+
347
+ /**
348
+ * 자신의 mesh만 제거 후 rebuild.
349
+ * 기본 update()는 clear()로 object3d의 모든 children을 제거하는데,
350
+ * 컨테이너(Port 등)의 경우 자식 컴포넌트(Carrier 등)의 3D 오브젝트도 함께 제거됨.
351
+ * 이를 방지하기 위해 자신이 생성한 mesh만 제거한다.
352
+ */
353
+ update() {
354
+ // 자신의 mesh만 제거 (자식 컴포넌트 3D 오브젝트 보존)
355
+ if (this._ownMesh) {
356
+ this.object3d.remove(this._ownMesh)
357
+ disposeObject3D(this._ownMesh)
358
+ this._ownMesh = undefined
359
+ }
360
+
361
+ this.build()
362
+ this.updateTransform()
363
+ this.updateAlpha()
364
+ this.updateHidden()
365
+ }
366
+
367
+ // RealObjectGroup 기본 동작 비활성화
255
368
  updateDimension() {}
256
- updateAlpha() {}
369
+
370
+ updateAlpha() {
371
+ const { alpha = 1 } = this.component.state
372
+ const configOpacity = this._config.opacity
373
+ const effective = configOpacity * alpha
374
+
375
+ this.object3d.traverse((o: any) => {
376
+ if (!o.material) return
377
+ const materials = Array.isArray(o.material) ? o.material : [o.material]
378
+ for (const m of materials) {
379
+ if (!m.visible) continue // openTop invisible face 무시
380
+ if (m.map) continue // 텍스처 material은 자체 transparent 유지
381
+ m.opacity = effective
382
+ m.transparent = effective < 1
383
+ }
384
+ })
385
+ }
257
386
  }
258
387
 
259
388
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
@@ -269,7 +398,7 @@ function register(type: string, overrides: Partial<MCS3DConfig> = {}) {
269
398
 
270
399
  register('Equipment', {
271
400
  defaultDepth: 3 * TRANSPORT_SURFACE_HEIGHT,
272
- opacity: 0.5,
401
+ opacity: 0.75,
273
402
  metalness: 0.4,
274
403
  roughness: 0.6,
275
404
  defaultColor: 0x8899aa,
@@ -277,7 +406,7 @@ register('Equipment', {
277
406
 
278
407
  register('Buffer', {
279
408
  defaultDepth: TRANSPORT_SURFACE_HEIGHT,
280
- opacity: 0.5,
409
+ opacity: 1.0,
281
410
  metalness: 0.3,
282
411
  roughness: 0.7,
283
412
  defaultColor: 0x99aa88,
@@ -298,26 +427,201 @@ register('OHTLine', {
298
427
  defaultColor: 0x888899,
299
428
  })
300
429
 
301
- register('STOCKER', {
430
+ // STOCKER: 벽면에 용량 게이지 바를 포함하는 커스텀 3D 오브젝트
431
+ const STOCKER_CONFIG: MCS3DConfig = {
432
+ ...DEFAULTS,
302
433
  defaultDepth: 5 * TRANSPORT_SURFACE_HEIGHT,
303
- opacity: 0.35,
434
+ opacity: 0.75,
304
435
  metalness: 0.3,
305
436
  roughness: 0.6,
306
437
  defaultColor: 0x8899bb,
307
- })
438
+ }
439
+
440
+ class StockerRealObject3D extends MCSRealObject3D {
441
+ private gaugeMesh?: THREE.Mesh
442
+
443
+ constructor(component: Component) {
444
+ super(component, STOCKER_CONFIG)
445
+ }
446
+
447
+ build() {
448
+ super.build()
449
+ this.buildGauge()
450
+ }
451
+
452
+ private buildGauge() {
453
+ const comp = this.component as any
454
+ const data = comp.data || {}
455
+ const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
456
+
457
+ const gauge = this.resolveGauge(data, gaugeMode)
458
+
459
+ // 테두리는 스토커의 상태 색상 (machine status)
460
+ const statusColor = comp.statusColor
461
+ if (statusColor && statusColor.toUpperCase() !== '#F0F0F0') {
462
+ gauge.borderColor = statusColor
463
+ }
464
+
465
+ const { w, h } = this.absSize
466
+ const depth = this.effectiveDepth
467
+ const isWidthLonger = w >= h
468
+
469
+ // 게이지 캔버스 (고해상도)
470
+ const barW = 130
471
+ const barH = 26
472
+ const scale = 4
473
+ const canvasW = barW * scale
474
+ const canvasH = barH * scale
475
+ const canvas = document.createElement('canvas')
476
+ canvas.width = canvasW
477
+ canvas.height = canvasH
478
+ const ctx = canvas.getContext('2d')!
479
+
480
+ const pad = 12
481
+
482
+ // 배경 + 테두리
483
+ ctx.fillStyle = '#dddddd'
484
+ ctx.fillRect(0, 0, canvasW, canvasH)
485
+ ctx.strokeStyle = gauge.borderColor
486
+ ctx.lineWidth = 12
487
+ ctx.strokeRect(6, 6, canvasW - 12, canvasH - 12)
488
+
489
+ // 세그먼트 채움 (왼→오)
490
+ const innerW = canvasW - pad * 2
491
+ const innerH = canvasH - pad * 2
492
+ let x = pad
493
+ for (const seg of gauge.segments) {
494
+ const segW = Math.max(innerW * seg.ratio, seg.ratio > 0 ? 1 : 0)
495
+ if (segW <= 0) continue
496
+ ctx.fillStyle = seg.color
497
+ ctx.fillRect(x, pad, segW, innerH)
498
+ x += segW
499
+ }
500
+
501
+ // 텍스트
502
+ ctx.font = `bold ${10 * scale}px sans-serif`
503
+ ctx.textAlign = 'center'
504
+ ctx.textBaseline = 'middle'
505
+ ctx.fillStyle = '#333333'
506
+ ctx.fillText(gauge.label, canvasW / 2, canvasH / 2)
507
+
508
+ // 텍스처 → Plane
509
+ const texture = new THREE.CanvasTexture(canvas)
510
+ texture.colorSpace = THREE.SRGBColorSpace
511
+
512
+ const geo = new THREE.PlaneGeometry(barW, barH)
513
+ geo.rotateX(-Math.PI / 2) // 수평 (지붕 위)
514
+ if (!isWidthLonger) geo.rotateY(Math.PI / 2)
515
+
516
+ const mat = new THREE.MeshBasicMaterial({
517
+ map: texture,
518
+ transparent: true,
519
+ side: THREE.DoubleSide,
520
+ })
521
+ mat.polygonOffset = true
522
+ mat.polygonOffsetFactor = -2
523
+ mat.polygonOffsetUnit = -2
524
+
525
+ this.gaugeMesh = new THREE.Mesh(geo, mat)
526
+ this.gaugeMesh.position.set(0, depth + 0.5, 0)
527
+ this.object3d.add(this.gaugeMesh)
528
+ }
529
+
530
+ private resolveGauge(data: any, mode: string) {
531
+ switch (mode) {
532
+ case 'carrier': {
533
+ const full = Number(data.FULL_CNT) || 0
534
+ const empty = Number(data.EMPTY_CNT) || 0
535
+ const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0
536
+ const total = full + empty + emptyEmpty
537
+ const segments = total > 0
538
+ ? [
539
+ { ratio: full / total, color: '#4AA7FE' },
540
+ { ratio: empty / total, color: '#8BDA5B' },
541
+ { ratio: emptyEmpty / total, color: '#019D59' },
542
+ ]
543
+ : [{ ratio: 1, color: '#dddddd' }]
544
+ return {
545
+ segments,
546
+ borderColor: '#4AA7FE',
547
+ label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '',
548
+ }
549
+ }
550
+ case 'abnormal': {
551
+ const normal = Number(data.CARRIER_NORMAL) || 0
552
+ const abnormal = Number(data.CARRIER_ABNORMAL) || 0
553
+ const total = normal + abnormal
554
+ const segments = total > 0
555
+ ? [
556
+ { ratio: normal / total, color: '#8BDA5B' },
557
+ { ratio: abnormal / total, color: '#F4B4B4' },
558
+ ]
559
+ : [{ ratio: 1, color: '#dddddd' }]
560
+ return {
561
+ segments,
562
+ borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B',
563
+ label: total > 0 ? `N${normal} A${abnormal}` : '',
564
+ }
565
+ }
566
+ default: { // capacity
567
+ const cur = Number(data.CURRENTCAPACITY) || 0
568
+ const max = Number(data.MAXCAPACITY) || 100
569
+ const fullup = data.FULLUP === 'T'
570
+ const usage = Math.round((cur / max) * 100)
571
+ const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
572
+ const color = fullup ? 'red' : (getVaueOnRanges(usage, legend) || '#44aa44')
573
+ return {
574
+ segments: [{ ratio: usage / 100, color }],
575
+ borderColor: color,
576
+ label: `${usage}%`,
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
583
+ if ('data' in after) {
584
+ this.clearGauge()
585
+ this.buildGauge()
586
+ }
587
+ super.onchange(after, before)
588
+ }
589
+
590
+ clear() {
591
+ this.clearGauge()
592
+ return super.clear()
593
+ }
594
+
595
+ private clearGauge() {
596
+ if (this.gaugeMesh) {
597
+ this.object3d.remove(this.gaugeMesh)
598
+ this.gaugeMesh.geometry?.dispose()
599
+ const mat = this.gaugeMesh.material as THREE.MeshBasicMaterial
600
+ mat?.map?.dispose()
601
+ mat?.dispose()
602
+ this.gaugeMesh = undefined
603
+ }
604
+ }
605
+ }
606
+
607
+ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealObject3D(component))
308
608
 
309
609
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
310
610
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
311
611
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
312
612
 
313
- register('Port', {
613
+ // Port: 상부 개방 5면 박스
614
+ const PORT_CONFIG: MCS3DConfig = {
615
+ ...DEFAULTS,
314
616
  depthRatio: 0.5,
315
617
  defaultZPosFromParent: true,
316
618
  opacity: 0.5,
317
619
  metalness: 0.4,
318
620
  roughness: 0.6,
319
621
  defaultColor: 0xb0a090,
320
- })
622
+ openTop: true,
623
+ }
624
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
321
625
 
322
626
  register('Shelf', {
323
627
  depthRatio: 0.25,
@@ -326,36 +630,138 @@ register('Shelf', {
326
630
  defaultColor: 0xaabbcc,
327
631
  })
328
632
 
329
- register('Carrier', {
633
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
634
+ const CARRIER_CONFIG: MCS3DConfig = {
635
+ ...DEFAULTS,
330
636
  depthRatio: 0.5,
331
637
  metalness: 0.2,
332
638
  roughness: 0.8,
333
639
  defaultColor: 0x66bb66,
334
640
  isVisible: (c: Component) => !!(c.state as any).id,
335
641
  sizeRatio: 0.8,
336
- })
642
+ texturedTop: true,
643
+ }
644
+
645
+ class CarrierRealObject3D extends MCSRealObject3D {
646
+ private _animRaf: number | null = null
647
+ private _animStartTime = 0
648
+
649
+ constructor(component: Component) {
650
+ super(component, CARRIER_CONFIG)
651
+ }
652
+
653
+ build() {
654
+ super.build()
655
+ this.startCarrierAnimation()
656
+ }
657
+
658
+ /**
659
+ * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
+ * things-scene의 delta 시스템과 독립적으로 동작.
661
+ */
662
+ private startCarrierAnimation() {
663
+ this.stopCarrierAnimation()
664
+
665
+ const comp = this.component as any
666
+ if (!comp.app?.isViewMode) return
667
+
668
+ const carrierStatus = comp.state?.CARRIERSTATUS
669
+ if (!carrierStatus) return
670
+
671
+ // 애니메이션 설정 가져오기
672
+ const config = comp.getAnimationConfig?.(carrierStatus)
673
+ if (!config?.animation) return
674
+
675
+ const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
+ if (!type) return
677
+
678
+ this._animStartTime = performance.now()
679
+
680
+ // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
+ // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
+ const obj = this.object3d
683
+
684
+ const animate = () => {
685
+ if (!this._ownMesh) return
686
+
687
+ const elapsed = performance.now() - this._animStartTime
688
+ const progress = (elapsed % duration) / duration // 0~1 반복
689
+
690
+ switch (type) {
691
+ case 'heartbeat': {
692
+ const t = progress < 0.5 ? progress * 2 : (1 - progress) * 2
693
+ const s = 1 + (animScale - 1) * t
694
+ obj.scale.set(s, 1, s)
695
+ break
696
+ }
697
+ case 'rotation': {
698
+ obj.rotation.y = progress * Math.PI * 2
699
+ break
700
+ }
701
+ case 'vibration': {
702
+ const theta = config.animation.theta ?? 0.26
703
+ const current = Math.sin(progress * Math.PI * 2) * theta
704
+ obj.rotation.y = current
705
+ break
706
+ }
707
+ case 'fade': {
708
+ const alpha = 0.3 + 0.7 * (progress < 0.5 ? progress * 2 : (1 - progress) * 2)
709
+ obj.traverse((o: any) => {
710
+ if (o.material && !o.material.map) {
711
+ o.material.opacity = alpha
712
+ o.material.transparent = true
713
+ }
714
+ })
715
+ break
716
+ }
717
+ }
718
+
719
+ this._animRaf = requestAnimationFrame(animate)
720
+ }
721
+
722
+ this._animRaf = requestAnimationFrame(animate)
723
+ }
724
+
725
+ // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
+ syncAnimationDelta() {
727
+ if (!this._animRaf) {
728
+ super.syncAnimationDelta()
729
+ }
730
+ }
731
+
732
+ private stopCarrierAnimation() {
733
+ if (this._animRaf) {
734
+ cancelAnimationFrame(this._animRaf)
735
+ this._animRaf = null
736
+ }
737
+ // group 스케일/회전 초기화
738
+ this.object3d.scale.set(1, 1, 1)
739
+ this.object3d.rotation.y = 0
740
+ }
741
+
742
+ update() {
743
+ this.stopCarrierAnimation()
744
+ super.update()
745
+ }
746
+
747
+ clear() {
748
+ this.stopCarrierAnimation()
749
+ return super.clear()
750
+ }
751
+ }
752
+
753
+ registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
337
754
 
338
755
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
339
756
  // Gauge (용량 게이지) — 얇은 바
340
757
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
341
758
 
342
- register('ZoneCapacityBar', {
343
- depthRatio: 0.15,
344
- metalness: 0.2,
345
- roughness: 0.8,
346
- defaultColor: 0x44aa44,
347
- })
348
-
349
- register('StockerCapacityBar', {
350
- depthRatio: 0.15,
351
- metalness: 0.2,
352
- roughness: 0.8,
353
- defaultColor: 0x44aa44,
354
- })
759
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
760
+ // Gauge (용량 게이지) — 2D 렌더링을 Plane 텍스처로 3D 표시
761
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
355
762
 
356
- register('MCSGaugeCapacityBar', {
357
- depthRatio: 0.15,
358
- metalness: 0.2,
359
- roughness: 0.8,
360
- defaultColor: 0x44aa44,
361
- })
763
+ // Gauge 컴포넌트 (StockerCapacityBar, ZoneCapacityBar, MCSGaugeCapacityBar)는
764
+ // 3D에서 표시하지 않음. 대신 부모(STOCKER 등)의 3D 모델에서 직접 게이지를 렌더링.
765
+ registerRealObjectFactory('StockerCapacityBar', () => null as any)
766
+ registerRealObjectFactory('MCSGaugeCapacityBar', () => null as any)
767
+ registerRealObjectFactory('ZoneCapacityBar', () => null as any)
@@ -12,7 +12,7 @@
12
12
  import * as THREE from 'three'
13
13
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
14
14
  import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
15
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
15
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
16
16
  import type { Component } from '@hatiolab/things-scene'
17
17
  import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
18
18
 
@@ -24,6 +24,7 @@ const DEFAULT_BODY_COLOR = 0x6688cc
24
24
  const OHT_RAIL_HEIGHT = 6 * TRANSPORT_SURFACE_HEIGHT
25
25
 
26
26
  export class OHT3D extends RealObjectGroup {
27
+ private _ownMeshes: THREE.Mesh[] = []
27
28
  get effectiveDepth(): number {
28
29
  const { depth } = this.component.state
29
30
  return depth || TRANSPORT_SURFACE_HEIGHT
@@ -89,7 +90,6 @@ export class OHT3D extends RealObjectGroup {
89
90
  const gripperMesh = new THREE.Mesh(gripperGeo, frameMat)
90
91
  gripperMesh.position.y = gripperBarH / 2
91
92
  gripperMesh.castShadow = true
92
- this.object3d.add(gripperMesh)
93
93
 
94
94
  // ── 2. 호이스트 암 (좌우 수직 기둥 + 하단 가로연결) ──
95
95
  const armBaseY = gripperBarH
@@ -111,7 +111,6 @@ export class OHT3D extends RealObjectGroup {
111
111
 
112
112
  const armMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(armGeometries), frameMat)
113
113
  armMesh.castShadow = true
114
- this.object3d.add(armMesh)
115
114
 
116
115
  // ── 3. 본체 하우징 (statusColor 적용) ──
117
116
  const bodyBaseY = armBaseY + armH
@@ -126,7 +125,6 @@ export class OHT3D extends RealObjectGroup {
126
125
  bodyMesh.position.y = bodyBaseY + bodyH / 2
127
126
  bodyMesh.castShadow = true
128
127
  bodyMesh.receiveShadow = true
129
- this.object3d.add(bodyMesh)
130
128
 
131
129
  // ── 4. 상단 트롤리 플레이트 (납작하고 넓은 레일 클램프) ──
132
130
  const trolleyBaseY = bodyBaseY + bodyH + gap
@@ -141,27 +139,53 @@ export class OHT3D extends RealObjectGroup {
141
139
  const trolleyMesh = new THREE.Mesh(trolleyGeo, trolleyMat)
142
140
  trolleyMesh.position.y = trolleyBaseY + trolleyH / 2
143
141
  trolleyMesh.castShadow = true
144
- this.object3d.add(trolleyMesh)
145
142
 
146
143
  // ── 5. 좌우 바퀴 (트롤리 위, 레일을 타는 원통형 바퀴) ──
147
144
  const wheelBaseY = trolleyBaseY + trolleyH
148
145
  const wheelRadius = wheelH / 2
149
146
  const wheelDepth = h * 0.2
147
+ const wheelMeshes: THREE.Mesh[] = []
150
148
  for (const xSign of [-1, 1]) {
151
149
  const wheelGeo = new THREE.CylinderGeometry(wheelRadius, wheelRadius, wheelDepth, 12)
152
150
  wheelGeo.rotateX(Math.PI / 2)
153
151
  const wheelMesh = new THREE.Mesh(wheelGeo, trolleyMat)
154
152
  wheelMesh.position.set(xSign * (trolleyW * 0.35), wheelBaseY + wheelRadius, 0)
155
153
  wheelMesh.castShadow = true
156
- this.object3d.add(wheelMesh)
154
+ wheelMeshes.push(wheelMesh)
157
155
  }
156
+
157
+ this._ownMeshes = [gripperMesh, armMesh, bodyMesh, trolleyMesh, ...wheelMeshes]
158
+ for (const m of this._ownMeshes) this.object3d.add(m)
158
159
  }
159
160
 
160
161
  updateDimension() {}
161
- updateAlpha() {}
162
+
163
+ update() {
164
+ for (const m of this._ownMeshes) {
165
+ this.object3d.remove(m)
166
+ disposeObject3D(m)
167
+ }
168
+ this._ownMeshes = []
169
+ this.build()
170
+ this.updateTransform()
171
+ this.updateAlpha()
172
+ this.updateHidden()
173
+ }
174
+
175
+ updateAlpha() {
176
+ const { alpha = 1 } = this.component.state
177
+ for (const m of this._ownMeshes) {
178
+ const materials = Array.isArray(m.material) ? m.material : [m.material]
179
+ for (const mat of materials) {
180
+ ;(mat as THREE.MeshStandardMaterial).opacity = alpha
181
+ ;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1
182
+ }
183
+ }
184
+ }
162
185
 
163
186
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
164
- if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
187
+ if ('width' in after || 'height' in after || 'depth' in after ||
188
+ 'fillStyle' in after || 'strokeStyle' in after || 'id' in after) {
165
189
  this.update()
166
190
  return
167
191
  }