@fmsim/machine 2.0.0-beta.99 → 2.0.0

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 (73) hide show
  1. package/dist/carrier-manager.js +9 -1
  2. package/dist/carrier-manager.js.map +1 -1
  3. package/dist/carrier.js +69 -52
  4. package/dist/carrier.js.map +1 -1
  5. package/dist/crane.js +15 -6
  6. package/dist/crane.js.map +1 -1
  7. package/dist/factories/agv-3d.js +9 -27
  8. package/dist/factories/agv-3d.js.map +1 -1
  9. package/dist/factories/conveyor-3d.js +11 -19
  10. package/dist/factories/conveyor-3d.js.map +1 -1
  11. package/dist/factories/crane-3d.js +8 -17
  12. package/dist/factories/crane-3d.js.map +1 -1
  13. package/dist/factories/crane-rail-3d.js +8 -12
  14. package/dist/factories/crane-rail-3d.js.map +1 -1
  15. package/dist/factories/machine-3d.js +100 -63
  16. package/dist/factories/machine-3d.js.map +1 -1
  17. package/dist/factories/oht-3d.js +9 -27
  18. package/dist/factories/oht-3d.js.map +1 -1
  19. package/dist/factories/shuttle-3d.js +11 -27
  20. package/dist/factories/shuttle-3d.js.map +1 -1
  21. package/dist/features/capacity-gauge-mixin.js +520 -0
  22. package/dist/features/capacity-gauge-mixin.js.map +1 -0
  23. package/dist/features/mcs-status-default.js +13 -0
  24. package/dist/features/mcs-status-default.js.map +1 -1
  25. package/dist/features/parent-object-mixin.js +5 -1
  26. package/dist/features/parent-object-mixin.js.map +1 -1
  27. package/dist/mcs-machine.js +4 -23
  28. package/dist/mcs-machine.js.map +1 -1
  29. package/dist/mcs-unit.js +2 -5
  30. package/dist/mcs-unit.js.map +1 -1
  31. package/dist/mcs-vehicle.js +3 -1
  32. package/dist/mcs-vehicle.js.map +1 -1
  33. package/dist/scene/root-container-override.js +5 -2
  34. package/dist/scene/root-container-override.js.map +1 -1
  35. package/dist/stocker-capacity-bar.js +11 -372
  36. package/dist/stocker-capacity-bar.js.map +1 -1
  37. package/dist/stocker.js +29 -1
  38. package/dist/stocker.js.map +1 -1
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/dist/utils/crane-position.js +23 -14
  41. package/dist/utils/crane-position.js.map +1 -1
  42. package/dist/utils/stocker-gauge.js +48 -10
  43. package/dist/utils/stocker-gauge.js.map +1 -1
  44. package/dist/zone-capacity-bar.js +13 -213
  45. package/dist/zone-capacity-bar.js.map +1 -1
  46. package/package.json +4 -4
  47. package/src/carrier-manager.ts +9 -1
  48. package/src/carrier.ts +73 -52
  49. package/src/crane.ts +14 -5
  50. package/src/factories/agv-3d.ts +10 -26
  51. package/src/factories/conveyor-3d.ts +12 -19
  52. package/src/factories/crane-3d.ts +9 -17
  53. package/src/factories/crane-rail-3d.ts +9 -13
  54. package/src/factories/machine-3d.ts +125 -66
  55. package/src/factories/oht-3d.ts +10 -26
  56. package/src/factories/shuttle-3d.ts +12 -26
  57. package/src/features/capacity-gauge-mixin.ts +642 -0
  58. package/src/features/mcs-status-default.ts +13 -0
  59. package/src/features/parent-object-mixin.ts +5 -1
  60. package/src/mcs-machine.ts +4 -23
  61. package/src/mcs-unit.ts +2 -5
  62. package/src/mcs-vehicle.ts +3 -1
  63. package/src/scene/root-container-override.ts +5 -2
  64. package/src/stocker-capacity-bar.ts +15 -476
  65. package/src/stocker.ts +29 -1
  66. package/src/utils/crane-position.ts +46 -14
  67. package/src/utils/stocker-gauge.ts +63 -10
  68. package/src/zone-capacity-bar.ts +17 -282
  69. package/translations/en.json +4 -1
  70. package/translations/ja.json +4 -1
  71. package/translations/ko.json +4 -1
  72. package/translations/ms.json +4 -1
  73. package/translations/zh.json +4 -1
