@operato/scene-transport 10.0.0-beta.22

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 (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +55 -0
  3. package/dist/agv-3d.d.ts +7 -0
  4. package/dist/agv-3d.js +233 -0
  5. package/dist/agv-3d.js.map +1 -0
  6. package/dist/agv.d.ts +57 -0
  7. package/dist/agv.js +171 -0
  8. package/dist/agv.js.map +1 -0
  9. package/dist/forklift-3d.d.ts +15 -0
  10. package/dist/forklift-3d.js +518 -0
  11. package/dist/forklift-3d.js.map +1 -0
  12. package/dist/forklift.d.ts +58 -0
  13. package/dist/forklift.js +163 -0
  14. package/dist/forklift.js.map +1 -0
  15. package/dist/index.d.ts +8 -0
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/templates/index.d.ts +47 -0
  19. package/dist/templates/index.js +73 -0
  20. package/dist/templates/index.js.map +1 -0
  21. package/dist/tugger-3d.d.ts +7 -0
  22. package/dist/tugger-3d.js +140 -0
  23. package/dist/tugger-3d.js.map +1 -0
  24. package/dist/tugger.d.ts +40 -0
  25. package/dist/tugger.js +135 -0
  26. package/dist/tugger.js.map +1 -0
  27. package/dist/worker-3d.d.ts +7 -0
  28. package/dist/worker-3d.js +199 -0
  29. package/dist/worker-3d.js.map +1 -0
  30. package/dist/worker.d.ts +44 -0
  31. package/dist/worker.js +130 -0
  32. package/dist/worker.js.map +1 -0
  33. package/icons/agv.png +0 -0
  34. package/icons/forklift.png +0 -0
  35. package/icons/tugger.png +0 -0
  36. package/icons/worker.png +0 -0
  37. package/package.json +44 -0
  38. package/src/agv-3d.ts +283 -0
  39. package/src/agv.ts +207 -0
  40. package/src/forklift-3d.ts +591 -0
  41. package/src/forklift.ts +200 -0
  42. package/src/index.ts +14 -0
  43. package/src/templates/index.ts +73 -0
  44. package/src/tugger-3d.ts +169 -0
  45. package/src/tugger.ts +169 -0
  46. package/src/worker-3d.ts +232 -0
  47. package/src/worker.ts +164 -0
  48. package/things-scene.config.js +5 -0
  49. package/translations/en.json +9 -0
  50. package/translations/ja.json +9 -0
  51. package/translations/ko.json +9 -0
  52. package/translations/ms.json +9 -0
  53. package/translations/zh.json +9 -0
  54. package/tsconfig.json +23 -0
  55. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,591 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Forklift 3D — designed from a real forklift's proportions, not stacked from
5
+ * primitive geometry. Procedural lo-poly with deliberate aesthetic choices.
6
+ *
7
+ * Design priorities (in order):
8
+ *
9
+ * 1. **Silhouette reads** — at any camera angle the mast + counterweight +
10
+ * forks combo identifies this as an industrial forklift.
11
+ * 2. **Proportions** — chassis ~35% of envelope, cab ~25%, overhead guard
12
+ * ~30%. Forks extend forward roughly the full chassis length.
13
+ * Counterweight is the heaviest visual mass, set rearward.
14
+ * 3. **Sculpted, not stacked** — the chassis side profile is a single
15
+ * `ExtrudeGeometry` (sloped hood / cab indent / counterweight rise),
16
+ * not three separate boxes glued together. Mast and forks are
17
+ * L-shaped extrusions, not rectangular prisms.
18
+ * 4. **Material palette** — yellow body with subtle shaded variant for
19
+ * shape-reading; dark counterweight; cool gray mast; chrome hydraulic.
20
+ * Status emissive only on the lamp + accent stripe.
21
+ * 5. **Sub-assemblies as groups** — chassis / cab / guard / mast / wheels
22
+ * / accents are organized as scene-graph children. Adjustable mass
23
+ * (mast group, forks) tracks `state.forkHeight` cleanly.
24
+ *
25
+ * Coordinate convention (component-local):
26
+ * - Origin at the geometric center of the component bounds.
27
+ * - X-axis: vehicle width (left/right).
28
+ * - Y-axis: vertical. Floor at `-depth/2`, ceiling at `+depth/2`.
29
+ * - Z-axis: vehicle length, +Z = forward (forks point this way).
30
+ *
31
+ * Defaults (with the scene-unit StandardHeights — operation=50, ceiling=200):
32
+ * - depth = 200 (full envelope; mast collapsed reaches ~80%, mast up to top)
33
+ * - wheelR = depth * 0.07 (~14)
34
+ * - chassis section length ~0.65 of width footprint
35
+ * - cab seat height ~operation level (matches the design intuition that
36
+ * the operator's seat sits at the standard operation surface)
37
+ */
38
+
39
+ import * as THREE from 'three'
40
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
41
+ import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
42
+ import { RealObjectGroup } from '@hatiolab/things-scene'
43
+
44
+ // ─── Color palette ─────────────────────────────────────────────────────────
45
+ const COLOR_COUNTERWEIGHT = 0x1c1c22
46
+ const COLOR_MAST = 0x36363f
47
+ const COLOR_FORK = 0x14141a
48
+ const COLOR_TIRE = 0x171719
49
+ const COLOR_RIM = 0x707080
50
+ const COLOR_HUB = 0xb8b8c0
51
+ const COLOR_SEAT = 0x14141c
52
+ const COLOR_STEERING = 0x202028
53
+ const COLOR_GUARD = 0x252530
54
+ const COLOR_HYDRAULIC = 0xc8ccd8
55
+ const COLOR_CHAIN = 0x4a4a55
56
+ const COLOR_WINDOW = 0x88aacc
57
+ const COLOR_TAIL = 0xff2626
58
+ const COLOR_HEADLIGHT = 0xfff7d4
59
+
60
+ export class Forklift3D extends RealObjectGroup {
61
+ /** Local-frame position of the fork-tip top surface — where cargo sits. */
62
+ get cargoMount(): { x: number; y: number; z: number; width: number; depth: number } {
63
+ const { width, height, depth = 200 } = this.component.state
64
+ const raw = (this.component.state.forkHeight as number) ?? 0
65
+ const forkHeight = Math.max(0, Math.min(raw, depth * 0.85))
66
+ const wheelR = depth * 0.07
67
+ const forkH = depth * 0.018
68
+ const forkLen = height * 0.42
69
+ const y = -depth / 2 + wheelR + forkHeight + forkH
70
+ const z = height * 0.42 + forkLen / 2
71
+ return { x: 0, y, z, width: width * 0.4, depth: forkLen }
72
+ }
73
+
74
+ build() {
75
+ super.build()
76
+
77
+ // ── State + clamping ─────────────────────────────────────────────
78
+ const { width, height, depth = 200 } = this.component.state
79
+ const bodyColor = (this.component.state.bodyColor as string) || '#ffc107'
80
+ const lampEmissive = (this.component.state.lampEmissive as string) || '#333333'
81
+ const status = this.component.state.status
82
+ const lampIntensity = status && status !== 'idle' ? 1.5 : 0.2
83
+ const forkHeightRaw = (this.component.state.forkHeight as number) ?? 0
84
+ const forkHeight = Math.max(0, Math.min(forkHeightRaw, depth * 0.85))
85
+
86
+ // ── Proportions ──────────────────────────────────────────────────
87
+ const baseY = -depth / 2
88
+ const wheelR = depth * 0.07
89
+ const chassisH = depth * 0.30 // hood height + step up
90
+ const cabH = depth * 0.22
91
+ const guardH = depth * 0.30 // overhead guard above cab
92
+ const cwH = depth * 0.50 // counterweight tall block
93
+ const cwD = height * 0.22 // counterweight depth (along z)
94
+ const chassisLen = height * 0.55 // length along z (vehicle direction)
95
+ const cabFloorY = baseY + wheelR + chassisH * 0.85 // cab seat near operation level
96
+
97
+ // ── Materials ─────────────────────────────────────────────────────
98
+ const bodyMat = new THREE.MeshStandardMaterial({
99
+ color: bodyColor,
100
+ metalness: 0.30,
101
+ roughness: 0.45
102
+ })
103
+ const bodyShadeMat = new THREE.MeshStandardMaterial({
104
+ color: new THREE.Color(bodyColor).multiplyScalar(0.78),
105
+ metalness: 0.28,
106
+ roughness: 0.50
107
+ })
108
+ const cwMat = new THREE.MeshStandardMaterial({
109
+ color: COLOR_COUNTERWEIGHT,
110
+ metalness: 0.55,
111
+ roughness: 0.65
112
+ })
113
+ const mastMat = new THREE.MeshStandardMaterial({
114
+ color: COLOR_MAST,
115
+ metalness: 0.85,
116
+ roughness: 0.30
117
+ })
118
+ const forkMat = new THREE.MeshStandardMaterial({
119
+ color: COLOR_FORK,
120
+ metalness: 0.85,
121
+ roughness: 0.35
122
+ })
123
+ const tireMat = new THREE.MeshStandardMaterial({ color: COLOR_TIRE, roughness: 0.95 })
124
+ const rimMat = new THREE.MeshStandardMaterial({ color: COLOR_RIM, metalness: 0.80, roughness: 0.30 })
125
+ const hubMat = new THREE.MeshStandardMaterial({ color: COLOR_HUB, metalness: 0.85, roughness: 0.25 })
126
+ const seatMat = new THREE.MeshStandardMaterial({ color: COLOR_SEAT, roughness: 0.85 })
127
+ const guardMat = new THREE.MeshStandardMaterial({ color: COLOR_GUARD, metalness: 0.75, roughness: 0.40 })
128
+ const hydraulicMat = new THREE.MeshStandardMaterial({ color: COLOR_HYDRAULIC, metalness: 0.95, roughness: 0.10 })
129
+ const chainMat = new THREE.MeshStandardMaterial({ color: COLOR_CHAIN, metalness: 0.80, roughness: 0.40 })
130
+ const windowMat = new THREE.MeshStandardMaterial({
131
+ color: COLOR_WINDOW, metalness: 0.20, roughness: 0.05,
132
+ transparent: true, opacity: 0.55
133
+ })
134
+ const tailMat = new THREE.MeshStandardMaterial({
135
+ color: COLOR_TAIL, emissive: COLOR_TAIL, emissiveIntensity: 0.6,
136
+ metalness: 0.10, roughness: 0.40
137
+ })
138
+ const headlightMat = new THREE.MeshStandardMaterial({
139
+ color: COLOR_HEADLIGHT, emissive: COLOR_HEADLIGHT, emissiveIntensity: 0.4,
140
+ metalness: 0.10, roughness: 0.30
141
+ })
142
+
143
+ // ────────────────────────────────────────────────────────────────
144
+ // 1. CHASSIS BODY — sculpted side profile + width via ExtrudeGeometry
145
+ // ────────────────────────────────────────────────────────────────
146
+ const chassisGroup = new THREE.Group()
147
+ chassisGroup.position.set(0, baseY + wheelR, 0) // wheels rest on floor
148
+
149
+ // Side profile (looking from +X, +Z forward, +Y up).
150
+ // Coordinates start at (z, y) = (chassisLen/2, 0) — front bottom corner.
151
+ const zFront = chassisLen * 0.5
152
+ const zBack = -chassisLen * 0.5
153
+ const hoodH = chassisH * 0.55
154
+ const stepZ = chassisLen * 0.10
155
+ const seatH = chassisH * 0.85
156
+
157
+ const sideShape = new THREE.Shape()
158
+ sideShape.moveTo(zFront, 0)
159
+ sideShape.lineTo(zFront, hoodH) // up to hood top
160
+ sideShape.lineTo(stepZ * 1.2, hoodH) // forward hood flat
161
+ sideShape.lineTo(stepZ * 0.4, seatH) // sloped step up to seat platform
162
+ sideShape.lineTo(zBack + stepZ, seatH) // platform flat to rear
163
+ sideShape.lineTo(zBack, seatH * 1.05) // tiny lip into counterweight
164
+ sideShape.lineTo(zBack, 0) // down to ground
165
+ sideShape.lineTo(zFront, 0)
166
+
167
+ const chassisGeo = new THREE.ExtrudeGeometry(sideShape, {
168
+ depth: width * 0.78,
169
+ bevelEnabled: true,
170
+ bevelSegments: 2,
171
+ bevelSize: chassisH * 0.04,
172
+ bevelThickness: chassisH * 0.04,
173
+ curveSegments: 6
174
+ })
175
+ chassisGeo.rotateY(Math.PI / 2)
176
+ chassisGeo.translate(width * 0.39, 0, 0)
177
+ const chassisMesh = new THREE.Mesh(chassisGeo, bodyMat)
178
+ chassisMesh.castShadow = true
179
+ chassisMesh.receiveShadow = true
180
+ chassisGroup.add(chassisMesh)
181
+
182
+ // Side accent panel (slightly recessed darker panel along the hood)
183
+ for (const xSign of [-1, 1]) {
184
+ const panelGeo = new RoundedBoxGeometry(width * 0.005, hoodH * 0.55, chassisLen * 0.6, 1, hoodH * 0.06)
185
+ const panelMesh = new THREE.Mesh(panelGeo, bodyShadeMat)
186
+ panelMesh.position.set(xSign * width * 0.395, hoodH * 0.45, stepZ * 0.4)
187
+ chassisGroup.add(panelMesh)
188
+ }
189
+
190
+ // Status pinstripe — emissive thin band above the panel, status color
191
+ const pinMat = new THREE.MeshStandardMaterial({
192
+ color: lampEmissive, emissive: lampEmissive,
193
+ emissiveIntensity: Math.max(lampIntensity * 0.3, 0.08),
194
+ metalness: 0.20, roughness: 0.40
195
+ })
196
+ for (const xSign of [-1, 1]) {
197
+ const pinGeo = new THREE.BoxGeometry(width * 0.006, hoodH * 0.04, chassisLen * 0.55)
198
+ const pinMesh = new THREE.Mesh(pinGeo, pinMat)
199
+ pinMesh.position.set(xSign * width * 0.40, hoodH * 0.78, stepZ * 0.4)
200
+ chassisGroup.add(pinMesh)
201
+ }
202
+
203
+ // Headlights (front face of hood, two small emissive blocks)
204
+ for (const xSign of [-1, 1]) {
205
+ const hlGeo = new RoundedBoxGeometry(width * 0.10, hoodH * 0.18, chassisH * 0.05, 2, hoodH * 0.04)
206
+ const hlMesh = new THREE.Mesh(hlGeo, headlightMat)
207
+ hlMesh.position.set(xSign * width * 0.21, hoodH * 0.60, zFront + chassisH * 0.025)
208
+ chassisGroup.add(hlMesh)
209
+ }
210
+
211
+ this.object3d.add(chassisGroup)
212
+
213
+ // ────────────────────────────────────────────────────────────────
214
+ // 2. COUNTERWEIGHT — sculpted rear mass (tapered, distinct from chassis)
215
+ // ────────────────────────────────────────────────────────────────
216
+ const cwGroup = new THREE.Group()
217
+ const cwZ = zBack - cwD * 0.5
218
+
219
+ // Base — wide, rectangular with rounded corners, sits low
220
+ const cwBaseGeo = new RoundedBoxGeometry(width * 0.96, cwH * 0.70, cwD, 3, cwH * 0.05)
221
+ const cwBaseMesh = new THREE.Mesh(cwBaseGeo, cwMat)
222
+ cwBaseMesh.position.set(0, baseY + wheelR + cwH * 0.35, cwZ)
223
+ cwBaseMesh.castShadow = true
224
+ cwGroup.add(cwBaseMesh)
225
+
226
+ // Top — narrower, slightly forward — gives the chamfered cast-iron look
227
+ const cwTopGeo = new RoundedBoxGeometry(width * 0.82, cwH * 0.32, cwD * 0.85, 3, cwH * 0.04)
228
+ const cwTopMat = new THREE.MeshStandardMaterial({
229
+ color: new THREE.Color(COLOR_COUNTERWEIGHT).multiplyScalar(1.20),
230
+ metalness: 0.55, roughness: 0.65
231
+ })
232
+ const cwTopMesh = new THREE.Mesh(cwTopGeo, cwTopMat)
233
+ cwTopMesh.position.set(0, baseY + wheelR + cwH * 0.70 + cwH * 0.16, cwZ + cwD * 0.05)
234
+ cwTopMesh.castShadow = true
235
+ cwGroup.add(cwTopMesh)
236
+
237
+ // Tail lights (rear face of cw base)
238
+ for (const xSign of [-1, 1]) {
239
+ const tailGeo = new RoundedBoxGeometry(width * 0.10, cwH * 0.10, cwD * 0.04, 1, cwD * 0.02)
240
+ const tailMesh = new THREE.Mesh(tailGeo, tailMat)
241
+ tailMesh.position.set(xSign * width * 0.36, baseY + wheelR + cwH * 0.50, cwZ - cwD * 0.50)
242
+ cwGroup.add(tailMesh)
243
+ }
244
+
245
+ this.object3d.add(cwGroup)
246
+
247
+ // ────────────────────────────────────────────────────────────────
248
+ // 3. CAB — open frame: pillars + roof + seat + steering
249
+ // ────────────────────────────────────────────────────────────────
250
+ const cabGroup = new THREE.Group()
251
+ const cabRoofY = cabFloorY + cabH
252
+ const pillarT = width * 0.025
253
+ const pillarPositions: [number, number][] = [
254
+ [width * 0.27, height * 0.05], // front-right
255
+ [width * 0.27, -height * 0.28], // rear-right
256
+ [-width * 0.27, height * 0.05],
257
+ [-width * 0.27, -height * 0.28]
258
+ ]
259
+
260
+ const pillarGeos: THREE.BufferGeometry[] = []
261
+ for (const [x, z] of pillarPositions) {
262
+ const g = new RoundedBoxGeometry(pillarT, cabH, pillarT, 1, pillarT * 0.2)
263
+ g.translate(x, cabFloorY + cabH / 2, z)
264
+ pillarGeos.push(g)
265
+ }
266
+ const pillarsMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(pillarGeos), bodyShadeMat)
267
+ pillarsMesh.castShadow = true
268
+ cabGroup.add(pillarsMesh)
269
+
270
+ // Roof panel with overhang
271
+ const cabRoofGeo = new RoundedBoxGeometry(width * 0.62, depth * 0.020, height * 0.40, 2, width * 0.02)
272
+ const cabRoofMesh = new THREE.Mesh(cabRoofGeo, bodyShadeMat)
273
+ cabRoofMesh.position.set(0, cabRoofY + depth * 0.010, -height * 0.115)
274
+ cabRoofMesh.castShadow = true
275
+ cabGroup.add(cabRoofMesh)
276
+
277
+ // Operator seat
278
+ const seatGeo = new RoundedBoxGeometry(width * 0.28, cabH * 0.40, height * 0.20, 2, width * 0.03)
279
+ const seatMesh = new THREE.Mesh(seatGeo, seatMat)
280
+ seatMesh.position.set(0, cabFloorY + cabH * 0.22, -height * 0.18)
281
+ cabGroup.add(seatMesh)
282
+
283
+ // Backrest
284
+ const backGeo = new RoundedBoxGeometry(width * 0.28, cabH * 0.65, height * 0.04, 2, width * 0.02)
285
+ const backMesh = new THREE.Mesh(backGeo, seatMat)
286
+ backMesh.position.set(0, cabFloorY + cabH * 0.55, -height * 0.27)
287
+ cabGroup.add(backMesh)
288
+
289
+ // Steering column + wheel
290
+ const steeringMat = new THREE.MeshStandardMaterial({
291
+ color: COLOR_STEERING, metalness: 0.4, roughness: 0.55
292
+ })
293
+ const colGeo = new THREE.CylinderGeometry(width * 0.014, width * 0.020, cabH * 0.55, 16)
294
+ colGeo.rotateX(-Math.PI * 0.12)
295
+ const colMesh = new THREE.Mesh(colGeo, steeringMat)
296
+ colMesh.position.set(0, cabFloorY + cabH * 0.30, -height * 0.05)
297
+ cabGroup.add(colMesh)
298
+ const swGeo = new THREE.TorusGeometry(width * 0.10, width * 0.013, 8, 24)
299
+ swGeo.rotateX(Math.PI / 2 - Math.PI * 0.12)
300
+ const swMesh = new THREE.Mesh(swGeo, steeringMat)
301
+ swMesh.position.set(0, cabFloorY + cabH * 0.55, height * 0.02)
302
+ cabGroup.add(swMesh)
303
+
304
+ this.object3d.add(cabGroup)
305
+
306
+ // ────────────────────────────────────────────────────────────────
307
+ // 4. OVERHEAD GUARD — ROPS structure above cab
308
+ // ────────────────────────────────────────────────────────────────
309
+ const guardGroup = new THREE.Group()
310
+ const guardPostT = width * 0.024
311
+ const guardX = width * 0.27
312
+ const guardZF = height * 0.05
313
+ const guardZB = -height * 0.38
314
+ const guardPostY = cabRoofY + guardH / 2
315
+
316
+ const guardGeos: THREE.BufferGeometry[] = []
317
+ for (const xSign of [-1, 1]) {
318
+ for (const z of [guardZF, guardZB]) {
319
+ const g = new THREE.BoxGeometry(guardPostT, guardH, guardPostT)
320
+ g.translate(xSign * guardX, guardPostY, z)
321
+ guardGeos.push(g)
322
+ }
323
+ }
324
+ // Roof grid (3 lateral + 3 longitudinal bars)
325
+ const roofY = guardPostY + guardH / 2 + guardPostT / 2
326
+ const roofZSpan = guardZF - guardZB
327
+ for (const xFrac of [-1, 0, 1]) {
328
+ const g = new THREE.BoxGeometry(guardPostT * 0.7, guardPostT * 0.7, roofZSpan + guardPostT)
329
+ g.translate(xFrac * guardX, roofY, (guardZF + guardZB) / 2)
330
+ guardGeos.push(g)
331
+ }
332
+ for (const zFrac of [-1, 0, 1]) {
333
+ const g = new THREE.BoxGeometry(2 * guardX + guardPostT, guardPostT * 0.7, guardPostT * 0.7)
334
+ g.translate(0, roofY, (guardZF + guardZB) / 2 + zFrac * roofZSpan * 0.5)
335
+ guardGeos.push(g)
336
+ }
337
+ const guardMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(guardGeos), guardMat)
338
+ guardMesh.castShadow = true
339
+ guardGroup.add(guardMesh)
340
+
341
+ // Side mirrors (small disks on bracket arms from guard front posts)
342
+ const mirrorMat = new THREE.MeshStandardMaterial({
343
+ color: COLOR_WINDOW, metalness: 0.85, roughness: 0.10
344
+ })
345
+ for (const xSign of [-1, 1]) {
346
+ const bracketGeo = new THREE.BoxGeometry(width * 0.025, guardPostT * 0.5, guardPostT * 0.5)
347
+ const bracketMesh = new THREE.Mesh(bracketGeo, guardMat)
348
+ bracketMesh.position.set(xSign * (guardX + width * 0.0125), cabRoofY + guardH * 0.65, guardZF)
349
+ guardGroup.add(bracketMesh)
350
+
351
+ const mirGeo = new THREE.CylinderGeometry(width * 0.04, width * 0.04, width * 0.008, 16)
352
+ mirGeo.rotateZ(Math.PI / 2)
353
+ const mirMesh = new THREE.Mesh(mirGeo, mirrorMat)
354
+ mirMesh.position.set(xSign * (guardX + width * 0.05), cabRoofY + guardH * 0.65, guardZF)
355
+ guardGroup.add(mirMesh)
356
+ }
357
+
358
+ this.object3d.add(guardGroup)
359
+
360
+ // ────────────────────────────────────────────────────────────────
361
+ // 5. MAST ASSEMBLY — rails + crossbar + hydraulic + chains + carriage + forks
362
+ // ────────────────────────────────────────────────────────────────
363
+ const mastGroup = new THREE.Group()
364
+ const mastH = depth * 0.95
365
+ const mastRailW = width * 0.06
366
+ const mastRailD = height * 0.03
367
+ const mastSpacing = width * 0.45
368
+ const mastZ = height * 0.42
369
+ const mastY = baseY + wheelR + mastH / 2
370
+
371
+ // Two main rails (rounded for visual softness)
372
+ for (const xSign of [-1, 1]) {
373
+ const railGeo = new RoundedBoxGeometry(mastRailW, mastH, mastRailD, 2, mastRailW * 0.20)
374
+ const railMesh = new THREE.Mesh(railGeo, mastMat)
375
+ railMesh.position.set((xSign * mastSpacing) / 2, mastY, mastZ)
376
+ railMesh.castShadow = true
377
+ mastGroup.add(railMesh)
378
+ }
379
+
380
+ // Crossbar at top
381
+ const crossbarGeo = new RoundedBoxGeometry(
382
+ mastSpacing + mastRailW, mastRailD * 1.3, mastRailD * 1.1, 2, mastRailW * 0.15
383
+ )
384
+ const crossbarMesh = new THREE.Mesh(crossbarGeo, mastMat)
385
+ crossbarMesh.position.set(0, baseY + wheelR + mastH, mastZ)
386
+ crossbarMesh.castShadow = true
387
+ mastGroup.add(crossbarMesh)
388
+
389
+ // Hydraulic cylinder (center, between rails)
390
+ const hydCylR = mastRailW * 0.42
391
+ const hydCylH = mastH * 0.78
392
+ const hydCylGeo = new THREE.CylinderGeometry(hydCylR, hydCylR * 1.05, hydCylH, 20)
393
+ const hydCylMesh = new THREE.Mesh(hydCylGeo, hydraulicMat)
394
+ hydCylMesh.position.set(0, baseY + wheelR + hydCylH / 2 + mastRailD * 0.5, mastZ - mastRailD * 0.15)
395
+ hydCylMesh.castShadow = true
396
+ mastGroup.add(hydCylMesh)
397
+
398
+ // Piston rod (length tracks forkHeight)
399
+ const pistonR = hydCylR * 0.55
400
+ const pistonH = hydCylH * 0.45 + forkHeight * 0.7
401
+ const pistonGeo = new THREE.CylinderGeometry(pistonR, pistonR, pistonH, 16)
402
+ const pistonMesh = new THREE.Mesh(pistonGeo, hydraulicMat)
403
+ pistonMesh.position.set(
404
+ 0,
405
+ baseY + wheelR + hydCylH + pistonH / 2 + mastRailD * 0.5,
406
+ mastZ - mastRailD * 0.15
407
+ )
408
+ mastGroup.add(pistonMesh)
409
+
410
+ // Lift chains (parallel link bars between rails)
411
+ for (const xSign of [-1, 1]) {
412
+ const chW = mastRailW * 0.18
413
+ const chH = mastH * 0.55
414
+ const chGeo = new THREE.BoxGeometry(chW, chH, mastRailD * 0.3)
415
+ const chMesh = new THREE.Mesh(chGeo, chainMat)
416
+ chMesh.position.set(xSign * mastSpacing * 0.30, baseY + wheelR + chH / 2 + mastRailD * 0.5, mastZ - mastRailD * 0.4)
417
+ mastGroup.add(chMesh)
418
+ }
419
+
420
+ // Carriage (sliding plate)
421
+ const carriageY = baseY + wheelR + forkHeight + cwH * 0.05
422
+ const carriageW = mastSpacing + mastRailW
423
+ const carriageH = chassisH * 0.40
424
+ const carriageD = mastRailD * 0.7
425
+ const carriageGeo = new RoundedBoxGeometry(carriageW, carriageH, carriageD, 2, carriageH * 0.10)
426
+ const carriageMesh = new THREE.Mesh(carriageGeo, forkMat)
427
+ carriageMesh.position.set(0, carriageY + carriageH / 2, mastZ + mastRailD)
428
+ carriageMesh.castShadow = true
429
+ mastGroup.add(carriageMesh)
430
+
431
+ // Forks (L-shape via ExtrudeGeometry — heel + horizontal blade)
432
+ const forkLen = height * 0.42
433
+ const forkW = width * 0.10
434
+ const forkH = depth * 0.018
435
+ const forkBladeY = carriageY + forkH / 2
436
+ for (const xSign of [-1, 1]) {
437
+ const heelH = carriageH * 0.85
438
+ const fs = new THREE.Shape()
439
+ fs.moveTo(0, 0)
440
+ fs.lineTo(forkLen, 0)
441
+ fs.lineTo(forkLen, forkH * 0.5)
442
+ fs.lineTo(forkH, forkH * 1.0)
443
+ fs.lineTo(forkH, heelH)
444
+ fs.lineTo(0, heelH)
445
+ fs.lineTo(0, 0)
446
+ const forkGeo = new THREE.ExtrudeGeometry(fs, {
447
+ depth: forkW,
448
+ bevelEnabled: true,
449
+ bevelSegments: 1,
450
+ bevelSize: forkH * 0.18,
451
+ bevelThickness: forkH * 0.18
452
+ })
453
+ forkGeo.rotateY(-Math.PI / 2)
454
+ forkGeo.translate(
455
+ xSign * mastSpacing * 0.45 + forkW / 2,
456
+ forkBladeY,
457
+ mastZ + mastRailD + carriageD
458
+ )
459
+ const forkMesh = new THREE.Mesh(forkGeo, forkMat)
460
+ forkMesh.castShadow = true
461
+ mastGroup.add(forkMesh)
462
+ }
463
+
464
+ this.object3d.add(mastGroup)
465
+
466
+ // ────────────────────────────────────────────────────────────────
467
+ // 6. WHEELS — tire + rim disc + spokes (drive front, caster rear)
468
+ // ────────────────────────────────────────────────────────────────
469
+ const wheelW = depth * 0.04
470
+ const wheelGroup = new THREE.Group()
471
+
472
+ const tireGeos: THREE.BufferGeometry[] = []
473
+ const rimGeos: THREE.BufferGeometry[] = []
474
+ const hubGeos: THREE.BufferGeometry[] = []
475
+
476
+ // Front (drive) wheels — slightly larger
477
+ for (const xSign of [-1, 1]) {
478
+ const cx = xSign * width * 0.42
479
+ const cz = height * 0.30
480
+ const r = wheelR * 1.05
481
+
482
+ const tire = new THREE.CylinderGeometry(r, r, wheelW, 24)
483
+ tire.rotateZ(Math.PI / 2)
484
+ tire.translate(cx, baseY + r, cz)
485
+ tireGeos.push(tire)
486
+
487
+ const rim = new THREE.CylinderGeometry(r * 0.55, r * 0.55, wheelW * 0.85, 18)
488
+ rim.rotateZ(Math.PI / 2)
489
+ rim.translate(cx + xSign * wheelW * 0.10, baseY + r, cz)
490
+ rimGeos.push(rim)
491
+
492
+ // Spokes (5 cross-bars on the rim front face)
493
+ for (let i = 0; i < 5; i++) {
494
+ const angle = (i / 5) * Math.PI * 2
495
+ const sp = new THREE.BoxGeometry(wheelW * 0.10, r * 0.10, r * 0.95)
496
+ sp.rotateX(angle)
497
+ sp.translate(cx + xSign * wheelW * 0.50, baseY + r, cz)
498
+ hubGeos.push(sp)
499
+ }
500
+ // Center hub cap
501
+ const cap = new THREE.SphereGeometry(r * 0.22, 14, 10, 0, Math.PI * 2, 0, Math.PI / 2)
502
+ cap.rotateZ(Math.PI / 2 * xSign)
503
+ cap.translate(cx + xSign * wheelW * 0.55, baseY + r, cz)
504
+ hubGeos.push(cap)
505
+ }
506
+
507
+ // Rear (caster) wheels — smaller
508
+ for (const xSign of [-1, 1]) {
509
+ const cx = xSign * width * 0.36
510
+ const cz = -height * 0.32
511
+ const r = wheelR * 0.78
512
+
513
+ const tire = new THREE.CylinderGeometry(r, r, wheelW * 0.85, 20)
514
+ tire.rotateZ(Math.PI / 2)
515
+ tire.translate(cx, baseY + r, cz)
516
+ tireGeos.push(tire)
517
+
518
+ const rim = new THREE.CylinderGeometry(r * 0.55, r * 0.55, wheelW * 0.7, 16)
519
+ rim.rotateZ(Math.PI / 2)
520
+ rim.translate(cx + xSign * wheelW * 0.08, baseY + r, cz)
521
+ rimGeos.push(rim)
522
+ }
523
+
524
+ const tireMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(tireGeos), tireMat)
525
+ tireMesh.castShadow = true
526
+ wheelGroup.add(tireMesh)
527
+
528
+ const rimMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(rimGeos), rimMat)
529
+ rimMesh.castShadow = true
530
+ wheelGroup.add(rimMesh)
531
+
532
+ if (hubGeos.length > 0) {
533
+ const hubMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(hubGeos), hubMat)
534
+ wheelGroup.add(hubMesh)
535
+ }
536
+
537
+ this.object3d.add(wheelGroup)
538
+
539
+ // ────────────────────────────────────────────────────────────────
540
+ // 7. STATUS LAMP — sculpted (base + body + dome) on guard top
541
+ // ────────────────────────────────────────────────────────────────
542
+ const lampGroup = new THREE.Group()
543
+ const lampR = Math.min(width, height) * 0.04
544
+ const lampH = lampR * 1.5
545
+ const lampMat = new THREE.MeshStandardMaterial({
546
+ color: lampEmissive, emissive: lampEmissive,
547
+ emissiveIntensity: lampIntensity,
548
+ metalness: 0.0, roughness: 0.30
549
+ })
550
+
551
+ // Base disc
552
+ const lbGeo = new THREE.CylinderGeometry(lampR * 1.15, lampR * 1.15, lampR * 0.25, 16)
553
+ const lbMesh = new THREE.Mesh(lbGeo, guardMat)
554
+ lbMesh.position.set(0, roofY + guardPostT * 0.5 + lampR * 0.125, (guardZF + guardZB) / 2)
555
+ lampGroup.add(lbMesh)
556
+
557
+ // Body
558
+ const lyGeo = new THREE.CylinderGeometry(lampR, lampR * 0.95, lampH, 16)
559
+ const lyMesh = new THREE.Mesh(lyGeo, lampMat)
560
+ lyMesh.position.set(0, roofY + guardPostT * 0.5 + lampR * 0.25 + lampH / 2, (guardZF + guardZB) / 2)
561
+ lampGroup.add(lyMesh)
562
+
563
+ // Dome
564
+ const ldGeo = new THREE.SphereGeometry(lampR, 16, 8, 0, Math.PI * 2, 0, Math.PI / 2)
565
+ const ldMesh = new THREE.Mesh(ldGeo, lampMat)
566
+ ldMesh.position.set(0, roofY + guardPostT * 0.5 + lampR * 0.25 + lampH, (guardZF + guardZB) / 2)
567
+ lampGroup.add(ldMesh)
568
+
569
+ this.object3d.add(lampGroup)
570
+ }
571
+
572
+ updateDimension() {}
573
+
574
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
575
+ if (
576
+ 'status' in after ||
577
+ 'bodyColor' in after ||
578
+ 'lampEmissive' in after ||
579
+ 'forkHeight' in after ||
580
+ 'width' in after ||
581
+ 'height' in after ||
582
+ 'depth' in after
583
+ ) {
584
+ this.update()
585
+ return
586
+ }
587
+ super.onchange(after, before)
588
+ }
589
+
590
+ updateAlpha() {}
591
+ }