@fmsim/machine 1.0.88 → 2.0.0-beta.100

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 (165) hide show
  1. package/dist/agv-line.js.map +1 -1
  2. package/dist/agv.js +6 -2
  3. package/dist/agv.js.map +1 -1
  4. package/dist/buffer.js.map +1 -1
  5. package/dist/carrier-manager.js +183 -0
  6. package/dist/carrier-manager.js.map +1 -0
  7. package/dist/carrier.js +105 -14
  8. package/dist/carrier.js.map +1 -1
  9. package/dist/conveyor-join.js +8 -2
  10. package/dist/conveyor-join.js.map +1 -1
  11. package/dist/conveyor.js +9 -1
  12. package/dist/conveyor.js.map +1 -1
  13. package/dist/crane-rail.js +53 -0
  14. package/dist/crane-rail.js.map +1 -0
  15. package/dist/crane.js +144 -1
  16. package/dist/crane.js.map +1 -1
  17. package/dist/equipment.js.map +1 -1
  18. package/dist/factories/agv-3d.js +147 -0
  19. package/dist/factories/agv-3d.js.map +1 -0
  20. package/dist/factories/conveyor-3d.js +345 -0
  21. package/dist/factories/conveyor-3d.js.map +1 -0
  22. package/dist/factories/crane-3d.js +186 -0
  23. package/dist/factories/crane-3d.js.map +1 -0
  24. package/dist/factories/crane-rail-3d.js +95 -0
  25. package/dist/factories/crane-rail-3d.js.map +1 -0
  26. package/dist/factories/machine-3d.js +973 -0
  27. package/dist/factories/machine-3d.js.map +1 -0
  28. package/dist/factories/oht-3d.js +177 -0
  29. package/dist/factories/oht-3d.js.map +1 -0
  30. package/dist/factories/shuttle-3d.js +159 -0
  31. package/dist/factories/shuttle-3d.js.map +1 -0
  32. package/dist/factories/z-priority-defaults.js +33 -0
  33. package/dist/factories/z-priority-defaults.js.map +1 -0
  34. package/dist/factories/z-priority.js +54 -0
  35. package/dist/factories/z-priority.js.map +1 -0
  36. package/dist/features/conveyor-mixin.js.map +1 -1
  37. package/dist/features/mcs-status-default.js +15 -1
  38. package/dist/features/mcs-status-default.js.map +1 -1
  39. package/dist/features/mcs-status-mixin.js +22 -0
  40. package/dist/features/mcs-status-mixin.js.map +1 -1
  41. package/dist/features/mcs-zone-mixin.js.map +1 -1
  42. package/dist/features/parent-object-mixin.js +15 -7
  43. package/dist/features/parent-object-mixin.js.map +1 -1
  44. package/dist/floating-container.js +5 -0
  45. package/dist/floating-container.js.map +1 -1
  46. package/dist/index.js +58 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/mcs-carrier-holder.js +56 -17
  49. package/dist/mcs-carrier-holder.js.map +1 -1
  50. package/dist/mcs-machine.js +39 -0
  51. package/dist/mcs-machine.js.map +1 -1
  52. package/dist/mcs-unit.js +20 -0
  53. package/dist/mcs-unit.js.map +1 -1
  54. package/dist/mcs-vehicle.js +10 -4
  55. package/dist/mcs-vehicle.js.map +1 -1
  56. package/dist/node.js +10 -3
  57. package/dist/node.js.map +1 -1
  58. package/dist/oht-line.js.map +1 -1
  59. package/dist/oht.js +4 -0
  60. package/dist/oht.js.map +1 -1
  61. package/dist/port-flow.js +4 -0
  62. package/dist/port-flow.js.map +1 -1
  63. package/dist/port.js +4 -0
  64. package/dist/port.js.map +1 -1
  65. package/dist/scanner.js +30 -0
  66. package/dist/scanner.js.map +1 -0
  67. package/dist/scene/root-container-override.js +160 -96
  68. package/dist/scene/root-container-override.js.map +1 -1
  69. package/dist/shelf.js +4 -0
  70. package/dist/shelf.js.map +1 -1
  71. package/dist/shuttle.js.map +1 -1
  72. package/dist/static-container.js +5 -0
  73. package/dist/static-container.js.map +1 -1
  74. package/dist/stocker-abnormal-bar.js +184 -0
  75. package/dist/stocker-abnormal-bar.js.map +1 -0
  76. package/dist/stocker-capacity-bar.js +253 -6
  77. package/dist/stocker-capacity-bar.js.map +1 -1
  78. package/dist/stocker-carrier-bar.js +198 -0
  79. package/dist/stocker-carrier-bar.js.map +1 -0
  80. package/dist/stocker.js +7 -1
  81. package/dist/stocker.js.map +1 -1
  82. package/dist/templates/backdrop.js +30 -0
  83. package/dist/templates/backdrop.js.map +1 -0
  84. package/dist/templates/decal.js +21 -0
  85. package/dist/templates/decal.js.map +1 -0
  86. package/dist/templates/index.js +7 -1
  87. package/dist/templates/index.js.map +1 -1
  88. package/dist/templates/scanner.js +20 -0
  89. package/dist/templates/scanner.js.map +1 -0
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/dist/utils/crane-position.js +83 -0
  92. package/dist/utils/crane-position.js.map +1 -0
  93. package/dist/utils/data-value.js +21 -0
  94. package/dist/utils/data-value.js.map +1 -0
  95. package/dist/utils/fit-gauge-scale.js +28 -0
  96. package/dist/utils/fit-gauge-scale.js.map +1 -0
  97. package/dist/utils/get-value-on-ranges.js +2 -4
  98. package/dist/utils/get-value-on-ranges.js.map +1 -1
  99. package/dist/utils/stocker-gauge.js +78 -0
  100. package/dist/utils/stocker-gauge.js.map +1 -0
  101. package/dist/zone-capacity-bar.js +3 -3
  102. package/dist/zone-capacity-bar.js.map +1 -1
  103. package/icons/backdrop.png +0 -0
  104. package/icons/decal.png +0 -0
  105. package/jest.config.js +10 -0
  106. package/package.json +7 -6
  107. package/src/agv-line.ts +1 -1
  108. package/src/agv.ts +10 -5
  109. package/src/buffer.ts +1 -1
  110. package/src/carrier-manager.ts +223 -0
  111. package/src/carrier.ts +111 -18
  112. package/src/conveyor-join.ts +6 -3
  113. package/src/conveyor.ts +10 -2
  114. package/src/crane-rail.ts +58 -0
  115. package/src/crane.ts +182 -2
  116. package/src/equipment.ts +1 -1
  117. package/src/factories/agv-3d.ts +160 -0
  118. package/src/factories/conveyor-3d.ts +398 -0
  119. package/src/factories/crane-3d.ts +217 -0
  120. package/src/factories/crane-rail-3d.ts +115 -0
  121. package/src/factories/machine-3d.ts +1192 -0
  122. package/src/factories/oht-3d.ts +196 -0
  123. package/src/factories/shuttle-3d.ts +179 -0
  124. package/src/factories/z-priority-defaults.ts +38 -0
  125. package/src/factories/z-priority.ts +60 -0
  126. package/src/features/conveyor-mixin.ts +1 -1
  127. package/src/features/mcs-status-default.ts +15 -1
  128. package/src/features/mcs-status-mixin.ts +26 -4
  129. package/src/features/mcs-zone-mixin.ts +1 -1
  130. package/src/features/parent-object-mixin.ts +17 -8
  131. package/src/floating-container.ts +5 -0
  132. package/src/index.ts +66 -2
  133. package/src/mcs-carrier-holder.ts +58 -29
  134. package/src/mcs-machine.ts +43 -1
  135. package/src/mcs-unit.ts +25 -2
  136. package/src/mcs-vehicle.ts +12 -9
  137. package/src/node.ts +9 -3
  138. package/src/oht-line.ts +1 -1
  139. package/src/oht.ts +7 -2
  140. package/src/port-flow.ts +6 -1
  141. package/src/port.ts +6 -1
  142. package/src/scanner.ts +29 -0
  143. package/src/scene/root-container-override.ts +156 -105
  144. package/src/shelf.ts +6 -1
  145. package/src/shuttle.ts +1 -1
  146. package/src/static-container.ts +5 -0
  147. package/src/stocker-abnormal-bar.ts +247 -0
  148. package/src/stocker-capacity-bar.ts +301 -10
  149. package/src/stocker-carrier-bar.ts +262 -0
  150. package/src/stocker.ts +10 -3
  151. package/src/templates/backdrop.ts +30 -0
  152. package/src/templates/decal.ts +21 -0
  153. package/src/templates/index.ts +7 -1
  154. package/src/templates/scanner.ts +20 -0
  155. package/src/utils/crane-position.ts +105 -0
  156. package/src/utils/data-value.ts +19 -0
  157. package/src/utils/fit-gauge-scale.ts +35 -0
  158. package/src/utils/get-value-on-ranges.ts +2 -4
  159. package/src/utils/stocker-gauge.ts +111 -0
  160. package/src/zone-capacity-bar.ts +5 -5
  161. package/translations/en.json +26 -1
  162. package/translations/ja.json +26 -1
  163. package/translations/ko.json +26 -1
  164. package/translations/ms.json +26 -1
  165. package/translations/zh.json +26 -1