package/src/carrier.ts CHANGED
@@ -4,7 +4,7 @@ import { getPopupData } from '@fmsim/api'
4
4
  import { BOUNDS, Component, ComponentNature, Properties, Shape, sceneComponent } from '@hatiolab/things-scene'
5
5
  import { ANIMATION_DEFAULT, AnimationPreset, AnimationConfig } from './features/animation-default.js'
6
6
  import { ParentObjectMixin } from './features/parent-object-mixin.js'
7
- import { LEGEND_CARRIER, Legend } from './features/mcs-status-default.js'
7
+ import { Legend, resolveCarrierLegend } from './features/mcs-status-default.js'
8
8
  import { MCSStatusMixin } from './features/mcs-status-mixin.js'
9
9
 
10
10
  const NATURE: ComponentNature = {
@@ -56,6 +56,8 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
56
56
 
57
57
  set path(path) {}
58
58
 
59
+ /** 마지막으로 적용한 (애니메이션 테마 | CARRIERSTATUS) 조합. 같은 조합 재적용을 막는다. */
60
+ private _appliedAnimationKey?: string
59
61
 
60
62
  added(parent: any) {
61
63
  super.added(parent)
@@ -74,6 +76,11 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
74
76
 
75
77
  this.syncStateFromData()
76
78
 
79
+ /* 부모가 정해진 지금 다시 해석한다 — 생성 시(data 선할당) 판정에는 parent 가 없어
80
+ * 홀더의 carrierAnimationName 이 반영되지 않았다. reparent 로 홀더가 바뀐 경우도 여기서
81
+ * 새 홀더 테마로 갱신된다. (같은 조합이면 내부 가드로 no-op) */
82
+ this._applyStatusAnimation()
83
+
77
84
  // 3D 오브젝트 최초 생성 (동기적 — reparent()에서 _realObject 필요)
78
85
  if ((this.root as any)?.threed && !this._realObject) {
79
86
  ;(this.root as any).model_layer?._threeCapability?.addObject(this)
@@ -102,52 +109,60 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
102
109
  this.setState('CARRIERTYPE', CARRIERTYPE)
103
110
  this.setState('CARRIERSTATUS', CARRIERSTATUS)
104
111
 
105
- // TODO carrierstatus에 따라서 매핑되는 애니메이션 테마 수행
106
- if (after.data?.CARRIERSTATUS !== before.data?.CARRIERSTATUS) {
107
- const { CARRIERSTATUS: lastCarrierStatus } = before.data || {}
108
- const lastAnimationConfig = lastCarrierStatus && this.getAnimationConfig(lastCarrierStatus)
109
-
110
- if (lastAnimationConfig) {
111
- let { animation, decorator, border, arrow } = lastAnimationConfig
112
- if (animation) {
113
- this.resetAnimation()
114
- this.setState('animation', {
115
- oncreate: null
116
- })
117
- }
118
-
119
- if (decorator || border || arrow) {
120
- this.trigger('deco-off')
121
- }
122
- }
123
-
124
- const animationConfig = this.getAnimationConfig(this.getState('CARRIERSTATUS'))
112
+ /* 상태에 매핑된 애니메이션/데코 적용. 조건 판정은 _applyStatusAnimation 내부의
113
+ * (테마|상태) 키 비교로 하므로 여기서 status 변경 여부를 따지지 않는다 —
114
+ * 상태는 그대로여도 홀더가 바뀌어(=테마가 바뀌어) 재적용이 필요할 수 있다. */
115
+ this._applyStatusAnimation()
116
+ }
125
117
 
126
- if (animationConfig) {
127
- let { animation, decorator, border, arrow } = animationConfig
128
- if (animation) {
129
- this.resetAnimation()
130
- this.setState('animation', {
131
- oncreate: animation
132
- })
133
- this.started = true
134
- }
118
+ /**
119
+ * 현재 (해석된 애니메이션 테마 | CARRIERSTATUS) 조합에 맞는 애니메이션/데코를 적용한다.
120
+ *
121
+ * 같은 조합이면 아무것도 하지 않는다 — data 는 매 update 마다 재할당되므로, 가드가 없으면
122
+ * 매번 애니메이션이 재시작되어 끊긴다.
123
+ *
124
+ * 호출 시점이 둘인 이유:
125
+ * 1) onchangeData — 상태(또는 테마) 변화 반영
126
+ * 2) added() — **부모가 생긴 직후**. CarrierManager 는 `carrier.data = item` 을
127
+ * addComponent 보다 먼저 하므로, 생성 시 첫 판정에는 parent 가 없어 홀더의
128
+ * carrierAnimationName 이 무시된다(루트 폴백만 보임). 부모가 붙은 뒤 한 번 더
129
+ * 해석해야 홀더 테마가 반영된다.
130
+ */
131
+ private _applyStatusAnimation(): void {
132
+ const status = this.getState('CARRIERSTATUS')
133
+ const key = `${this.resolvedCarrierAnimationName}|${status ?? ''}`
134
+ if (key === this._appliedAnimationKey) return
135
+ this._appliedAnimationKey = key
136
+
137
+ /* 이전 적용분 해제 — 이전 테마/상태를 되짚지 않고 끈다(끄는 동작은 멱등).
138
+ * 테마가 바뀐 경우 이전 config 로는 무엇이 켜져 있었는지 정확히 알 수 없다. */
139
+ this.resetAnimation()
140
+ this.setState('animation', { oncreate: null })
141
+ this.trigger('deco-off')
142
+
143
+ const animationConfig = this.getAnimationConfig(status)
144
+ if (!animationConfig) return
145
+
146
+ const { animation, decorator, border, arrow } = animationConfig
147
+
148
+ if (animation) {
149
+ this.setState('animation', { oncreate: animation })
150
+ this.started = true
151
+ }
135
152
 
136
- if (decorator) {
137
- this.trigger('deco-icon-off')
138
- this.trigger('deco-icon', decorator)
139
- }
153
+ if (decorator) {
154
+ this.trigger('deco-icon-off')
155
+ this.trigger('deco-icon', decorator)
156
+ }
140
157
 
141
- if (border) {
142
- this.trigger('deco-border-off')
143
- this.trigger('deco-border', border)
144
- }
158
+ if (border) {
159
+ this.trigger('deco-border-off')
160
+ this.trigger('deco-border', border)
161
+ }
145
162
 
146
- if (arrow) {
147
- this.trigger('deco-arrow-off')
148
- this.trigger('deco-arrow', arrow)
149
- }
150
- }
163
+ if (arrow) {
164
+ this.trigger('deco-arrow-off')
165
+ this.trigger('deco-arrow', arrow)
151
166
  }
152
167
  }
153
168
 
@@ -156,13 +171,8 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
156
171
  }
157
172
 
158
173
  get legend(): Legend {
159
- const { carrierLegendName } = this.parent?.state || {}
160
-
161
- if (carrierLegendName) {
162
- return (this.root as any)?.style[carrierLegendName]
163
- }
164
-
165
- return (this.root as any)?.carrierLegendTheme || LEGEND_CARRIER
174
+ // carrier·스토커 게이지(2D/3D) 공유 단일 소스.
175
+ return resolveCarrierLegend(this)
166
176
  }
167
177
 
168
178
  getAnimationConfig(carrierStatus): AnimationConfig | null {
@@ -170,9 +180,20 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
170
180
  return (config as AnimationConfig) || null
171
181
  }
172
182
 
183
+ /**
184
+ * 적용할 애니메이션 테마 이름 — 소유 홀더(부모) 설정이 우선, 없으면 보드(rootModel) 폴백.
185
+ *
186
+ * 부모가 아직 없는 시점(생성 직후 data 선할당)에는 홀더 설정을 알 수 없어 루트 폴백만
187
+ * 보인다. 그래서 added() 에서 한 번 더 해석한다 — _applyStatusAnimation 참조.
188
+ */
189
+ get resolvedCarrierAnimationName(): string {
190
+ return (
191
+ this.parent?.state.carrierAnimationName || (this.root as any)?.rootModel?.state?.carrierAnimationName || ''
192
+ )
193
+ }
194
+
173
195
  get animationPreset(): AnimationPreset {
174
- const carrierAnimationName = this.parent?.state.carrierAnimationName ||
175
- this.root?.rootModel.state.carrierAnimationName || ''
196
+ const carrierAnimationName = this.resolvedCarrierAnimationName
176
197
 
177
198
  if (carrierAnimationName) {
178
199
  // 캐시에서 먼저 확인
package/src/crane.ts CHANGED
@@ -165,10 +165,12 @@ export default class Crane extends MCSVehicle {
165
165
  * cranePosition (current + limit) → state.left/top + forkZ.
166
166
  *
167
167
  * 부모 오브젝트 (보통은 Stocker 이지만 다른 컨테이너일 수도) 의 width/height
168
- * 만 사용해 좌표 매핑 — 타입 무관 일반화. 가상 rail 은 host 의 긴쪽:
169
- * - current.x → host 의 긴쪽 (자동 판별: max(width, height))
170
- * - current.y → 짧은쪽
171
- * - current.z → forkZ (state 에 0~100 으로 저장, 3D 측에서 mast 비율로 매핑)
168
+ * 만 사용해 좌표 매핑 — 타입 무관 일반화. 축 배정과 원점은 host 의 내부 좌표계 설정을
169
+ * 따른다 (coordAxisX / coordInvertX / coordInvertY, 미설정 시 아래 폴백):
170
+ * - current.x → X축 (기본: host 의 긴쪽, 자동 판별 max(width, height))
171
+ * - current.y → Y축 (기본: 짧은쪽)
172
+ * - current.z → forkZ (state 에 0~100 으로 저장, 3D 측에서 mast 비율로 매핑).
173
+ * Z 는 바닥 기준이 물리적으로 고정이라 반전 설정을 두지 않는다.
172
174
  *
173
175
  * 각 축 비율 = (current - limit.min) / (limit.max - limit.min) ∈ [0,1].
174
176
  *
@@ -196,7 +198,14 @@ export default class Crane extends MCSVehicle {
196
198
  const ry = axisRatio(cp.current?.y, cp.limit?.y)
197
199
  const rz = axisRatio(cp.current?.z, cp.limit?.z)
198
200
 
199
- const { left, top } = mapCraneLocalPosition(hw, hh, cw, ch, rx, ry)
201
+ // 축 배정/원점은 host(보통 STOCKER) 의 내부 좌표계 설정을 따른다. 타입을 묻지 않고
202
+ // state 만 읽어 어떤 컨테이너가 host 여도 동일하게 동작한다 — 미설정이면 종전
203
+ // 폴백(긴쪽=X, 원점 left/top).
204
+ const { left, top } = mapCraneLocalPosition(hw, hh, cw, ch, rx, ry, {
205
+ axisX: host.state?.coordAxisX,
206
+ invertX: !!host.state?.coordInvertX,
207
+ invertY: !!host.state?.coordInvertY
208
+ })
200
209
 
201
210
  return {
202
211
  left,
@@ -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, disposeObject3D } from '@hatiolab/things-scene'
18
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D, MaterialBank } 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
 
@@ -76,11 +76,8 @@ export class AGV3D extends RealObjectGroup {
76
76
  const bumperPad = Math.min(w, h) * 0.03
77
77
  const bumperCornerR = Math.min(w + bumperPad * 2, h + bumperPad * 2, bumperH) * 0.2
78
78
  const bumperGeo = new RoundedBoxGeometry(w + bumperPad * 2, bumperH, h + bumperPad * 2, 4, bumperCornerR)
79
- const bumperMat = new THREE.MeshStandardMaterial({
80
- color: BUMPER_COLOR,
81
- roughness: 0.8,
82
- metalness: 0.1,
83
- })
79
+ // MaterialBank 공유 — dedup + faint 안전(코어 변형 스왑, disposeObject3D 가 __bankOwned 스킵).
80
+ const bumperMat = MaterialBank.material('agv|bumper', { roughness: 0.8, metalness: 0.1 }, BUMPER_COLOR)
84
81
  const bumper = new THREE.Mesh(bumperGeo, bumperMat)
85
82
  bumper.position.y = bumperH / 2
86
83
  bumper.castShadow = true
@@ -89,11 +86,7 @@ export class AGV3D extends RealObjectGroup {
89
86
  // ── 본체 (둥근 모서리, statusColor 적용) ──
90
87
  const bodyColor = this.resolveColor()
91
88
  const bodyGeo = new RoundedBoxGeometry(w, bodyH, h, 4, cornerR)
92
- const bodyMat = new THREE.MeshStandardMaterial({
93
- color: bodyColor,
94
- roughness: 0.3,
95
- metalness: 0.5,
96
- })
89
+ const bodyMat = MaterialBank.material('agv|body', { roughness: 0.3, metalness: 0.5 }, bodyColor)
97
90
  const body = new THREE.Mesh(bodyGeo, bodyMat)
98
91
  body.position.y = bumperH + bodyH / 2
99
92
  body.castShadow = true
@@ -103,11 +96,7 @@ export class AGV3D extends RealObjectGroup {
103
96
  const platInset = Math.min(w, h) * 0.04
104
97
  const platCornerR = Math.min(w - platInset * 2, h - platInset * 2, platH) * 0.15
105
98
  const platGeo = new RoundedBoxGeometry(w - platInset * 2, platH, h - platInset * 2, 4, platCornerR)
106
- const platMat = new THREE.MeshStandardMaterial({
107
- color: PLATFORM_COLOR,
108
- roughness: 0.5,
109
- metalness: 0.15,
110
- })
99
+ const platMat = MaterialBank.material('agv|platform', { roughness: 0.5, metalness: 0.15 }, PLATFORM_COLOR)
111
100
  const plat = new THREE.Mesh(platGeo, platMat)
112
101
  plat.position.y = bumperH + bodyH + gapH + platH / 2
113
102
  plat.castShadow = true
@@ -116,6 +105,9 @@ export class AGV3D extends RealObjectGroup {
116
105
  this._ownMeshes = [bumper, body, plat]
117
106
  // ownGroup이 center-origin ↔ bottom-up 좌표계 변환을 자동 처리
118
107
  for (const m of this._ownMeshes) this.components3D.add(m)
108
+
109
+ // mesh 는 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게).
110
+ this.updateAlpha()
119
111
  }
120
112
 
121
113
  updateDimension() {}
@@ -134,16 +126,8 @@ export class AGV3D extends RealObjectGroup {
134
126
  this.updateHidden()
135
127
  }
136
128
 
137
- updateAlpha() {
138
- const { alpha = 1 } = this.component.state
139
- for (const m of this._ownMeshes) {
140
- const materials = Array.isArray(m.material) ? m.material : [m.material]
141
- for (const mat of materials) {
142
- ;(mat as THREE.MeshStandardMaterial).opacity = alpha
143
- ;(mat as THREE.MeshStandardMaterial).transparent = alpha < 1
144
- }
145
- }
146
- }
129
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴.
130
+ // canonical(bank base opacity 1) × state.alpha × faint. view 모드 불변, 편집 모드 faint 추가.
147
131
 
148
132
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
149
133
  if ('width' in after || 'height' in after || 'depth' in after ||
@@ -13,7 +13,7 @@
13
13
 
14
14
  import * as THREE from 'three'
15
15
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
16
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
16
+ import { RealObjectGroup, registerRealObjectFactory, MaterialBank } from '@hatiolab/things-scene'
17
17
  import type { Component } from '@hatiolab/things-scene'
18
18
  import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
19
19
  import { Z_FMSIM } from './z-priority'
@@ -85,11 +85,8 @@ export class Conveyor3D extends RealObjectGroup {
85
85
  const railW = Math.max(beltWidth * 0.06, 2)
86
86
  const legThickness = Math.max(railW * 0.8, 2)
87
87
 
88
- const frameMaterial = new THREE.MeshStandardMaterial({
89
- color: FRAME_COLOR,
90
- metalness: 0.85,
91
- roughness: 0.35,
92
- })
88
+ // MaterialBank 공유(dedup). 자체 update() 없이 base 생명주기(guarded disposeObject3D) 상속 → 안전.
89
+ const frameMaterial = MaterialBank.material('conveyor|frame', { metalness: 0.85, roughness: 0.35 }, FRAME_COLOR)
93
90
 
94
91
  // --- Frame: side rails + legs + cross-bracing ---
95
92
  // Y 좌표: y=0(바닥) ~ y=depth(상단). 레일 상단이 depth에 위치.
@@ -179,6 +176,9 @@ export class Conveyor3D extends RealObjectGroup {
179
176
  } else {
180
177
  this.buildRollers(width, height, depth, frameH, railW, rollWidth, isVertical)
181
178
  }
179
+
180
+ // mesh 는 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게).
181
+ this.updateAlpha()
182
182
  }
183
183
 
184
184
  private buildRollers(
@@ -231,7 +231,7 @@ export class Conveyor3D extends RealObjectGroup {
231
231
  if (rollerGeometries.length > 0) {
232
232
  const rollerMesh = new THREE.Mesh(
233
233
  BufferGeometryUtils.mergeGeometries(rollerGeometries),
234
- new THREE.MeshStandardMaterial({ color, metalness: 0.9, roughness: 0.2 })
234
+ MaterialBank.material('conveyor|roller', { metalness: 0.9, roughness: 0.2 }, color)
235
235
  )
236
236
  rollerMesh.castShadow = true
237
237
  this.components3D.add(rollerMesh)
@@ -268,18 +268,14 @@ export class Conveyor3D extends RealObjectGroup {
268
268
 
269
269
  const drumMesh = new THREE.Mesh(
270
270
  BufferGeometryUtils.mergeGeometries(drumGeometries),
271
- new THREE.MeshStandardMaterial({ color: ROLLER_COLOR, metalness: 0.9, roughness: 0.2 })
271
+ MaterialBank.material('conveyor|drum', { metalness: 0.9, roughness: 0.2 }, ROLLER_COLOR)
272
272
  )
273
273
  drumMesh.castShadow = true
274
274
  this.components3D.add(drumMesh)
275
275
 
276
276
  // Belt surface
277
277
  const beltThickness = drumRadius * 0.12
278
- const beltMaterial = new THREE.MeshStandardMaterial({
279
- color: color,
280
- metalness: 0.0,
281
- roughness: 0.9,
282
- })
278
+ const beltMaterial = MaterialBank.material('conveyor|belt', { metalness: 0.0, roughness: 0.9 }, color)
283
279
 
284
280
  // Flat belt top surface spanning between drums
285
281
  const flatLen = drum2 - drum1
@@ -347,11 +343,7 @@ export class Conveyor3D extends RealObjectGroup {
347
343
  // 두 레일 간격: 내부 폭의 60%
348
344
  const railSpacing = innerWidth * 0.6
349
345
 
350
- const railMaterial = new THREE.MeshStandardMaterial({
351
- color,
352
- metalness: 0.7,
353
- roughness: 0.3,
354
- })
346
+ const railMaterial = MaterialBank.material('conveyor|rail', { metalness: 0.7, roughness: 0.3 }, color)
355
347
 
356
348
  const railGeometries: THREE.BufferGeometry[] = []
357
349
 
@@ -376,7 +368,8 @@ export class Conveyor3D extends RealObjectGroup {
376
368
  }
377
369
 
378
370
  updateDimension() {}
379
- updateAlpha() {}
371
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint).
372
+ // view 모드는 factor=1 → build-시 opacity 그대로(불변), 편집 모드만 faint 추가.
380
373
 
381
374
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
382
375
  if (
@@ -13,7 +13,7 @@
13
13
 
14
14
  import * as THREE from 'three'
15
15
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
16
- import { RealObjectGroup, registerRealObjectFactory } from '@hatiolab/things-scene'
16
+ import { RealObjectGroup, registerRealObjectFactory, MaterialBank } from '@hatiolab/things-scene'
17
17
  import type { Component } from '@hatiolab/things-scene'
18
18
  import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
19
19
 
@@ -84,11 +84,8 @@ export class Crane3D extends RealObjectGroup {
84
84
 
85
85
  const pillarThick = Math.min(w, h) * 0.12
86
86
 
87
- const clampMat = new THREE.MeshStandardMaterial({
88
- color: CLAMP_COLOR,
89
- roughness: 0.5,
90
- metalness: 0.5,
91
- })
87
+ // MaterialBank 공유(dedup). 자체 update() 없이 base 생명주기(guarded disposeObject3D) 상속 → 안전.
88
+ const clampMat = MaterialBank.material('crane|clamp', { roughness: 0.5, metalness: 0.5 }, CLAMP_COLOR)
92
89
 
93
90
  // ── 1. 하단 클램프 (바닥 레일에 물리는 바퀴부) ──
94
91
  const clampW = w * 0.7
@@ -102,11 +99,7 @@ export class Crane3D extends RealObjectGroup {
102
99
  // ── 2. 수직 마스트 (좌우 기둥 + 상·하 가로 연결 바) ──
103
100
  const mastBaseY = clampH
104
101
  const bodyColor = this.resolveColor()
105
- const mastMat = new THREE.MeshStandardMaterial({
106
- color: bodyColor,
107
- roughness: 0.3,
108
- metalness: 0.6,
109
- })
102
+ const mastMat = MaterialBank.material('crane|mast', { roughness: 0.3, metalness: 0.6 }, bodyColor)
110
103
 
111
104
  const mastGeometries: THREE.BufferGeometry[] = []
112
105
 
@@ -137,11 +130,7 @@ export class Crane3D extends RealObjectGroup {
137
130
  // 포크 geometry 는 *_mastBaseY 기준 local 0 에 둠* — 즉 group.position.y = 0
138
131
  // 일 때 mast 바닥 자리에. forkZ 0~100 에 따라 group.position.y 만 변경하면
139
132
  // mast 따라 위/아래 이동. mesh 재구축 불필요.
140
- const forkMat = new THREE.MeshStandardMaterial({
141
- color: FORK_COLOR,
142
- roughness: 0.4,
143
- metalness: 0.5,
144
- })
133
+ const forkMat = MaterialBank.material('crane|fork', { roughness: 0.4, metalness: 0.5 }, FORK_COLOR)
145
134
 
146
135
  const forkGeometries: THREE.BufferGeometry[] = []
147
136
 
@@ -188,10 +177,13 @@ export class Crane3D extends RealObjectGroup {
188
177
  topClamp.position.y = mastBaseY + mastH + clampH / 2
189
178
  topClamp.castShadow = true
190
179
  inner.add(topClamp)
180
+
181
+ // mesh 는 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게).
182
+ this.updateAlpha()
191
183
  }
192
184
 
193
185
  updateDimension() {}
194
- updateAlpha() {}
186
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint).
195
187
 
196
188
  /** build() 직전 호출. forkGroup 참조를 깨끗이 비워둔다 — 새 build 가 다시 set. */
197
189
  clear() {
@@ -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, MaterialBank } 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
  import { Z_FMSIM } from './z-priority'
@@ -46,17 +46,10 @@ export class CraneRail3D extends RealObjectGroup {
46
46
  const railW = Math.max(h * 0.06, 2) // 레일 폭
47
47
  const supportThick = Math.max(Math.min(w, h) * 0.05, 2)
48
48
 
49
- const railMat = new THREE.MeshStandardMaterial({
50
- color: RAIL_COLOR,
51
- roughness: 0.3,
52
- metalness: 0.7,
53
- })
54
-
55
- const supportMat = new THREE.MeshStandardMaterial({
56
- color: SUPPORT_COLOR,
57
- roughness: 0.5,
58
- metalness: 0.5,
59
- })
49
+ // MaterialBank 공유(정적 색 dedup). 자체 update() 없이 base 생명주기(guarded disposeObject3D)
50
+ // 상속 → 공유 base dispose 안전.
51
+ const railMat = MaterialBank.material('crane-rail|rail', { roughness: 0.3, metalness: 0.7 }, RAIL_COLOR)
52
+ const supportMat = MaterialBank.material('crane-rail|support', { roughness: 0.5, metalness: 0.5 }, SUPPORT_COLOR)
60
53
 
61
54
  // ── 바닥 레일 (2개 평행 트랙) ──
62
55
  const bottomGeometries: THREE.BufferGeometry[] = []
@@ -98,10 +91,13 @@ export class CraneRail3D extends RealObjectGroup {
98
91
  const supportMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(supportGeometries), supportMat)
99
92
  supportMesh.castShadow = true
100
93
  inner.add(supportMesh)
94
+
95
+ // mesh 는 super.build() 이후 생성 → 코어 faint 를 명시 적용(편집 흐림이 mesh 에 닿게).
96
+ this.updateAlpha()
101
97
  }
102
98
 
103
99
  updateDimension() {}
104
- updateAlpha() {}
100
+ // updateAlpha override 제거 — 코어 RealObject.updateAlpha(=applyMaterialOpacity)로 수렴(faint).
105
101
 
106
102
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
107
103
  if ('width' in after || 'height' in after || 'depth' in after || 'fillStyle' in after || 'strokeStyle' in after) {