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

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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@fmsim/machine",
3
3
  "description": "Layout View를 구성하는 설비(machine)들과 유닛을 구현한 모듈입니다.",
4
4
  "author": "heartyoh",
5
- "version": "2.0.0-beta.5",
5
+ "version": "2.0.0-beta.7",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
8
8
  "things-scene": true,
@@ -25,7 +25,7 @@
25
25
  "migration": "things-factory-migration"
26
26
  },
27
27
  "dependencies": {
28
- "@fmsim/api": "^2.0.0-beta.3",
28
+ "@fmsim/api": "^2.0.0-beta.7",
29
29
  "@hatiolab/things-scene": "^10.0.0-beta.1"
30
30
  },
31
31
  "devDependencies": {
@@ -54,5 +54,5 @@
54
54
  "prettier --write"
55
55
  ]
56
56
  },
57
- "gitHead": "522cb3abab43f7a3c8850cab80d1b9187d6447d6"
57
+ "gitHead": "ccd94cd6a7157852b9a1d4f59b2f0d46c9b6aac7"
58
58
  }
package/src/carrier.ts CHANGED
@@ -134,11 +134,11 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
134
134
  // 문자열 형태의 설정들을 미리 파싱
135
135
  const parsedTheme = {}
136
136
  for (const [status, config] of Object.entries(theme)) {
137
- if (typeof config === 'string') {
137
+ if (typeof config === 'string' && config.trim()) {
138
138
  try {
139
139
  parsedTheme[status] = JSON5.parse(config)
140
140
  } catch (e) {
141
- console.error('JSON5 parse error:', e)
141
+ console.error('JSON5 parse error:', e, 'input:', JSON.stringify(config), 'status:', status, 'theme:', carrierAnimationName)
142
142
  parsedTheme[status] = config
143
143
  }
144
144
  } else {
@@ -15,7 +15,7 @@
15
15
 
16
16
  import * as THREE from 'three'
17
17
  import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
18
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
18
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
19
19
  import type { Component } from '@hatiolab/things-scene'
20
20
  import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
21
21
 
@@ -24,6 +24,7 @@ const PLATFORM_COLOR = 0x333333
24
24
  const DEFAULT_BODY_COLOR = 0x6688cc
25
25
 
26
26
  export class AGV3D 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
@@ -88,7 +89,6 @@ export class AGV3D extends RealObjectGroup {
88
89
  bumper.position.y = bumperH / 2
89
90
  bumper.castShadow = true
90
91
  bumper.receiveShadow = true
91
- this.object3d.add(bumper)
92
92
 
93
93
  // ── 본체 (둥근 모서리, statusColor 적용) ──
94
94
  const bodyColor = this.resolveColor()
@@ -102,7 +102,6 @@ export class AGV3D extends RealObjectGroup {
102
102
  body.position.y = bumperH + bodyH / 2
103
103
  body.castShadow = true
104
104
  body.receiveShadow = true
105
- this.object3d.add(body)
106
105
 
107
106
  // ── 상단 플랫폼 (본체보다 안쪽, 어두운색) ──
108
107
  const platInset = Math.min(w, h) * 0.04
@@ -117,14 +116,41 @@ export class AGV3D extends RealObjectGroup {
117
116
  plat.position.y = bumperH + bodyH + gapH + platH / 2
118
117
  plat.castShadow = true
119
118
  plat.receiveShadow = true
120
- this.object3d.add(plat)
119
+
120
+ this._ownMeshes = [bumper, body, plat]
121
+ for (const m of this._ownMeshes) this.object3d.add(m)
121
122
  }
122
123
 
123
124
  updateDimension() {}
124
- updateAlpha() {}
125
+
126
+ update() {
127
+ // 자신의 mesh만 제거 (Carrier 등 자식 컴포넌트 3D 보존)
128
+ for (const m of this._ownMeshes) {
129
+ this.object3d.remove(m)
130
+ disposeObject3D(m)
131
+ }
132
+ this._ownMeshes = []
133
+
134
+ this.build()
135
+ this.updateTransform()
136
+ this.updateAlpha()
137
+ this.updateHidden()
138
+ }
139
+
140
+ updateAlpha() {
141
+ const { alpha = 1 } = this.component.state
142
+ for (const m of this._ownMeshes) {
143
+ const materials = Array.isArray(m.material) ? m.material : [m.material]
144
+ for (const mat of materials) {
145
+ ;(mat as THREE.MeshStandardMaterial).opacity = alpha
146
+ ;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1
147
+ }
148
+ }
149
+ }
125
150
 
126
151
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
127
- if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
152
+ if ('width' in after || 'height' in after || 'depth' in after ||
153
+ 'fillStyle' in after || 'strokeStyle' in after || 'id' in after) {
128
154
  this.update()
129
155
  return
130
156
  }
@@ -9,7 +9,7 @@
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
14
  import { LEGEND_CAPACITY } from '../features/mcs-status-default'
15
15
  import { getVaueOnRanges } from '../utils/get-value-on-ranges'
@@ -35,6 +35,12 @@ interface MCS3DConfig {
35
35
  isVisible?: (component: Component) => boolean
36
36
  /** 부모 대비 크기 비율 (기본 1.0). < 1이면 부모 중심에 배치. */
37
37
  sizeRatio?: number
38
+ /** 상부 면 제거 (5면 개방 박스) */
39
+ openTop?: boolean
40
+ /** 상판 무반사 (MeshBasicMaterial) */
41
+ matteTop?: boolean
42
+ /** 상판에 2D 렌더링 텍스처 적용 */
43
+ texturedTop?: boolean
38
44
  }
39
45
 
40
46
  /**
@@ -94,7 +100,7 @@ class MCSRealObject3D extends RealObjectGroup {
94
100
  }
95
101
 
96
102
  // 컴포넌트의 상태 색상(statusColor) 또는 fillStyle → Three.js 색상 변환
97
- private resolveColor(): number {
103
+ protected resolveColor(): number {
98
104
  const comp = this.component as any
99
105
 
100
106
  // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
@@ -190,6 +196,8 @@ class MCSRealObject3D extends RealObjectGroup {
190
196
 
191
197
  // ── Build ──
192
198
 
199
+ protected _ownMesh?: THREE.Mesh
200
+
193
201
  build() {
194
202
  super.build()
195
203
 
@@ -227,16 +235,36 @@ class MCSRealObject3D extends RealObjectGroup {
227
235
  material.polygonOffsetFactor = -1
228
236
  material.polygonOffsetUnit = -1
229
237
 
230
- 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)
231
257
  // 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)
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)
236
262
  }
237
263
 
238
264
  // ── Change handling ──
239
265
 
266
+ private _updatePending = false
267
+
240
268
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
241
269
  if (
242
270
  'width' in after ||
@@ -244,18 +272,117 @@ class MCSRealObject3D extends RealObjectGroup {
244
272
  'depth' in after ||
245
273
  'fillStyle' in after ||
246
274
  'strokeStyle' in after ||
247
- 'id' in after // Carrier: CARRIERNAME 변경 시 가시성 재판정
275
+ 'id' in after ||
276
+ 'EMPTYTYPE' in after ||
277
+ 'CARRIERSTATUS' in after
248
278
  ) {
249
- this.update()
279
+ // 연속 setState에 의한 다중 rebuild 방지: 한 프레임에 한 번만 실행
280
+ if (!this._updatePending) {
281
+ this._updatePending = true
282
+ queueMicrotask(() => {
283
+ this._updatePending = false
284
+ this.update()
285
+ })
286
+ }
250
287
  return
251
288
  }
252
289
 
253
290
  super.onchange(after, before)
254
291
  }
255
292
 
256
- // 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 기본 동작 비활성화
257
368
  updateDimension() {}
258
- 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
+ }
259
386
  }
260
387
 
261
388
  // ── 팩토리 등록 헬퍼 ─────────────────────────────────────
@@ -483,14 +610,18 @@ registerRealObjectFactory('STOCKER', (component: Component) => new StockerRealOb
483
610
  // Unit (이동체 · 홀더) — 낮은 depth, 불투명
484
611
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
485
612
 
486
- register('Port', {
613
+ // Port: 상부 개방 5면 박스
614
+ const PORT_CONFIG: MCS3DConfig = {
615
+ ...DEFAULTS,
487
616
  depthRatio: 0.5,
488
617
  defaultZPosFromParent: true,
489
618
  opacity: 0.5,
490
619
  metalness: 0.4,
491
620
  roughness: 0.6,
492
621
  defaultColor: 0xb0a090,
493
- })
622
+ openTop: true,
623
+ }
624
+ registerRealObjectFactory('Port', (component: Component) => new MCSRealObject3D(component, PORT_CONFIG))
494
625
 
495
626
  register('Shelf', {
496
627
  depthRatio: 0.25,
@@ -499,14 +630,127 @@ register('Shelf', {
499
630
  defaultColor: 0xaabbcc,
500
631
  })
501
632
 
502
- register('Carrier', {
633
+ // Carrier: CARRIERSTATUS에 따른 3D 애니메이션 지원
634
+ const CARRIER_CONFIG: MCS3DConfig = {
635
+ ...DEFAULTS,
503
636
  depthRatio: 0.5,
504
637
  metalness: 0.2,
505
638
  roughness: 0.8,
506
639
  defaultColor: 0x66bb66,
507
640
  isVisible: (c: Component) => !!(c.state as any).id,
508
641
  sizeRatio: 0.8,
509
- })
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))
510
754
 
511
755
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
512
756
  // Gauge (용량 게이지) — 얇은 바
@@ -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
  }
@@ -12,7 +12,7 @@
12
12
 
13
13
  import * as THREE from 'three'
14
14
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.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
 
@@ -21,6 +21,7 @@ const FORK_COLOR = 0x777777
21
21
  const DEFAULT_BODY_COLOR = 0x6688cc
22
22
 
23
23
  export class Shuttle3D extends RealObjectGroup {
24
+ private _ownMeshes: THREE.Mesh[] = []
24
25
  get effectiveDepth(): number {
25
26
  const { depth } = this.component.state
26
27
  return depth || TRANSPORT_SURFACE_HEIGHT
@@ -80,7 +81,6 @@ export class Shuttle3D extends RealObjectGroup {
80
81
  baseMesh.position.y = baseH / 2
81
82
  baseMesh.castShadow = true
82
83
  baseMesh.receiveShadow = true
83
- this.object3d.add(baseMesh)
84
84
 
85
85
  // ── 2. 좌우 마스트 기둥 (statusColor 적용) ──
86
86
  const mastBaseY = baseH
@@ -109,7 +109,6 @@ export class Shuttle3D extends RealObjectGroup {
109
109
 
110
110
  const mastMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(mastGeometries), mastMat)
111
111
  mastMesh.castShadow = true
112
- this.object3d.add(mastMesh)
113
112
 
114
113
  // ── 3. 상단 포크 (2개의 평행 암 + 백 플레이트) ──
115
114
  const forkBaseY = mastBaseY + mastH
@@ -141,14 +140,39 @@ export class Shuttle3D extends RealObjectGroup {
141
140
 
142
141
  const forkMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(forkGeometries), forkMat)
143
142
  forkMesh.castShadow = true
144
- this.object3d.add(forkMesh)
143
+
144
+ this._ownMeshes = [baseMesh, mastMesh, forkMesh]
145
+ for (const m of this._ownMeshes) this.object3d.add(m)
145
146
  }
146
147
 
147
148
  updateDimension() {}
148
- updateAlpha() {}
149
+
150
+ update() {
151
+ for (const m of this._ownMeshes) {
152
+ this.object3d.remove(m)
153
+ disposeObject3D(m)
154
+ }
155
+ this._ownMeshes = []
156
+ this.build()
157
+ this.updateTransform()
158
+ this.updateAlpha()
159
+ this.updateHidden()
160
+ }
161
+
162
+ updateAlpha() {
163
+ const { alpha = 1 } = this.component.state
164
+ for (const m of this._ownMeshes) {
165
+ const materials = Array.isArray(m.material) ? m.material : [m.material]
166
+ for (const mat of materials) {
167
+ ;(mat as THREE.MeshStandardMaterial).opacity = alpha
168
+ ;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1
169
+ }
170
+ }
171
+ }
149
172
 
150
173
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
151
- if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {
174
+ if ('width' in after || 'height' in after || 'depth' in after ||
175
+ 'fillStyle' in after || 'strokeStyle' in after || 'id' in after) {
152
176
  this.update()
153
177
  return
154
178
  }