@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
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@operato/scene-transport",
3
+ "description": "Transport-domain components for things-scene (smart factory / logistics) — forklift, worker, AGV.",
4
+ "author": "heartyoh",
5
+ "version": "10.0.0-beta.22",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "license": "MIT",
10
+ "things-scene": true,
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "@operato:registry": "https://registry.npmjs.org"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/things-scene/operato-scene.git",
18
+ "directory": "packages/transport"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepublishOnly": "tsc",
23
+ "lint": "eslint src/ && prettier \"src/**/*.ts\" --check",
24
+ "format": "eslint src/ --fix && prettier \"src/**/*.ts\" --write"
25
+ },
26
+ "dependencies": {
27
+ "@hatiolab/things-scene": "^10.0.0-beta.1",
28
+ "@operato/scene-base": "^10.0.0-beta.22",
29
+ "three": "^0.183.0"
30
+ },
31
+ "devDependencies": {
32
+ "@hatiolab/prettier-config": "^1.0.0",
33
+ "@types/three": "^0.183.0",
34
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
35
+ "@typescript-eslint/parser": "^8.0.0",
36
+ "eslint": "^9.18.0",
37
+ "eslint-config-prettier": "^10.0.1",
38
+ "prettier": "^3.2.5",
39
+ "tslib": "^2.3.1",
40
+ "typescript": "^5.0.4"
41
+ },
42
+ "prettier": "@hatiolab/prettier-config",
43
+ "gitHead": "f48e52f4f5fdc30ec06af9da7cf253f6e29cfb0e"
44
+ }
package/src/agv-3d.ts ADDED
@@ -0,0 +1,283 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Agv 3D — payload (Kiva-style) automated guided vehicle.
5
+ *
6
+ * LO-POLY but with **rounded volume** — the Kiva AGV's signature is a
7
+ * pill-shaped, low-slung chassis with rounded corners, not a flat box.
8
+ *
9
+ * Structure:
10
+ * - rounded-box chassis (volumetric, with bevels — the visual identity)
11
+ * - top deck with raised lift pad at center (Kiva-style under-shelf lift)
12
+ * - LED strip running around the perimeter
13
+ * - safety bumpers (front + rear, hi-vis with black corners)
14
+ * - LiDAR sensor: cylindrical body + transparent dome top
15
+ * - 4 wheel covers (fender bumps integrated into chassis sides)
16
+ * - charging contacts at rear
17
+ * - corner indicator lamps on deck
18
+ */
19
+
20
+ import * as THREE from 'three'
21
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
22
+ import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js'
23
+ import { RealObjectGroup } from '@hatiolab/things-scene'
24
+
25
+ const CHASSIS_COLOR_DARK = 0x444444
26
+ const DECK_COLOR = 0x666677
27
+ const LIFT_PAD_COLOR = 0x4a4a55
28
+ const TIRE_COLOR = 0x202020
29
+ const WHEEL_COVER_COLOR = 0x3a3a3a
30
+ const LIDAR_BODY = 0x222233
31
+ const LIDAR_DOME = 0x445566
32
+ const BUMPER_COLOR = 0xeeaa00
33
+ const BUMPER_BLACK = 0x111111
34
+ const CHARGE_COPPER = 0xc77a00
35
+
36
+ export class Agv3D extends RealObjectGroup {
37
+ build() {
38
+ super.build()
39
+
40
+ const { width, height, depth = 0 } = this.component.state
41
+ const bodyColor = (this.component.state.bodyColor as string) || '#999'
42
+ const emissiveColor = (this.component.state.lampEmissive as string) || '#222222'
43
+ const status = this.component.state.status
44
+ const lampIntensity = status && status !== 'idle' ? 1.5 : 0.2
45
+
46
+ const chassisH = depth * 0.85
47
+ const deckH = depth * 0.05
48
+ const wheelR = chassisH * 0.30
49
+ const baseY = -depth / 2
50
+
51
+ // ── Rounded chassis (volumetric — the Kiva AGV signature) ────────
52
+ const chassisRadius = Math.min(width, height) * 0.10
53
+ const chassisGeo = new RoundedBoxGeometry(width * 0.95, chassisH, height * 0.95, 4, chassisRadius)
54
+ const chassisMesh = new THREE.Mesh(
55
+ chassisGeo,
56
+ new THREE.MeshStandardMaterial({
57
+ color: CHASSIS_COLOR_DARK,
58
+ metalness: 0.4,
59
+ roughness: 0.5
60
+ })
61
+ )
62
+ chassisMesh.position.set(0, baseY + chassisH / 2, 0)
63
+ chassisMesh.castShadow = true
64
+ chassisMesh.receiveShadow = true
65
+ this.object3d.add(chassisMesh)
66
+
67
+ // ── Top deck (rounded slab) ───────────────────────────────────────
68
+ const deckGeo = new RoundedBoxGeometry(width * 0.95, deckH, height * 0.95, 2, chassisRadius * 0.5)
69
+ const deckMesh = new THREE.Mesh(
70
+ deckGeo,
71
+ new THREE.MeshStandardMaterial({ color: DECK_COLOR, metalness: 0.5, roughness: 0.4 })
72
+ )
73
+ deckMesh.position.set(0, baseY + chassisH + deckH / 2, 0)
74
+ deckMesh.castShadow = true
75
+ deckMesh.receiveShadow = true
76
+ this.object3d.add(deckMesh)
77
+
78
+ // ── Lift mechanism (round pad — Kiva-style under-shelf lift) ─────
79
+ const liftR = Math.min(width, height) * 0.30
80
+ const liftH = deckH * 1.5
81
+ const liftGeo = new THREE.CylinderGeometry(liftR, liftR * 1.05, liftH, 24)
82
+ const liftMesh = new THREE.Mesh(
83
+ liftGeo,
84
+ new THREE.MeshStandardMaterial({ color: LIFT_PAD_COLOR, metalness: 0.6, roughness: 0.35 })
85
+ )
86
+ liftMesh.position.set(0, baseY + chassisH + deckH + liftH / 2, 0)
87
+ liftMesh.castShadow = true
88
+ this.object3d.add(liftMesh)
89
+
90
+ // Body color band on lift rim (status hint)
91
+ const tintH = liftH * 0.3
92
+ const tintGeo = new THREE.CylinderGeometry(liftR * 1.03, liftR * 1.03, tintH, 24, 1, true)
93
+ const tintMesh = new THREE.Mesh(
94
+ tintGeo,
95
+ new THREE.MeshStandardMaterial({
96
+ color: bodyColor,
97
+ side: THREE.DoubleSide,
98
+ transparent: true,
99
+ opacity: 0.7,
100
+ metalness: 0.1,
101
+ roughness: 0.6
102
+ })
103
+ )
104
+ tintMesh.position.set(0, baseY + chassisH + deckH + tintH / 2, 0)
105
+ this.object3d.add(tintMesh)
106
+
107
+ // ── LED strip (4 sides, rounded corners follow chassis bevel) ────
108
+ const stripH = chassisH * 0.10
109
+ const stripT = Math.min(width, height) * 0.015
110
+ const stripY = baseY + chassisH * 0.55
111
+ const stripMaterial = new THREE.MeshStandardMaterial({
112
+ color: emissiveColor,
113
+ emissive: emissiveColor,
114
+ emissiveIntensity: lampIntensity,
115
+ metalness: 0.1,
116
+ roughness: 0.4
117
+ })
118
+ for (const zSign of [-1, 1]) {
119
+ const stripGeo = new THREE.BoxGeometry(width * 0.85, stripH, stripT)
120
+ const stripMesh = new THREE.Mesh(stripGeo, stripMaterial)
121
+ stripMesh.position.set(0, stripY, zSign * (height * 0.475 - stripT / 2))
122
+ this.object3d.add(stripMesh)
123
+ }
124
+ for (const xSign of [-1, 1]) {
125
+ const stripGeo = new THREE.BoxGeometry(stripT, stripH, height * 0.85)
126
+ const stripMesh = new THREE.Mesh(stripGeo, stripMaterial)
127
+ stripMesh.position.set(xSign * (width * 0.475 - stripT / 2), stripY, 0)
128
+ this.object3d.add(stripMesh)
129
+ }
130
+
131
+ // ── Safety bumpers (front + rear) ────────────────────────────────
132
+ const bumperH = chassisH * 0.18
133
+ const bumperT = Math.min(width, height) * 0.025
134
+ const bumperY = baseY + bumperH / 2 + wheelR * 0.5
135
+ const bumperMaterial = new THREE.MeshStandardMaterial({
136
+ color: BUMPER_COLOR,
137
+ metalness: 0.1,
138
+ roughness: 0.6
139
+ })
140
+ const bumperBlackMaterial = new THREE.MeshStandardMaterial({
141
+ color: BUMPER_BLACK,
142
+ metalness: 0.2,
143
+ roughness: 0.7
144
+ })
145
+ for (const zSign of [-1, 1]) {
146
+ const yellow = new RoundedBoxGeometry(width * 0.7, bumperH, bumperT, 2, bumperT * 0.4)
147
+ const yMesh = new THREE.Mesh(yellow, bumperMaterial)
148
+ yMesh.position.set(0, bumperY, zSign * (height * 0.475 + bumperT / 2))
149
+ this.object3d.add(yMesh)
150
+ for (const xSign of [-1, 1]) {
151
+ const black = new RoundedBoxGeometry(width * 0.13, bumperH, bumperT, 2, bumperT * 0.4)
152
+ const bMesh = new THREE.Mesh(black, bumperBlackMaterial)
153
+ bMesh.position.set(xSign * (width * 0.7 / 2 + width * 0.065), bumperY, zSign * (height * 0.475 + bumperT / 2))
154
+ this.object3d.add(bMesh)
155
+ }
156
+ }
157
+
158
+ // ── LiDAR sensor (cylinder body + transparent dome) ──────────────
159
+ const lidarR = Math.min(width, height) * 0.10
160
+ const lidarBodyH = lidarR * 0.7
161
+ const lidarBodyGeo = new THREE.CylinderGeometry(lidarR, lidarR * 1.1, lidarBodyH, 16)
162
+ const lidarBodyMesh = new THREE.Mesh(
163
+ lidarBodyGeo,
164
+ new THREE.MeshStandardMaterial({ color: LIDAR_BODY, metalness: 0.6, roughness: 0.4 })
165
+ )
166
+ lidarBodyMesh.position.set(0, baseY + chassisH + lidarBodyH / 2, height * 0.35)
167
+ lidarBodyMesh.castShadow = true
168
+ this.object3d.add(lidarBodyMesh)
169
+
170
+ const domeGeo = new THREE.SphereGeometry(lidarR * 0.95, 24, 16, 0, Math.PI * 2, 0, Math.PI / 2)
171
+ const domeMesh = new THREE.Mesh(
172
+ domeGeo,
173
+ new THREE.MeshStandardMaterial({
174
+ color: LIDAR_DOME,
175
+ metalness: 0.3,
176
+ roughness: 0.2,
177
+ transparent: true,
178
+ opacity: 0.8
179
+ })
180
+ )
181
+ domeMesh.position.set(0, baseY + chassisH + lidarBodyH, height * 0.35)
182
+ this.object3d.add(domeMesh)
183
+
184
+ // Status hint on LiDAR top
185
+ const hintR = lidarR * 0.25
186
+ const hintMesh = new THREE.Mesh(
187
+ new THREE.SphereGeometry(hintR, 24, 8),
188
+ new THREE.MeshStandardMaterial({
189
+ color: emissiveColor,
190
+ emissive: emissiveColor,
191
+ emissiveIntensity: lampIntensity,
192
+ roughness: 0.3
193
+ })
194
+ )
195
+ hintMesh.position.set(0, baseY + chassisH + lidarBodyH + lidarR * 0.85, height * 0.35)
196
+ this.object3d.add(hintMesh)
197
+
198
+ // ── Wheels with covers ───────────────────────────────────────────
199
+ const wheelW = width * 0.07
200
+ const tireMaterial = new THREE.MeshStandardMaterial({ color: TIRE_COLOR, roughness: 0.95 })
201
+ const coverMaterial = new THREE.MeshStandardMaterial({
202
+ color: WHEEL_COVER_COLOR,
203
+ metalness: 0.5,
204
+ roughness: 0.6
205
+ })
206
+
207
+ const tireGeos: THREE.BufferGeometry[] = []
208
+ for (const xSign of [-1, 1]) {
209
+ for (const zSign of [-1, 1]) {
210
+ const tire = new THREE.CylinderGeometry(wheelR, wheelR, wheelW, 24)
211
+ tire.rotateZ(Math.PI / 2)
212
+ tire.translate(xSign * width * 0.42, baseY + wheelR, zSign * height * 0.32)
213
+ tireGeos.push(tire)
214
+ // Rounded fender cover
215
+ const cover = new RoundedBoxGeometry(wheelW * 1.5, wheelR * 1.4, wheelR * 1.6, 2, wheelR * 0.25)
216
+ cover.translate(xSign * (width * 0.42 + wheelW * 0.4), baseY + wheelR * 1.2, zSign * height * 0.32)
217
+ const coverMesh = new THREE.Mesh(cover, coverMaterial)
218
+ coverMesh.castShadow = true
219
+ this.object3d.add(coverMesh)
220
+ }
221
+ }
222
+ const tireMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(tireGeos), tireMaterial)
223
+ tireMesh.castShadow = true
224
+ this.object3d.add(tireMesh)
225
+
226
+ // ── Charging contacts (rear) ─────────────────────────────────────
227
+ const chargeMaterial = new THREE.MeshStandardMaterial({
228
+ color: CHARGE_COPPER,
229
+ metalness: 0.9,
230
+ roughness: 0.2
231
+ })
232
+ const chargeT = wheelR * 0.15
233
+ const chargeH = wheelR * 0.6
234
+ const chargeW = width * 0.08
235
+ for (const xSign of [-1, 1]) {
236
+ const chargeGeo = new RoundedBoxGeometry(chargeW, chargeH, chargeT, 1, chargeT * 0.3)
237
+ const chargeMesh = new THREE.Mesh(chargeGeo, chargeMaterial)
238
+ chargeMesh.position.set(xSign * width * 0.10, baseY + chargeH / 2 + wheelR * 0.2, -height * 0.475 - chargeT / 2)
239
+ this.object3d.add(chargeMesh)
240
+ }
241
+
242
+ // ── Corner indicator lamps ───────────────────────────────────────
243
+ const cornerR = Math.min(width, height) * 0.022
244
+ for (const xSign of [-1, 1]) {
245
+ for (const zSign of [-1, 1]) {
246
+ const cornerMesh = new THREE.Mesh(
247
+ new THREE.SphereGeometry(cornerR, 24, 6),
248
+ new THREE.MeshStandardMaterial({
249
+ color: emissiveColor,
250
+ emissive: emissiveColor,
251
+ emissiveIntensity: lampIntensity * 0.6,
252
+ roughness: 0.3
253
+ })
254
+ )
255
+ cornerMesh.position.set(
256
+ xSign * (width * 0.42),
257
+ baseY + chassisH + deckH + cornerR * 0.6,
258
+ zSign * (height * 0.42)
259
+ )
260
+ this.object3d.add(cornerMesh)
261
+ }
262
+ }
263
+ }
264
+
265
+ updateDimension() {}
266
+
267
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
268
+ if (
269
+ 'status' in after ||
270
+ 'bodyColor' in after ||
271
+ 'lampEmissive' in after ||
272
+ 'width' in after ||
273
+ 'height' in after ||
274
+ 'depth' in after
275
+ ) {
276
+ this.update()
277
+ return
278
+ }
279
+ super.onchange(after, before)
280
+ }
281
+
282
+ updateAlpha() {}
283
+ }
package/src/agv.ts ADDED
@@ -0,0 +1,207 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { Component, ComponentNature, Container, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
+ import {
6
+ Legendable,
7
+ Placeable,
8
+ type Alignment,
9
+ type Heights,
10
+ type LegendBinding,
11
+ type PlacementArchetype
12
+ } from '@operato/scene-base'
13
+
14
+ import { Agv3D } from './agv-3d.js'
15
+
16
+ /**
17
+ * Agv status — common to both payload and towing AGVs (kept narrow on purpose).
18
+ *
19
+ * - `idle` — parked, awaiting task
20
+ * - `moving` — driving along a path / executing transport
21
+ * - `charging` — at a charging dock / battery station
22
+ * - `error` — fault / blocked / e-stop
23
+ *
24
+ * Loaded-vs-empty distinction is *not* in the status enum because for a
25
+ * Kiva-style payload AGV it's already obvious from the children (cargo
26
+ * components) parented to it — duplicating that as a status flag would
27
+ * invite drift.
28
+ */
29
+ export type AgvStatus = 'idle' | 'moving' | 'charging' | 'error'
30
+
31
+ /**
32
+ * Body color — neutral industrial gray base, slightly modulated by status.
33
+ * AGVs typically have status-color LED strips rather than full body color
34
+ * change; the body legend stays subtle so the LED strip + lamp do the
35
+ * communicating.
36
+ */
37
+ const BODY_LEGEND = {
38
+ idle: '#999',
39
+ moving: '#aaa',
40
+ charging: '#aaa',
41
+ error: '#c66',
42
+ default: '#999'
43
+ }
44
+
45
+ /**
46
+ * LED strip emissive — the dominant status indicator for AGVs (typically a
47
+ * color-band running around the chassis perimeter).
48
+ */
49
+ const LAMP_EMISSIVE_LEGEND = {
50
+ idle: '#222222',
51
+ moving: '#44ff44', // green (operating)
52
+ charging: '#ffaa00', // amber (charging)
53
+ error: '#ff3333', // red
54
+ default: '#222222'
55
+ }
56
+
57
+ const NATURE: ComponentNature = {
58
+ mutable: false,
59
+ resizable: true,
60
+ rotatable: true,
61
+ properties: [
62
+ {
63
+ type: 'select',
64
+ label: 'status',
65
+ name: 'status',
66
+ property: {
67
+ options: [
68
+ { display: 'Idle', value: 'idle' },
69
+ { display: 'Moving', value: 'moving' },
70
+ { display: 'Charging', value: 'charging' },
71
+ { display: 'Error', value: 'error' }
72
+ ]
73
+ }
74
+ },
75
+ {
76
+ type: 'number',
77
+ label: 'battery',
78
+ name: 'battery',
79
+ placeholder: '0..1'
80
+ }
81
+ ],
82
+ help: 'scene/component/agv'
83
+ }
84
+
85
+ const Base = Legendable(Placeable(Container)) as unknown as typeof Component
86
+
87
+ /**
88
+ * Agv — payload (unit-load) automated guided vehicle. The Kiva-style flat-deck
89
+ * AGV that drives under or carries cargo to/from operation surfaces.
90
+ *
91
+ * **Container-based for cargo containment.** A payload Agv has a flat top
92
+ * deck whose surface is at the scene's operation height. Boxes, parcels,
93
+ * loaded pallets (anything with `placement: 'operation'`) can be added as
94
+ * children — when they are, their natural archetype-derived zPos puts them
95
+ * exactly on the AGV's deck (since AGV depth = operation - floor).
96
+ *
97
+ * For the towing variant (no cargo deck, pulls trailers behind), see Tugger.
98
+ */
99
+ @sceneComponent('agv')
100
+ export default class Agv extends Base {
101
+ static legends: Record<string, LegendBinding> = {
102
+ bodyColor: { from: 'status', legend: BODY_LEGEND },
103
+ lampEmissive: { from: 'status', legend: LAMP_EMISSIVE_LEGEND }
104
+ }
105
+
106
+ /**
107
+ * AGV sits on its wheels — `floor` archetype. Default depth = operation,
108
+ * so the top deck lands at the scene's operation height. This is the
109
+ * design point of payload AGVs: the deck height matches conveyor belt
110
+ * height, equipment ports, and forklift fork height — cargo transfers
111
+ * across all of them at the same level.
112
+ */
113
+ static placement: PlacementArchetype = 'floor'
114
+ static align: Alignment = 'bottom'
115
+ static defaultDepth = (h: Heights) => h.operation - h.floor
116
+
117
+ get nature() {
118
+ return NATURE
119
+ }
120
+
121
+ get anchors() {
122
+ return []
123
+ }
124
+
125
+ /** Accept logistics packages (placement='operation') as deck cargo. */
126
+ containable(component: Component) {
127
+ const archetype = (component.constructor as any).placement
128
+ if (archetype === 'operation') return true
129
+ return component.isDescendible(this as any)
130
+ }
131
+
132
+ /**
133
+ * 2D — render() sets up the rounded chassis path; the framework fills it
134
+ * with `fillStyle` (= bodyColor from Legendable) and strokes with
135
+ * `strokeStyle`. Additional top-down details (bumpers, LiDAR, lift pad,
136
+ * direction triangle) are drawn in `postrender()`.
137
+ */
138
+ render(ctx: CanvasRenderingContext2D) {
139
+ const { width, height, left, top } = this.state
140
+ const radius = Math.min(width, height) * 0.12
141
+ ctx.beginPath()
142
+ ctx.roundRect(left, top, width, height, radius)
143
+ }
144
+
145
+ /** Top-view accent details — bumpers, lift pad, LiDAR, direction triangle. */
146
+ postrender(ctx: CanvasRenderingContext2D) {
147
+ super.postrender?.(ctx)
148
+
149
+ const { width, height, left, top } = this.state
150
+ const cx = left + width / 2
151
+ const cy = top + height / 2
152
+ const accentColor = (this.state.lampEmissive as string) || '#44ff44'
153
+
154
+ ctx.save()
155
+
156
+ // Hi-vis bumper strips (front + rear)
157
+ const bumperT = Math.min(width, height) * 0.06
158
+ ctx.fillStyle = '#eeaa00'
159
+ ctx.fillRect(left + width * 0.15, top, width * 0.7, bumperT)
160
+ ctx.fillRect(left + width * 0.15, top + height - bumperT, width * 0.7, bumperT)
161
+ ctx.fillStyle = '#111'
162
+ ctx.fillRect(left + width * 0.02, top, width * 0.13, bumperT)
163
+ ctx.fillRect(left + width * 0.85, top, width * 0.13, bumperT)
164
+ ctx.fillRect(left + width * 0.02, top + height - bumperT, width * 0.13, bumperT)
165
+ ctx.fillRect(left + width * 0.85, top + height - bumperT, width * 0.13, bumperT)
166
+
167
+ // Lift pad (center circle — Kiva-style under-shelf lift)
168
+ const padR = Math.min(width, height) * 0.22
169
+ ctx.fillStyle = '#4a4a55'
170
+ ctx.strokeStyle = '#222'
171
+ ctx.lineWidth = 1
172
+ ctx.beginPath()
173
+ ctx.ellipse(cx, cy, padR, padR, 0, 0, Math.PI * 2)
174
+ ctx.fill()
175
+ ctx.stroke()
176
+
177
+ // LiDAR sensor (small filled circle near front)
178
+ const lidarR = Math.min(width, height) * 0.07
179
+ ctx.fillStyle = '#222233'
180
+ ctx.beginPath()
181
+ ctx.ellipse(cx, top + height * 0.20, lidarR, lidarR, 0, 0, Math.PI * 2)
182
+ ctx.fill()
183
+ ctx.stroke()
184
+ // Status hint on LiDAR
185
+ ctx.fillStyle = accentColor
186
+ ctx.beginPath()
187
+ ctx.ellipse(cx, top + height * 0.20, lidarR * 0.4, lidarR * 0.4, 0, 0, Math.PI * 2)
188
+ ctx.fill()
189
+
190
+ // Direction-of-travel triangle
191
+ ctx.fillStyle = accentColor
192
+ ctx.strokeStyle = '#111'
193
+ ctx.beginPath()
194
+ ctx.moveTo(cx, top + height * 0.06)
195
+ ctx.lineTo(cx - width * 0.06, top + height * 0.13)
196
+ ctx.lineTo(cx + width * 0.06, top + height * 0.13)
197
+ ctx.closePath()
198
+ ctx.fill()
199
+ ctx.stroke()
200
+
201
+ ctx.restore()
202
+ }
203
+
204
+ buildRealObject(): RealObject | undefined {
205
+ return new Agv3D(this as any)
206
+ }
207
+ }