@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,200 @@
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 { Forklift3D } from './forklift-3d.js'
15
+
16
+ /**
17
+ * Forklift status — the operating state of a forklift truck.
18
+ *
19
+ * - `idle` — parked, engine off
20
+ * - `running` — driving (no load)
21
+ * - `lifting` — actively lifting / lowering forks
22
+ * - `loaded` — driving with cargo on forks
23
+ * - `error` — fault / emergency stop
24
+ *
25
+ * The 5-state set differs from Conveyor's because forklifts have a distinct
26
+ * loaded-vs-empty distinction relevant for fleet visualization.
27
+ */
28
+ export type ForkliftStatus = 'idle' | 'running' | 'lifting' | 'loaded' | 'error'
29
+
30
+ /** Body color — yellow base hue, modulated slightly by status for at-a-glance state. */
31
+ const BODY_LEGEND = {
32
+ idle: '#d4a017', // muted yellow (parked)
33
+ running: '#FFD700', // bright yellow (active)
34
+ lifting: '#FFD700',
35
+ loaded: '#FFA500', // orange tint (carrying)
36
+ error: '#e9746b', // red
37
+ default: '#d4a017'
38
+ }
39
+
40
+ /** Status lamp emissive — saturated for at-a-glance status from a distance. */
41
+ const LAMP_EMISSIVE_LEGEND = {
42
+ idle: '#333333',
43
+ running: '#44ff44', // green (operating)
44
+ lifting: '#44aaff', // blue (lifting)
45
+ loaded: '#ffaa00', // amber (loaded)
46
+ error: '#ff3333', // red
47
+ default: '#333333'
48
+ }
49
+
50
+ const NATURE: ComponentNature = {
51
+ mutable: false,
52
+ resizable: true,
53
+ rotatable: true,
54
+ properties: [
55
+ {
56
+ type: 'select',
57
+ label: 'status',
58
+ name: 'status',
59
+ property: {
60
+ options: [
61
+ { display: 'Idle', value: 'idle' },
62
+ { display: 'Running', value: 'running' },
63
+ { display: 'Lifting', value: 'lifting' },
64
+ { display: 'Loaded', value: 'loaded' },
65
+ { display: 'Error', value: 'error' }
66
+ ]
67
+ }
68
+ },
69
+ {
70
+ type: 'number',
71
+ label: 'fork-height',
72
+ name: 'forkHeight',
73
+ placeholder: '0..2000mm'
74
+ }
75
+ ],
76
+ help: 'scene/component/forklift'
77
+ }
78
+
79
+ const Base = Legendable(Placeable(Container)) as unknown as typeof Component
80
+
81
+ /**
82
+ * Forklift — a powered industrial truck used to lift and transport material
83
+ * over short distances.
84
+ *
85
+ * **Container-based for cargo containment.** A Forklift extends `Container` so
86
+ * that loaded items (boxes, parcels, pallets) can be added as children. The
87
+ * intended visual semantics: the children's 3D representations should appear
88
+ * sitting on the forks, at a height determined by `state.forkHeight`. The
89
+ * actual *positioning* of cargo on the forks is a v2 concern — for now,
90
+ * children render with their declared archetype's zPos (operation level by
91
+ * default), which approximates the fork-down position but won't track
92
+ * `forkHeight` dynamically. See `forklift-3d.ts` for the fork attach point.
93
+ */
94
+ @sceneComponent('forklift')
95
+ export default class Forklift extends Base {
96
+ static legends: Record<string, LegendBinding> = {
97
+ bodyColor: { from: 'status', legend: BODY_LEGEND },
98
+ lampEmissive: { from: 'status', legend: LAMP_EMISSIVE_LEGEND }
99
+ }
100
+
101
+ /**
102
+ * Forklift sits on its wheels — `floor` archetype. Default depth is the
103
+ * forklift's overall envelope with mast collapsed (~= operation level).
104
+ */
105
+ static placement: PlacementArchetype = 'floor'
106
+ static align: Alignment = 'bottom'
107
+ static defaultDepth = (h: Heights) => h.operation - h.floor
108
+
109
+ get nature() {
110
+ return NATURE
111
+ }
112
+
113
+ get anchors() {
114
+ return []
115
+ }
116
+
117
+ /**
118
+ * Allow items that flow at operation level (boxes, cartons, parcels, loaded
119
+ * pallets) to be added as cargo. Restricting by archetype rather than type
120
+ * means new package components are auto-permitted as they're added to the
121
+ * scene-base ecosystem.
122
+ */
123
+ containable(component: Component) {
124
+ const archetype = (component.constructor as any).placement
125
+ if (archetype === 'operation') return true
126
+ // Container's default — accept anything descendible (default things-scene policy).
127
+ return component.isDescendible(this as any)
128
+ }
129
+
130
+ /**
131
+ * 2D — render() sets the body silhouette path (auto-filled with bodyColor
132
+ * via the Legendable→fillStyle chain). postrender() draws structural
133
+ * details (forks, mast bar, counterweight tail-light hint, status lamp).
134
+ */
135
+ render(ctx: CanvasRenderingContext2D) {
136
+ const { width, height, left, top } = this.state
137
+ const forkLen = height * 0.30
138
+ const cwBulge = height * 0.10
139
+ const radius = Math.min(width, height) * 0.10
140
+
141
+ ctx.beginPath()
142
+ // Body with rounded corners + rear counterweight bulge (slight extension at top
143
+ // edge in top-view convention: top = back of forklift)
144
+ ctx.roundRect(left, top - cwBulge, width, height - forkLen + cwBulge, radius)
145
+ }
146
+
147
+ postrender(ctx: CanvasRenderingContext2D) {
148
+ super.postrender?.(ctx)
149
+
150
+ const { width, height, left, top } = this.state
151
+ const forkLen = height * 0.30
152
+ const accentColor = (this.state.lampEmissive as string) || '#44ff44'
153
+
154
+ ctx.save()
155
+
156
+ // Mast (thin horizontal bar at the front of the body, before the forks)
157
+ const mastY = top + height - forkLen
158
+ ctx.fillStyle = '#444455'
159
+ ctx.fillRect(left + width * 0.10, mastY - height * 0.025, width * 0.80, height * 0.025)
160
+
161
+ // Forks (two prongs extending forward — bottom of the top-down silhouette)
162
+ const forkW = width * 0.10
163
+ ctx.fillStyle = '#222233'
164
+ ctx.strokeStyle = '#111'
165
+ ctx.lineWidth = 1
166
+ for (const xFrac of [0.20, 0.70]) {
167
+ ctx.beginPath()
168
+ ctx.rect(left + width * xFrac, mastY, forkW, forkLen)
169
+ ctx.fill()
170
+ ctx.stroke()
171
+ }
172
+
173
+ // Tail lights on counterweight rear (top edge in top-down view)
174
+ ctx.fillStyle = '#ff2222'
175
+ const tailLightW = width * 0.08
176
+ const tailLightH = height * 0.025
177
+ const cwBulge = height * 0.10
178
+ ctx.fillRect(left + width * 0.15, top - cwBulge + tailLightH * 0.5, tailLightW, tailLightH)
179
+ ctx.fillRect(left + width - width * 0.15 - tailLightW, top - cwBulge + tailLightH * 0.5, tailLightW, tailLightH)
180
+
181
+ // Status lamp on top of overhead guard (center)
182
+ const lampR = Math.min(width, height) * 0.06
183
+ ctx.fillStyle = accentColor
184
+ ctx.strokeStyle = '#111'
185
+ ctx.beginPath()
186
+ ctx.ellipse(left + width / 2, top + height * 0.30, lampR, lampR, 0, 0, Math.PI * 2)
187
+ ctx.fill()
188
+ ctx.stroke()
189
+
190
+ ctx.restore()
191
+ }
192
+
193
+ get fillStyle() {
194
+ return (this.state.bodyColor as string) || '#d4a017'
195
+ }
196
+
197
+ buildRealObject(): RealObject | undefined {
198
+ return new Forklift3D(this as any)
199
+ }
200
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ export { default as Forklift } from './forklift.js'
5
+ export type { ForkliftStatus } from './forklift.js'
6
+
7
+ export { default as Worker } from './worker.js'
8
+ export type { WorkerStatus } from './worker.js'
9
+
10
+ export { default as Agv } from './agv.js'
11
+ export type { AgvStatus } from './agv.js'
12
+
13
+ export { default as Tugger } from './tugger.js'
14
+ export type { TuggerStatus } from './tugger.js'
@@ -0,0 +1,73 @@
1
+ /*
2
+ * things-scene catalog templates for the transport domain.
3
+ *
4
+ * Icons reference PNG assets in `packages/transport/icons/`. They are not yet
5
+ * shipped — the catalog will fall back to the default rendering if icons are
6
+ * missing. Add icon files before publishing the catalog publicly.
7
+ */
8
+ const forklift = new URL('../../icons/forklift.png', import.meta.url).href
9
+ const worker = new URL('../../icons/worker.png', import.meta.url).href
10
+ const agv = new URL('../../icons/agv.png', import.meta.url).href
11
+ const tugger = new URL('../../icons/tugger.png', import.meta.url).href
12
+
13
+ export default [
14
+ {
15
+ type: 'forklift',
16
+ description: 'forklift truck',
17
+ group: 'transport',
18
+ icon: forklift,
19
+ model: {
20
+ type: 'forklift',
21
+ top: 100,
22
+ left: 100,
23
+ width: 120,
24
+ height: 200,
25
+ status: 'idle',
26
+ forkHeight: 0
27
+ }
28
+ },
29
+ {
30
+ type: 'worker',
31
+ description: 'human worker',
32
+ group: 'transport',
33
+ icon: worker,
34
+ model: {
35
+ type: 'worker',
36
+ top: 100,
37
+ left: 300,
38
+ width: 50,
39
+ height: 50,
40
+ status: 'idle'
41
+ }
42
+ },
43
+ {
44
+ type: 'agv',
45
+ description: 'payload AGV (unit-load, Kiva-style)',
46
+ group: 'transport',
47
+ icon: agv,
48
+ model: {
49
+ type: 'agv',
50
+ top: 100,
51
+ left: 500,
52
+ width: 200,
53
+ height: 200,
54
+ status: 'idle',
55
+ battery: 1
56
+ }
57
+ },
58
+ {
59
+ type: 'tugger',
60
+ description: 'tugger AGV (towing tractor)',
61
+ group: 'transport',
62
+ icon: tugger,
63
+ model: {
64
+ type: 'tugger',
65
+ top: 100,
66
+ left: 800,
67
+ width: 150,
68
+ height: 250,
69
+ status: 'idle',
70
+ battery: 1
71
+ }
72
+ }
73
+ ]
@@ -0,0 +1,169 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Tugger 3D — towing AGV.
5
+ *
6
+ * LO-POLY but structurally complete:
7
+ *
8
+ * - chassis (compact body, ~600mm tall — no cargo deck)
9
+ * - control panel hump on top (operator-less but houses electronics)
10
+ * - 4 wheels (front pair = drive/steer, larger; rear pair = caster, smaller)
11
+ * - LiDAR sensor at front (forward-facing — detects obstacles)
12
+ * - hitch at rear (the defining feature — what distinguishes a tugger from
13
+ * a payload AGV)
14
+ * - status lamp on top of control panel
15
+ *
16
+ * Design rule: the hitch is the visual signature. Without it, this looks like
17
+ * a tall payload AGV. Make sure the hitch is unambiguously visible.
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 TIRE_COLOR = 0x202020
26
+ const HITCH_COLOR = 0x222233
27
+ const PANEL_COLOR = 0x444455
28
+ const LIDAR_COLOR = 0x222233
29
+
30
+ export class Tugger3D extends RealObjectGroup {
31
+ build() {
32
+ super.build()
33
+
34
+ const { width, height, depth = 600 } = this.component.state
35
+ const bodyColor = (this.component.state.bodyColor as string) || '#666'
36
+ const emissiveColor = (this.component.state.lampEmissive as string) || '#222222'
37
+ const status = this.component.state.status
38
+ const lampIntensity = status && status !== 'idle' ? 1.5 : 0.2
39
+
40
+ // Proportions
41
+ const chassisH = depth * 0.55
42
+ const panelH = depth * 0.30
43
+ const wheelR = chassisH * 0.45
44
+ const baseY = -depth / 2
45
+
46
+ const bodyMaterial = new THREE.MeshStandardMaterial({
47
+ color: bodyColor,
48
+ metalness: 0.4,
49
+ roughness: 0.5
50
+ })
51
+
52
+ // ── Chassis (rounded body) ────────────────────────────────────
53
+ const chassisRadius = Math.min(width, height) * 0.10
54
+ const chassisGeo = new RoundedBoxGeometry(width * 0.9, chassisH, height * 0.85, 4, chassisRadius)
55
+ const chassisMesh = new THREE.Mesh(chassisGeo, bodyMaterial)
56
+ chassisMesh.position.set(0, baseY + chassisH / 2 + wheelR * 0.3, 0)
57
+ chassisMesh.castShadow = true
58
+ chassisMesh.receiveShadow = true
59
+ this.object3d.add(chassisMesh)
60
+
61
+ // ── Control panel (rounded hump on top) ───────────────────────
62
+ const panelGeo = new RoundedBoxGeometry(width * 0.55, panelH, height * 0.5, 3, chassisRadius * 0.6)
63
+ const panelMesh = new THREE.Mesh(
64
+ panelGeo,
65
+ new THREE.MeshStandardMaterial({ color: PANEL_COLOR, metalness: 0.5, roughness: 0.4 })
66
+ )
67
+ panelMesh.position.set(0, baseY + chassisH + panelH / 2 + wheelR * 0.3, 0)
68
+ panelMesh.castShadow = true
69
+ this.object3d.add(panelMesh)
70
+
71
+ // ── LiDAR sensor at front ─────────────────────────────────────
72
+ const lidarR = Math.min(width, height) * 0.07
73
+ const lidarH = lidarR * 0.7
74
+ const lidarGeo = new THREE.CylinderGeometry(lidarR, lidarR * 1.1, lidarH, 24)
75
+ const lidarMesh = new THREE.Mesh(
76
+ lidarGeo,
77
+ new THREE.MeshStandardMaterial({ color: LIDAR_COLOR, metalness: 0.6, roughness: 0.4 })
78
+ )
79
+ lidarMesh.position.set(0, baseY + chassisH + lidarH / 2 + wheelR * 0.3, height * 0.40)
80
+ lidarMesh.castShadow = true
81
+ this.object3d.add(lidarMesh)
82
+
83
+ // ── Hitch at rear (the tugger's signature) ────────────────────
84
+ const hitchW = width * 0.15
85
+ const hitchH = chassisH * 0.30
86
+ const hitchD = height * 0.18
87
+ const hitchMaterial = new THREE.MeshStandardMaterial({
88
+ color: HITCH_COLOR,
89
+ metalness: 0.85,
90
+ roughness: 0.4
91
+ })
92
+
93
+ // Hitch arm (horizontal bar extending rearward)
94
+ const hitchArmGeo = new THREE.BoxGeometry(hitchW * 0.4, hitchH * 0.4, hitchD)
95
+ const hitchArmMesh = new THREE.Mesh(hitchArmGeo, hitchMaterial)
96
+ hitchArmMesh.position.set(0, baseY + wheelR * 0.6, -height * 0.5 - hitchD / 2)
97
+ hitchArmMesh.castShadow = true
98
+ this.object3d.add(hitchArmMesh)
99
+
100
+ // Hitch coupler (the ball/pin where a trailer attaches)
101
+ const couplerR = hitchH * 0.5
102
+ const couplerGeo = new THREE.SphereGeometry(couplerR, 20, 8)
103
+ const couplerMesh = new THREE.Mesh(couplerGeo, hitchMaterial)
104
+ couplerMesh.position.set(0, baseY + wheelR * 0.6 + couplerR * 0.5, -height * 0.5 - hitchD - couplerR * 0.2)
105
+ couplerMesh.castShadow = true
106
+ this.object3d.add(couplerMesh)
107
+
108
+ // ── Wheels — front (larger, drive/steer) and rear (smaller, caster) ─
109
+ const frontWheelR = wheelR
110
+ const rearWheelR = wheelR * 0.7
111
+ const wheelGeometries: THREE.BufferGeometry[] = []
112
+
113
+ // Front pair
114
+ for (const xSign of [-1, 1]) {
115
+ const wheel = new THREE.CylinderGeometry(frontWheelR, frontWheelR, width * 0.08, 20)
116
+ wheel.rotateZ(Math.PI / 2)
117
+ wheel.translate(xSign * width * 0.4, baseY + frontWheelR, height * 0.30)
118
+ wheelGeometries.push(wheel)
119
+ }
120
+ // Rear pair (smaller)
121
+ for (const xSign of [-1, 1]) {
122
+ const wheel = new THREE.CylinderGeometry(rearWheelR, rearWheelR, width * 0.07, 20)
123
+ wheel.rotateZ(Math.PI / 2)
124
+ wheel.translate(xSign * width * 0.35, baseY + rearWheelR, -height * 0.32)
125
+ wheelGeometries.push(wheel)
126
+ }
127
+
128
+ const wheelMesh = new THREE.Mesh(
129
+ BufferGeometryUtils.mergeGeometries(wheelGeometries),
130
+ new THREE.MeshStandardMaterial({ color: TIRE_COLOR, roughness: 0.95 })
131
+ )
132
+ wheelMesh.castShadow = true
133
+ this.object3d.add(wheelMesh)
134
+
135
+ // ── Status lamp on control panel top ──────────────────────────
136
+ const lampR = Math.min(width, height) * 0.05
137
+ const lampH = lampR * 1.3
138
+ const lampMaterial = new THREE.MeshStandardMaterial({
139
+ color: emissiveColor,
140
+ emissive: emissiveColor,
141
+ emissiveIntensity: lampIntensity,
142
+ metalness: 0,
143
+ roughness: 0.3
144
+ })
145
+ const lampGeo = new THREE.CylinderGeometry(lampR, lampR * 0.85, lampH, 20)
146
+ const lampMesh = new THREE.Mesh(lampGeo, lampMaterial)
147
+ lampMesh.position.set(0, baseY + chassisH + panelH + lampH / 2 + wheelR * 0.3, 0)
148
+ this.object3d.add(lampMesh)
149
+ }
150
+
151
+ updateDimension() {}
152
+
153
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
154
+ if (
155
+ 'status' in after ||
156
+ 'bodyColor' in after ||
157
+ 'lampEmissive' in after ||
158
+ 'width' in after ||
159
+ 'height' in after ||
160
+ 'depth' in after
161
+ ) {
162
+ this.update()
163
+ return
164
+ }
165
+ super.onchange(after, before)
166
+ }
167
+
168
+ updateAlpha() {}
169
+ }
package/src/tugger.ts ADDED
@@ -0,0 +1,169 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { Component, ComponentNature, RealObject, RectPath, Shape, 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 { Tugger3D } from './tugger-3d.js'
15
+
16
+ import type { AgvStatus } from './agv.js'
17
+
18
+ /**
19
+ * Tugger — a towing AGV. Pulls trailers / carts behind it; has no cargo deck
20
+ * of its own. Common in mail sorting, automotive line-side delivery, hospital
21
+ * supply transport.
22
+ *
23
+ * Status enum is reused from {@link AgvStatus} — the tugger and the unit-load
24
+ * AGV share the same operating-state semantics. The structural difference
25
+ * (cargo deck vs hitch) is in geometry, not state.
26
+ */
27
+ export type TuggerStatus = AgvStatus
28
+
29
+ const BODY_LEGEND = {
30
+ idle: '#666',
31
+ moving: '#777',
32
+ charging: '#777',
33
+ error: '#c66',
34
+ default: '#666'
35
+ }
36
+
37
+ const LAMP_EMISSIVE_LEGEND = {
38
+ idle: '#222222',
39
+ moving: '#44ff44',
40
+ charging: '#ffaa00',
41
+ error: '#ff3333',
42
+ default: '#222222'
43
+ }
44
+
45
+ const NATURE: ComponentNature = {
46
+ mutable: false,
47
+ resizable: true,
48
+ rotatable: true,
49
+ properties: [
50
+ {
51
+ type: 'select',
52
+ label: 'status',
53
+ name: 'status',
54
+ property: {
55
+ options: [
56
+ { display: 'Idle', value: 'idle' },
57
+ { display: 'Moving', value: 'moving' },
58
+ { display: 'Charging', value: 'charging' },
59
+ { display: 'Error', value: 'error' }
60
+ ]
61
+ }
62
+ },
63
+ {
64
+ type: 'number',
65
+ label: 'battery',
66
+ name: 'battery',
67
+ placeholder: '0..1'
68
+ }
69
+ ],
70
+ help: 'scene/component/tugger'
71
+ }
72
+
73
+ const Base = Legendable(Placeable(RectPath(Shape))) as unknown as typeof Component
74
+
75
+ /**
76
+ * Tugger has no cargo deck — just a powered tractor with a hitch. Trailers
77
+ * are separate scene components and are linked by data binding (a
78
+ * `currentTrailer` data field) rather than scene-tree containment, since
79
+ * trailers swap dynamically and don't share the tractor's transform.
80
+ *
81
+ * Default depth is shorter than a payload AGV — there's no operation surface
82
+ * to align to. ~600mm is enough for the motor housing + control panel.
83
+ */
84
+ @sceneComponent('tugger')
85
+ export default class Tugger extends Base {
86
+ static legends: Record<string, LegendBinding> = {
87
+ bodyColor: { from: 'status', legend: BODY_LEGEND },
88
+ lampEmissive: { from: 'status', legend: LAMP_EMISSIVE_LEGEND }
89
+ }
90
+
91
+ static placement: PlacementArchetype = 'floor'
92
+ static align: Alignment = 'bottom'
93
+ static defaultDepth = 600
94
+
95
+ get nature() {
96
+ return NATURE
97
+ }
98
+
99
+ get anchors() {
100
+ return []
101
+ }
102
+
103
+ /**
104
+ * 2D — render() draws the rounded body silhouette (auto-filled with
105
+ * bodyColor); postrender() adds the hitch arm + LiDAR + status lamp.
106
+ */
107
+ render(ctx: CanvasRenderingContext2D) {
108
+ const { width, height, left, top } = this.state
109
+ const radius = Math.min(width, height) * 0.18
110
+ ctx.beginPath()
111
+ ctx.roundRect(left, top + height * 0.10, width, height * 0.80, radius)
112
+ }
113
+
114
+ postrender(ctx: CanvasRenderingContext2D) {
115
+ super.postrender?.(ctx)
116
+
117
+ const { width, height, left, top } = this.state
118
+ const cx = left + width / 2
119
+ const accentColor = (this.state.lampEmissive as string) || '#44ff44'
120
+
121
+ ctx.save()
122
+
123
+ // Hitch arm + coupler at rear (top in top-down convention)
124
+ ctx.fillStyle = '#222233'
125
+ ctx.strokeStyle = '#111'
126
+ ctx.lineWidth = 1
127
+ const hitchW = width * 0.12
128
+ const hitchH = height * 0.10
129
+ ctx.fillRect(cx - hitchW / 2, top, hitchW, hitchH)
130
+ ctx.strokeRect(cx - hitchW / 2, top, hitchW, hitchH)
131
+ // Coupler ball
132
+ const ballR = Math.min(width, height) * 0.04
133
+ ctx.fillStyle = '#444455'
134
+ ctx.beginPath()
135
+ ctx.ellipse(cx, top + ballR, ballR, ballR, 0, 0, Math.PI * 2)
136
+ ctx.fill()
137
+ ctx.stroke()
138
+
139
+ // LiDAR sensor at front (bottom)
140
+ const lidarR = Math.min(width, height) * 0.07
141
+ ctx.fillStyle = '#222233'
142
+ ctx.beginPath()
143
+ ctx.ellipse(cx, top + height * 0.85, lidarR, lidarR, 0, 0, Math.PI * 2)
144
+ ctx.fill()
145
+ ctx.stroke()
146
+ ctx.fillStyle = accentColor
147
+ ctx.beginPath()
148
+ ctx.ellipse(cx, top + height * 0.85, lidarR * 0.4, lidarR * 0.4, 0, 0, Math.PI * 2)
149
+ ctx.fill()
150
+
151
+ // Status lamp on top of control panel (center)
152
+ const lampR = Math.min(width, height) * 0.06
153
+ ctx.fillStyle = accentColor
154
+ ctx.beginPath()
155
+ ctx.ellipse(cx, top + height * 0.45, lampR, lampR, 0, 0, Math.PI * 2)
156
+ ctx.fill()
157
+ ctx.stroke()
158
+
159
+ ctx.restore()
160
+ }
161
+
162
+ get fillStyle() {
163
+ return (this.state.bodyColor as string) || '#666'
164
+ }
165
+
166
+ buildRealObject(): RealObject | undefined {
167
+ return new Tugger3D(this as any)
168
+ }
169
+ }