package/src/crane.ts CHANGED
@@ -1,6 +1,12 @@
1
- import { Component, ComponentNature, Properties, sceneComponent } from '@hatiolab/things-scene'
1
+ import { Component, ComponentNature, sceneComponent, frameClock } from '@hatiolab/things-scene'
2
2
  import MCSVehicle from './mcs-vehicle'
3
3
  import { LEGEND_CRANE } from './features/mcs-status-default'
4
+ import {
5
+ axisRatio,
6
+ mapCraneLocalPosition,
7
+ normalizeCranePosition,
8
+ type CranePositionData
9
+ } from './utils/crane-position'
4
10
 
5
11
  const NATURE: ComponentNature = {
6
12
  mutable: false,
@@ -11,6 +17,29 @@ const NATURE: ComponentNature = {
11
17
 
12
18
  const GAP = 2
13
19
 
20
+ /** 좌표 보간 기본 duration (ms). data.DURATION_MS 가 없으면 이 값 사용. */
21
+ const DEFAULT_DURATION_MS = 1000
22
+
23
+ function lerp(a: number, b: number, t: number): number {
24
+ return a + (b - a) * t
25
+ }
26
+
27
+ function easeInOut(t: number): number {
28
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
29
+ }
30
+
31
+ interface CranePositionAnimator {
32
+ fromLeft: number
33
+ fromTop: number
34
+ fromForkZ: number
35
+ toLeft: number
36
+ toTop: number
37
+ toForkZ: number
38
+ startTime: number
39
+ duration: number
40
+ unsubscribe: () => void
41
+ }
42
+
14
43
  @sceneComponent('Crane')
15
44
  export default class Crane extends MCSVehicle {
16
45
  static get nature() {
@@ -21,8 +50,159 @@ export default class Crane extends MCSVehicle {
21
50
  return 'black'
22
51
  }
23
52
 
53
+ /** Crane 의 parentObject 는 STOCKER. */
54
+ get parentObjectTypes(): string[] {
55
+ return ['STOCKER']
56
+ }
57
+
24
58
  getLegendFallback() {
25
- return this.root.craneLegendTheme || super.getLegendFallback()
59
+ return (this.root as any).craneLegendTheme || super.getLegendFallback()
60
+ }
61
+
62
+ private _posAnimator?: CranePositionAnimator
63
+
64
+ /**
65
+ * data.cranePosition = { current: {x,y,z}, limit: {x|y|z:{min,max}} } 분기.
66
+ * current 를 limit 범위 안의 0~1 비율로 환산한 뒤, 부모 (보통 Stocker) 기준으로
67
+ * 실 좌표 (state.left/top) 와 forkZ 로 변환해 lerp 시작. x→host 긴쪽, y→짧은쪽,
68
+ * z→포크 높이. 보간 중 새 값이 들어오면 *현재 보간 위치* 를 from 으로 잡고 새
69
+ * target 으로 새 lerp 시작.
70
+ * NODEID 경로는 super (MCSVehicle.onchangeData) 가 처리 — cranePosition 만 있는
71
+ * 데이터에선 NODEID 가 없어 자연스럽게 noop.
72
+ */
73
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
74
+ const afterData = after?.data
75
+ // 데이터 키는 모두 대문자 전제. CRANEPOSITION 의 중첩 키(CURRENT/LIMIT/X/Y/Z/MIN/MAX)는
76
+ // normalizeCranePosition 이 내부 표준형(소문자)으로 변환. DURATION_MS 는 사용하지 않음.
77
+ const afterCP = normalizeCranePosition(afterData?.CRANEPOSITION)
78
+ if (afterCP) {
79
+ const beforeCP = normalizeCranePosition(before?.data?.CRANEPOSITION)
80
+ if (this._cranePositionChanged(afterCP, beforeCP)) {
81
+ this._startPositionAnimation(afterCP)
82
+ }
83
+ }
84
+ super.onchangeData(after, before)
85
+ }
86
+
87
+ /** current 또는 limit 중 하나라도 바뀌면 재계산 필요 (limit 변화도 실 위치를 바꿈). */
88
+ private _cranePositionChanged(a: CranePositionData, b?: CranePositionData): boolean {
89
+ if (!b) return true
90
+ return (
91
+ JSON.stringify(a?.current ?? null) !== JSON.stringify(b?.current ?? null) ||
92
+ JSON.stringify(a?.limit ?? null) !== JSON.stringify(b?.limit ?? null)
93
+ )
94
+ }
95
+
96
+ dispose() {
97
+ if (this._posAnimator) {
98
+ this._posAnimator.unsubscribe()
99
+ this._posAnimator = undefined
100
+ }
101
+ super.dispose()
102
+ }
103
+
104
+ private _startPositionAnimation(cranePosition: CranePositionData, durationMs?: number): void {
105
+ const target = this._ratioToReal(cranePosition)
106
+ if (!target) return // 부모(host) 없거나 크기 0 이면 변환 skip
107
+
108
+ const now = frameClock.simTime
109
+ const duration = Math.max(1, Number(durationMs) || DEFAULT_DURATION_MS)
110
+
111
+ // 현재 위치 — 보간 중이면 그 시점 보간값을 from 으로
112
+ let fromLeft = Number(this.state.left) || 0
113
+ let fromTop = Number(this.state.top) || 0
114
+ let fromForkZ = Number((this.state as any).forkZ) || 0
115
+
116
+ if (this._posAnimator) {
117
+ const a = this._posAnimator
118
+ const t = Math.min(1, Math.max(0, (now - a.startTime) / a.duration))
119
+ const e = easeInOut(t)
120
+ fromLeft = lerp(a.fromLeft, a.toLeft, e)
121
+ fromTop = lerp(a.fromTop, a.toTop, e)
122
+ fromForkZ = lerp(a.fromForkZ, a.toForkZ, e)
123
+ a.unsubscribe()
124
+ this._posAnimator = undefined
125
+ }
126
+
127
+ const unsubscribe = frameClock.subscribe(
128
+ (_dt: number, simTime: number) => this._tickPositionAnimation(simTime),
129
+ { description: 'crane-position', component: this }
130
+ )
131
+
132
+ this._posAnimator = {
133
+ fromLeft,
134
+ fromTop,
135
+ fromForkZ,
136
+ toLeft: target.left,
137
+ toTop: target.top,
138
+ toForkZ: target.forkZ,
139
+ startTime: now,
140
+ duration,
141
+ unsubscribe
142
+ }
143
+ // 즉시 첫 frame 반영 (t=0) — set 한 번 호출. paused 환경에서도 from→target
144
+ // 진입 시점이 명확.
145
+ this._tickPositionAnimation(now)
146
+ }
147
+
148
+ private _tickPositionAnimation(simTime: number): void {
149
+ const a = this._posAnimator
150
+ if (!a) return
151
+ const t = Math.min(1, Math.max(0, (simTime - a.startTime) / a.duration))
152
+ const e = easeInOut(t)
153
+ this.set({
154
+ left: lerp(a.fromLeft, a.toLeft, e),
155
+ top: lerp(a.fromTop, a.toTop, e),
156
+ forkZ: lerp(a.fromForkZ, a.toForkZ, e)
157
+ })
158
+ if (t >= 1) {
159
+ a.unsubscribe()
160
+ this._posAnimator = undefined
161
+ }
162
+ }
163
+
164
+ /**
165
+ * cranePosition (current + limit) → state.left/top + forkZ.
166
+ *
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 비율로 매핑)
172
+ *
173
+ * 각 축 비율 = (current - limit.min) / (limit.max - limit.min) ∈ [0,1].
174
+ *
175
+ * **스토커 안쪽 오프셋**: 크레인의 폭/두께를 감안해, 크레인 *전체* 가 host 안에
176
+ * 머무르도록 top-left 좌표를 [0, hostLen - craneLen] 범위로 매핑한다 (= 양끝에서
177
+ * craneLen/2 만큼 inset 된 중심선 위를 움직임). 크레인이 host 보다 크면 span 0
178
+ * 으로 떨어져 host 모서리에 정렬.
179
+ *
180
+ * 참조는 ParentObjectMixin 이 제공하는 `parentObject` 사용 —
181
+ * state.parentObjectId 명시 시 그것, 미명시 시 this.parent 자동 fallback.
182
+ */
183
+ private _ratioToReal(
184
+ cp: CranePositionData
185
+ ): { left: number; top: number; forkZ: number } | undefined {
186
+ const host: any = (this as any).parentObject
187
+ if (!host) return undefined
188
+
189
+ const hw = Number(host.state?.width) || 0
190
+ const hh = Number(host.state?.height) || 0
191
+ const cw = Number(this.state.width) || 0
192
+ const ch = Number(this.state.height) || 0
193
+ if (!hw || !hh) return undefined
194
+
195
+ const rx = axisRatio(cp.current?.x, cp.limit?.x)
196
+ const ry = axisRatio(cp.current?.y, cp.limit?.y)
197
+ const rz = axisRatio(cp.current?.z, cp.limit?.z)
198
+
199
+ const { left, top } = mapCraneLocalPosition(hw, hh, cw, ch, rx, ry)
200
+
201
+ return {
202
+ left,
203
+ top,
204
+ forkZ: rz * 100 // forkZ 0~100, 3D 측 (crane-3d) 에서 mast 비율로 매핑
205
+ }
26
206
  }
27
207
 
28
208
  render(ctx: CanvasRenderingContext2D) {
package/src/equipment.ts CHANGED
@@ -15,7 +15,7 @@ export default class Equipment extends MCSMachine {
15
15
  }
16
16
 
17
17
  getLegendFallback() {
18
- return this.root.equipmentLegendTheme || super.getLegendFallback()
18
+ return (this.root as any).equipmentLegendTheme || super.getLegendFallback()
19
19
  }
20
20
 
21
21
  containable(component: Component) {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * AGV 3D Factory
3
+ *
4
+ * 범퍼 + 본체 + 플랫폼 3단 구조:
5
+ * - 하단 범퍼 (10%, 본체보다 약간 넓음, #222222)
6
+ * - 본체 (58%, RoundedBoxGeometry, statusColor 적용)
7
+ * - 간격 (4%)
8
+ * - 상단 플랫폼 (28%, 본체보다 안쪽, #333333)
9
+ *
10
+ * Based on operato-scene Vehicle3D, adapted for fmsim:
11
+ * - extends RealObjectGroup (Conveyor3D와 동일 패턴)
12
+ * - statusColor from MCSStatusMixin
13
+ * - 볼륨 중심 원점 (things-scene 기본 컨벤션) - mesh 로컬 [-d/2, +d/2]
14
+ */
15
+
16
+ import * as THREE from 'three'
17
+ import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
18
+ import { RealObjectGroup, registerRealObjectFactory, disposeObject3D } from '@hatiolab/things-scene'
19
+ import type { Component } from '@hatiolab/things-scene'
20
+ import { TRANSPORT_SURFACE_HEIGHT } from './machine-3d.js'
21
+
22
+ const BUMPER_COLOR = 0x222222
23
+ const PLATFORM_COLOR = 0x333333
24
+ const DEFAULT_BODY_COLOR = 0x6688cc
25
+
26
+ export class AGV3D extends RealObjectGroup {
27
+ private _ownMeshes: THREE.Mesh[] = []
28
+ get effectiveDepth(): number {
29
+ const { depth } = this.component.state
30
+ return depth || TRANSPORT_SURFACE_HEIGHT
31
+ }
32
+
33
+ private resolveColor(): number {
34
+ const comp = this.component as any
35
+
36
+ // 1) MCSStatusMixin.statusColor (legend 기반 상태 색상)
37
+ // '#F0F0F0'은 상태 미설정 시 기본 fallback — 무시하고 defaultColor 사용
38
+ try {
39
+ const sc = comp.statusColor
40
+ if (typeof sc === 'string' && sc && sc.toUpperCase() !== '#F0F0F0' && sc !== 'transparent') return new THREE.Color(sc).getHex()
41
+ } catch {
42
+ /* statusColor 미구현 시 무시 */
43
+ }
44
+
45
+ // 2) state.fillStyle fallback
46
+ const { fillStyle } = this.component.state
47
+ if (fillStyle && typeof fillStyle === 'string' && fillStyle !== 'transparent') {
48
+ try {
49
+ return new THREE.Color(fillStyle).getHex()
50
+ } catch {
51
+ /* 파싱 실패 시 무시 */
52
+ }
53
+ }
54
+
55
+ return DEFAULT_BODY_COLOR
56
+ }
57
+
58
+ build() {
59
+ super.build()
60
+
61
+ const { width = 10, height = 10 } = this.component.state
62
+ const w = Math.abs(width)
63
+ const h = Math.abs(height)
64
+ const d = this.effectiveDepth
65
+ if (!w || !h) return
66
+
67
+ // 비율: 하단 범퍼 10%, 본체 58%, 간격 4%, 플랫폼 28%
68
+ const bumperH = d * 0.1
69
+ const bodyH = d * 0.58
70
+ const gapH = d * 0.04
71
+ const platH = d * 0.28
72
+
73
+ const cornerR = Math.min(w, h, bodyH) * 0.12
74
+
75
+ // ── 하단 범퍼 밴드 (본체보다 약간 넓게 돌출) ──
76
+ const bumperPad = Math.min(w, h) * 0.03
77
+ const bumperCornerR = Math.min(w + bumperPad * 2, h + bumperPad * 2, bumperH) * 0.2
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
+ })
84
+ const bumper = new THREE.Mesh(bumperGeo, bumperMat)
85
+ bumper.position.y = bumperH / 2
86
+ bumper.castShadow = true
87
+ bumper.receiveShadow = true
88
+
89
+ // ── 본체 (둥근 모서리, statusColor 적용) ──
90
+ const bodyColor = this.resolveColor()
91
+ 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
+ })
97
+ const body = new THREE.Mesh(bodyGeo, bodyMat)
98
+ body.position.y = bumperH + bodyH / 2
99
+ body.castShadow = true
100
+ body.receiveShadow = true
101
+
102
+ // ── 상단 플랫폼 (본체보다 안쪽, 어두운색) ──
103
+ const platInset = Math.min(w, h) * 0.04
104
+ const platCornerR = Math.min(w - platInset * 2, h - platInset * 2, platH) * 0.15
105
+ 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
+ })
111
+ const plat = new THREE.Mesh(platGeo, platMat)
112
+ plat.position.y = bumperH + bodyH + gapH + platH / 2
113
+ plat.castShadow = true
114
+ plat.receiveShadow = true
115
+
116
+ this._ownMeshes = [bumper, body, plat]
117
+ // ownGroup이 center-origin ↔ bottom-up 좌표계 변환을 자동 처리
118
+ for (const m of this._ownMeshes) this.components3D.add(m)
119
+ }
120
+
121
+ updateDimension() {}
122
+
123
+ update() {
124
+ // 자신의 mesh만 제거 (Carrier 등 자식 컴포넌트 3D 보존)
125
+ for (const m of this._ownMeshes) {
126
+ this.components3D.remove(m)
127
+ disposeObject3D(m)
128
+ }
129
+ this._ownMeshes = []
130
+
131
+ this.build()
132
+ this.updateTransform()
133
+ this.updateAlpha()
134
+ this.updateHidden()
135
+ }
136
+
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
+ }
147
+
148
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
149
+ if ('width' in after || 'height' in after || 'depth' in after ||
150
+ 'fillStyle' in after || 'strokeStyle' in after || 'id' in after ||
151
+ 'EMPTYTYPE' in after || 'CARRIERSTATUS' in after) {
152
+ this.update()
153
+ return
154
+ }
155
+
156
+ super.onchange(after, before)
157
+ }
158
+ }
159
+
160
+ registerRealObjectFactory('AGV', (component: Component) => new AGV3D(component))