@operato/scene-storage 10.0.0-beta.28 → 10.0.0-beta.30

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 (54) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/asrs-crane-3d.d.ts +10 -0
  3. package/dist/asrs-crane-3d.js +17 -0
  4. package/dist/asrs-crane-3d.js.map +1 -1
  5. package/dist/asrs-crane.d.ts +49 -13
  6. package/dist/asrs-crane.js +120 -16
  7. package/dist/asrs-crane.js.map +1 -1
  8. package/dist/asrs-rack.d.ts +49 -19
  9. package/dist/asrs-rack.js +108 -20
  10. package/dist/asrs-rack.js.map +1 -1
  11. package/dist/box.d.ts +3 -3
  12. package/dist/box.js +1 -2
  13. package/dist/box.js.map +1 -1
  14. package/dist/generic-container.d.ts +2 -2
  15. package/dist/generic-container.js +1 -2
  16. package/dist/generic-container.js.map +1 -1
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/pallet.d.ts +2 -2
  21. package/dist/pallet.js +1 -2
  22. package/dist/pallet.js.map +1 -1
  23. package/dist/parcel.d.ts +3 -3
  24. package/dist/parcel.js +1 -2
  25. package/dist/parcel.js.map +1 -1
  26. package/dist/rack-cell-3d.d.ts +25 -0
  27. package/dist/rack-cell-3d.js +88 -0
  28. package/dist/rack-cell-3d.js.map +1 -0
  29. package/dist/rack-cell.d.ts +56 -0
  30. package/dist/rack-cell.js +200 -0
  31. package/dist/rack-cell.js.map +1 -0
  32. package/dist/spot.d.ts +4 -11
  33. package/dist/spot.js +2 -3
  34. package/dist/spot.js.map +1 -1
  35. package/dist/templates/index.d.ts +42 -0
  36. package/dist/templates/index.js +43 -1
  37. package/dist/templates/index.js.map +1 -1
  38. package/package.json +9 -4
  39. package/src/asrs-crane-3d.ts +20 -0
  40. package/src/asrs-crane.ts +137 -16
  41. package/src/asrs-rack.ts +119 -20
  42. package/src/box.ts +2 -4
  43. package/src/generic-container.ts +1 -3
  44. package/src/index.ts +3 -0
  45. package/src/pallet.ts +1 -3
  46. package/src/parcel.ts +2 -4
  47. package/src/rack-cell-3d.ts +101 -0
  48. package/src/rack-cell.ts +228 -0
  49. package/src/spot.ts +4 -5
  50. package/src/templates/index.ts +43 -1
  51. package/test/setup.js +279 -0
  52. package/test/test-asrs-crane.ts +319 -0
  53. package/tsconfig.json +2 -1
  54. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@operato/scene-storage",
3
3
  "description": "Storage-domain components for things-scene (smart factory / logistics) — pallet, box, parcel; AS/RS and shelves planned.",
4
4
  "author": "heartyoh",
5
- "version": "10.0.0-beta.28",
5
+ "version": "10.0.0-beta.30",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "module": "dist/index.js",
@@ -21,24 +21,29 @@
21
21
  "build": "tsc",
22
22
  "prepublishOnly": "tsc",
23
23
  "lint": "eslint src/ && prettier \"src/**/*.ts\" --check",
24
- "format": "eslint src/ --fix && prettier \"src/**/*.ts\" --write"
24
+ "format": "eslint src/ --fix && prettier \"src/**/*.ts\" --write",
25
+ "test": "mocha --require should --require ./test/setup.js --node-option import=tsx \"test/**/test-*.ts\""
25
26
  },
26
27
  "dependencies": {
27
28
  "@hatiolab/things-scene": "^10.0.0-beta.1",
28
- "@operato/scene-base": "^10.0.0-beta.24",
29
+ "@operato/scene-base": "^10.0.0-beta.30",
29
30
  "three": "^0.183.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@hatiolab/prettier-config": "^1.0.0",
34
+ "@types/mocha": "^10.0.0",
33
35
  "@types/three": "^0.183.0",
34
36
  "@typescript-eslint/eslint-plugin": "^8.0.0",
35
37
  "@typescript-eslint/parser": "^8.0.0",
36
38
  "eslint": "^9.18.0",
37
39
  "eslint-config-prettier": "^10.0.1",
40
+ "mocha": "^11.0.0",
38
41
  "prettier": "^3.2.5",
42
+ "should": "^13.2.3",
39
43
  "tslib": "^2.3.1",
44
+ "tsx": "^4.21.0",
40
45
  "typescript": "^5.0.4"
41
46
  },
42
47
  "prettier": "@hatiolab/prettier-config",
43
- "gitHead": "40634f9afc681d852d6028a329cedb51cc4fb94f"
48
+ "gitHead": "06b35b1726ec4f27ee76657ce341c6c6f3ba1b3a"
44
49
  }
@@ -167,6 +167,26 @@ export class AsrsCrane3D extends RealObjectGroup {
167
167
  // Place lamp near the corner of the base, away from the mast
168
168
  lampMesh.position.set(width * 0.3, baseY + railH + baseH + lampH / 2, height * 0.3)
169
169
  this.object3d.add(lampMesh)
170
+
171
+ // ── Carriage frame (invisible anchor for carrier attach) ──────────
172
+ // Placed at the top of the shuttle, where cargo rests.
173
+ this._carriageFrame = new THREE.Object3D()
174
+ this._carriageFrame.name = 'crane-carriage-tcp'
175
+ this._carriageFrame.position.set(0, carriageY - carriageH / 2 - shuttleH, 0)
176
+ this.object3d.add(this._carriageFrame)
177
+ }
178
+
179
+ /** Sub-frame where carriers attach during transport (fork tool-centre-point). */
180
+ private _carriageFrame?: THREE.Object3D
181
+
182
+ /**
183
+ * Return the carriage TCP anchor. Carriers attached to this frame will
184
+ * follow carriage movement as `carriageHeight` changes and the crane rebuilds.
185
+ *
186
+ * Callers should re-fetch this after any state change that triggers rebuild.
187
+ */
188
+ getCarriageFrame(): THREE.Object3D | undefined {
189
+ return this._carriageFrame
170
190
  }
171
191
 
172
192
  updateDimension() {}
package/src/asrs-crane.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
- import { Component, ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
4
+ import { Component, ComponentNature, ContainerAbstract, ContainerCapacity, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
+ import type { SlotDef } from '@hatiolab/things-scene'
5
6
  import {
7
+ CarrierHolder,
6
8
  Legendable,
9
+ Mover,
7
10
  Placeable,
11
+ type AttachFrame,
8
12
  type Alignment,
9
13
  type Heights,
10
14
  type LegendBinding,
15
+ type MoveOptions,
11
16
  type PlacementArchetype
12
17
  } from '@operato/scene-base'
13
18
 
@@ -72,27 +77,37 @@ const NATURE: ComponentNature = {
72
77
  help: 'scene/component/asrs-crane'
73
78
  }
74
79
 
75
- const Base = Legendable(Placeable(RectPath(Shape))) as unknown as typeof Component
76
-
80
+ // Mixin chain: Mover → CarrierHolder → ContainerCapacity → Legendable Placeable ContainerAbstract
81
+ //
82
+ // Mover: pick / place / pickAndPlace / moveTo / engage primitives
83
+ // CarrierHolder: attachPointFor() — where the carrier sits on the crane (carriage fork)
84
+ // ContainerCapacity: receive() / dispatch() / canReceive() / slots — slot tracking +
85
+ // TRANSFER_SLOT_KEY bookkeeping during transit
86
+ // Legendable: status → bodyColor / lampEmissive colour mapping
87
+ // Placeable: floor-archetype 3D positioning
88
+ // ContainerAbstract: child management — carrier becomes a child while in transit
89
+ //
90
+ // Note: ContainerAbstract replaces Shape. The 2D outline is drawn manually in
91
+ // render() below (a simple top-down rectangle), matching the old Shape output
92
+ // without the Shape base-class overhead.
77
93
  /**
78
94
  * AsrsCrane — the stacker / retrieval crane that runs in the aisle of an
79
95
  * AS/RS, moving cargo between the load port and the rack cells.
80
96
  *
81
97
  * Structure: a tall vertical mast that translates along a floor + ceiling
82
98
  * rail (the aisle), with a carriage that slides up/down the mast carrying a
83
- * shuttle / forks. The whole assembly's footprint is narrow (mast width)
84
- * but its visual height is full ceiling — by far the tallest single
85
- * component in a typical scene.
99
+ * shuttle / forks.
100
+ *
101
+ * **Monitoring mode**: crane status is driven by data binding
102
+ * (`state.status`, `state.carriageHeight`). The carrier is referenced
103
+ * via data binding — it is NOT a child of the crane in monitoring mode.
86
104
  *
87
- * Currently Shape-based (no children). The carrier the crane is *currently
88
- * carrying* is best modeled via data binding (a `currentCarrier` data field
89
- * on the crane looked up to a Pallet/Box elsewhere in the scene), as in
90
- * fmsim's CarrierManager pattern. Adding the carrier as a child would mix
91
- * static placement with the dynamic data-driven flow we deliberately keep
92
- * separate (see Phase A4 commit notes).
105
+ * **Simulation mode**: call `crane.pick(carrier)` / `crane.place(carrier, rackCell)`
106
+ * (or `crane.pickAndPlace(carrier, rackCell)`). Mover handles navigation +
107
+ * engage + reparent. During transit the carrier IS a child of the crane.
93
108
  */
94
109
  @sceneComponent('asrs-crane')
95
- export default class AsrsCrane extends Base {
110
+ export default class AsrsCrane extends Mover(CarrierHolder(ContainerCapacity(Legendable(Placeable(ContainerAbstract))))) {
96
111
  static legends: Record<string, LegendBinding> = {
97
112
  bodyColor: { from: 'status', legend: BODY_LEGEND },
98
113
  lampEmissive: { from: 'status', legend: LAMP_EMISSIVE_LEGEND }
@@ -102,6 +117,9 @@ export default class AsrsCrane extends Base {
102
117
  static align: Alignment = 'bottom'
103
118
  static defaultDepth = (h: Heights) => h.ceiling - h.floor
104
119
 
120
+ /** Yaw offset: crane model is drawn with the aisle axis along X (right = forward). */
121
+ static yawOffset = 0
122
+
105
123
  get nature() {
106
124
  return NATURE
107
125
  }
@@ -110,21 +128,124 @@ export default class AsrsCrane extends Base {
110
128
  return []
111
129
  }
112
130
 
131
+ // ── ContainerCapacity ─────────────────────────────────────────────────────
132
+
133
+ /** Stacker crane carries at most one load at a time on its forks. */
134
+ get slots(): SlotDef[] {
135
+ return [{ id: 'forks', maxCount: 1 }]
136
+ }
137
+
138
+ // ── CarrierHolder — attach frame (carriage fork position) ─────────────────
139
+
140
+ /**
141
+ * Return the 3D attach frame on the crane's carriage (fork tip).
142
+ * Carriers are attached here while the crane is in transit (pick phase).
143
+ *
144
+ * The AsrsCrane3D exposes `getCarriageFrame()` — a sub-Object3D that
145
+ * tracks the carriage height and sits at the fork TCP. If the 3D object
146
+ * isn't built yet (e.g. before scene initialization), fall back to the
147
+ * crane's own object3d centre.
148
+ */
149
+ attachPointFor(carrier: Component): AttachFrame | null {
150
+ const ro = (this as any)._realObject as AsrsCrane3D | undefined
151
+ const frame = ro?.getCarriageFrame?.()
152
+ if (frame) {
153
+ const carrierDepth = resolveCarrierDepth(carrier)
154
+ return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
155
+ }
156
+ const root = (this as any)._realObject?.object3d
157
+ if (!root) return null
158
+ return { attach: root }
159
+ }
160
+
161
+ // ── Mover overrides ───────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Domain-specific actuation between arrival and reparent.
165
+ *
166
+ * Simulation sequence for PICK:
167
+ * 1. Mover.pick() navigates crane to carrier position (moveTo).
168
+ * 2. engage('pick') → snap carriage height + status 'loading'.
169
+ * 3. Carrier is reparented to crane (becomes child).
170
+ *
171
+ * For now: set status and snap carriage height. A full ASRS simulation
172
+ * would tween the carriageHeight here (animate AsrsCrane3D).
173
+ *
174
+ * Status lifecycle:
175
+ * idle → (moveTo running) → engage fires → loading/unloading → (reparent) → idle
176
+ * The 'moving' state is not set from Mover.moveTo() because TypeScript
177
+ * can't call super.moveTo() on an `: any`-typed mixin. WCS data binding
178
+ * sets 'moving' in monitoring mode; override pick()/place() to set it
179
+ * in full simulation environments.
180
+ */
181
+ async engage(
182
+ target: Component,
183
+ kind: 'pick' | 'place',
184
+ _options: MoveOptions = {}
185
+ ): Promise<void> {
186
+ if (kind === 'pick') {
187
+ this.setState({ status: 'loading' as AsrsCraneStatus })
188
+ const carrierY = resolveCarrierCenterY(target)
189
+ if (carrierY !== null) {
190
+ this.setState({ carriageHeight: carrierY })
191
+ }
192
+ } else {
193
+ this.setState({ status: 'unloading' as AsrsCraneStatus })
194
+ }
195
+ // In a full simulation: await carriage-motion tween here.
196
+ }
197
+
198
+ // ── Domain aliases ────────────────────────────────────────────────────────
199
+
200
+ /** Fetch a carrier from a rack cell (semantically = pick). */
201
+ fetch(carrier: Component, options?: MoveOptions): Promise<void> {
202
+ return (this as any).pick(carrier, options)
203
+ }
204
+
205
+ /** Deposit a carrier into a rack cell (semantically = place). */
206
+ deposit(carrier: Component, cell: Component, options?: MoveOptions): Promise<void> {
207
+ return (this as any).place(carrier, cell, options)
208
+ }
209
+
210
+ // ── 2D rendering ─────────────────────────────────────────────────────────
211
+
113
212
  /**
114
213
  * 2D — top-down rectangle showing the crane's footprint along the aisle.
115
214
  * The crane is much taller than wide, so the 2D mark is small.
116
215
  */
117
216
  render(ctx: CanvasRenderingContext2D) {
118
217
  const { width, height, left, top } = this.state
218
+ const fillColor = (this.state.bodyColor as string) || '#888'
219
+ ctx.save()
220
+ ctx.fillStyle = fillColor
119
221
  ctx.beginPath()
120
222
  ctx.rect(left, top, width, height)
223
+ ctx.fill()
224
+ ctx.restore()
121
225
  }
122
226
 
123
- get fillStyle() {
124
- return (this.state.bodyColor as string) || '#888'
125
- }
227
+ // ── 3D ───────────────────────────────────────────────────────────────────
126
228
 
127
229
  buildRealObject(): RealObject | undefined {
128
230
  return new AsrsCrane3D(this as any)
129
231
  }
130
232
  }
233
+
234
+ function resolveCarrierDepth(c: Component): number {
235
+ const eff = (c as any)._realObject?.effectiveDepth
236
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
237
+ return numOr((c as any)?.state?.depth, 0)
238
+ }
239
+
240
+ function resolveCarrierCenterY(c: Component): number | null {
241
+ const pos = (c as any).state
242
+ if (!pos) return null
243
+ // zPos is the 3D Y center of a Placeable component in things-scene
244
+ const zPos = numOr(pos.zPos, NaN)
245
+ if (!Number.isNaN(zPos)) return zPos
246
+ return null
247
+ }
248
+
249
+ function numOr(v: unknown, dflt: number): number {
250
+ return typeof v === 'number' && Number.isFinite(v) ? v : dflt
251
+ }
package/src/asrs-rack.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  */
4
4
  import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
5
  import {
6
+ CellContainer,
7
+ CellMap,
8
+ CarrierHolder,
6
9
  Placeable,
10
+ type AttachFrame,
7
11
  type Alignment,
8
12
  type Heights,
9
13
  type PlacementArchetype
@@ -35,35 +39,31 @@ const NATURE: ComponentNature = {
35
39
  // `ContainerAbstract` (not `Container`) — Container = MixinHTMLElement(ContainerAbstract),
36
40
  // which forces `isHTMLElement(): true` and trips the 3D pipeline's
37
41
  // addObject DOM-skip gate. ASRS rack lives only in the 3D scene graph.
38
- const Base = Placeable(ContainerAbstract) as unknown as typeof Component
39
-
42
+ //
43
+ // Mixin chain: CellContainer → CarrierHolder → Placeable → ContainerAbstract
44
+ // CellContainer: cell topology (cellMap, cell(), findAvailableCell(), occupiedCellIds())
45
+ // CarrierHolder: 3D attach-point protocol (attachPointFor, containable gates)
46
+ // Placeable: floor-archetype positioning
47
+ // ContainerAbstract: child component management
40
48
  /**
41
49
  * AsrsRack — a multi-level high-bay storage rack, the structural backbone of
42
50
  * an AS/RS (Automated Storage / Retrieval System).
43
51
  *
44
52
  * `levels` × `bays` cells form a vertical grid. Each cell holds one logistics
45
53
  * package (Pallet / Box / Parcel). A pair of AsrsRacks separated by an aisle
46
- * (where an AsrsCrane runs) is the typical AS/RS configuration; v1 ships the
47
- * single-rack unit and lets users compose multi-rack systems by placing them
48
- * side by side. A future `AsrsAisle` composite may bundle the pair + crane.
54
+ * (where an AsrsCrane runs) is the typical AS/RS configuration.
49
55
  *
50
- * **Placement**: `floor` archetype, full ceiling depth by default AS/RS
51
- * racks typically span floor to ceiling, with levels sized to fit the tallest
52
- * pallet load. Users can shorten via explicit `state.depth` for warehouses
53
- * with smaller envelopes.
56
+ * **Monitoring mode** (default): pallets/boxes are direct children of the rack,
57
+ * placed by the WCS data binding. No RackCell children are created.
54
58
  *
55
- * **Container-based**. Cells host stored cargo as children each child's
56
- * left/top within the rack's bounds determines which cell it occupies. The
57
- * stacking pass in `Placeable.computeDefaultZPos` ensures each child cargo's
58
- * z lands on the rack's overall bottom (parent.zPos + parent.depth = ceiling),
59
- * which isn't quite cell-level resolution — true per-cell z positioning is
60
- * a v3 concern (the cargo would need to know which cell-row it's in).
59
+ * **Simulation mode**: call `rack._buildCells()` after placing the rack on the
60
+ * scene. This creates RackCell children at the correct 3D positions. The
61
+ * AsrsCrane then navigates to individual RackCells for pick-and-place.
61
62
  *
62
- * No Legendable for v1 racks are passive structures; their per-cell
63
- * occupancy state is implicit in the children, not a status flag.
63
+ * **Placement**: `floor` archetype, full ceiling depth by default.
64
64
  */
65
65
  @sceneComponent('asrs-rack')
66
- export default class AsrsRack extends Base {
66
+ export default class AsrsRack extends CellContainer(CarrierHolder(Placeable(ContainerAbstract))) {
67
67
  static placement: PlacementArchetype = 'floor'
68
68
  static align: Alignment = 'bottom'
69
69
  static defaultDepth = (h: Heights) => h.ceiling - h.floor
@@ -76,13 +76,110 @@ export default class AsrsRack extends Base {
76
76
  return []
77
77
  }
78
78
 
79
- /** Operation cargo (pallets / boxes / parcels) goes in the rack's cells. */
80
- containable(component: Component) {
79
+ // ── CellContainer ─────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Derive the cell topology from the rack's current dimensions and bay/level
83
+ * counts. The CellMap is rebuilt fresh each time (state changes trigger
84
+ * re-reads via things-scene's invalidation pipeline).
85
+ *
86
+ * Coordinate convention (matches things-scene 3D):
87
+ * X = bay axis (left → right)
88
+ * Y = level axis (floor → ceiling, the rack's `depth` state property)
89
+ * Z = row axis (front → back, the rack's `height` state property)
90
+ */
91
+ get cellMap(): CellMap {
92
+ const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
93
+ const levels = Math.max(1, Math.floor((this.state.levels as number) || 4))
94
+ const width = (this.state.width as number) || 1000
95
+ const rackDepth = (this.state.depth as number) || 3000 // Y: floor→ceiling
96
+ const rackHeight = (this.state.height as number) || 600 // Z: front→back
97
+
98
+ return CellMap.grid({
99
+ bays,
100
+ rows: 1,
101
+ levels,
102
+ bayWidth: width / bays,
103
+ rowDepth: rackHeight,
104
+ levelHeight: rackDepth / levels
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Create RackCell child components for each cell in the CellMap.
110
+ *
111
+ * Called explicitly to enter simulation mode — monitoring-mode racks
112
+ * never call this (pallets are direct children, no explicit cells).
113
+ *
114
+ * Idempotent: removes existing rack-cell children first.
115
+ */
116
+ _buildCells(): void {
117
+ // Remove existing rack-cell children
118
+ const existing = ((this as any).components as Component[] | undefined) ?? []
119
+ for (const child of [...existing]) {
120
+ if ((child as any).state?.type === 'rack-cell') {
121
+ ;(this as any).removeComponent?.(child)
122
+ }
123
+ }
124
+
125
+ // Create a RackCell for each cell in the map
126
+ const RackCellClass = (Component as any).register('rack-cell') as (new (...args: any[]) => Component) | undefined
127
+ if (!RackCellClass) {
128
+ console.warn('AsrsRack._buildCells: rack-cell type not registered. Import rack-cell.ts first.')
129
+ return
130
+ }
131
+
132
+ const context = (this as any)._app
133
+ for (const cell of this.cellMap.cells) {
134
+ const model = {
135
+ type: 'rack-cell',
136
+ cellId: cell.id,
137
+ width: cell.size.width,
138
+ height: cell.size.depth, // 2D height = 3D Z depth
139
+ depth: cell.size.height // 3D Y = level height
140
+ }
141
+ const rackCell = new RackCellClass(model, context)
142
+ ;(this as any).addComponent?.(rackCell)
143
+ }
144
+ }
145
+
146
+ // ── Container gates ───────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Allow:
150
+ * - Carriable components (pallets, boxes, parcels) — direct children in monitoring mode.
151
+ * - RackCell — created by _buildCells() in simulation mode.
152
+ *
153
+ * Block:
154
+ * - Everything else (sensors, labels, etc. can be siblings of the rack, not children).
155
+ */
156
+ containable(component: Component): boolean {
157
+ if ((component as any).state?.type === 'rack-cell') return true
81
158
  const archetype = (component.constructor as any).placement
82
159
  if (archetype === 'operation') return true
83
160
  return component.isDescendible(this as any)
84
161
  }
85
162
 
163
+ // ── CarrierHolder — attach frame for direct carrier children ─────────────
164
+
165
+ /**
166
+ * Attach frame for carriers that are DIRECT children of the rack
167
+ * (monitoring mode, where pallets go directly into the rack without
168
+ * explicit RackCell components).
169
+ *
170
+ * In simulation mode, carriers become children of their RackCell,
171
+ * and each RackCell provides its own attachPointFor(). So this method
172
+ * is only invoked on direct-child carriers in monitoring mode — it
173
+ * returns the rack's own object3d as the attach frame (default behavior).
174
+ */
175
+ attachPointFor(_carrier: Component): AttachFrame | null {
176
+ const root = (this as any)._realObject?.object3d
177
+ if (!root) return null
178
+ return { attach: root }
179
+ }
180
+
181
+ // ── 2D rendering ─────────────────────────────────────────────────────────
182
+
86
183
  /**
87
184
  * 2D — top-down rectangle showing the rack footprint, with subdivisions
88
185
  * suggesting the bay layout (lines parallel to the aisle).
@@ -106,6 +203,8 @@ export default class AsrsRack extends Base {
106
203
  return '#a0a0a8'
107
204
  }
108
205
 
206
+ // ── 3D ───────────────────────────────────────────────────────────────────
207
+
109
208
  buildRealObject(): RealObject | undefined {
110
209
  return new AsrsRack3D(this as any)
111
210
  }
package/src/box.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
- import { Component, ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
4
+ import { ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
5
5
  import {
6
6
  Carriable,
7
7
  Legendable,
@@ -54,8 +54,6 @@ const NATURE: ComponentNature = {
54
54
 
55
55
  // Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,
56
56
  // AGV deck, robot-arm gripper, Spot for staging).
57
- const Base = Carriable(Legendable(Placeable(RectPath(Shape)))) as unknown as typeof Component
58
-
59
57
  /**
60
58
  * Box — a generic stackable container for goods. Wood crate or plastic tote
61
59
  * variants distinguished by `material` prop.
@@ -65,7 +63,7 @@ const Base = Carriable(Legendable(Placeable(RectPath(Shape)))) as unknown as typ
65
63
  * scene-tree). If a future use case needs nested boxes, extend Container.
66
64
  */
67
65
  @sceneComponent('box')
68
- export default class Box extends Base {
66
+ export default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {
69
67
  static legends: Record<string, LegendBinding> = {
70
68
  bodyColor: { from: 'material', legend: BODY_LEGEND }
71
69
  }
@@ -87,10 +87,8 @@ const NATURE: ComponentNature = {
87
87
 
88
88
  // 합성 순서: 안쪽부터 → ContainerAbstract → Placeable → Legendable → GltfComponent
89
89
  // (GenericFacility 와 동일 패턴)
90
- const Base = GltfComponent(Legendable(Placeable(ContainerAbstract))) as unknown as typeof Component
91
-
92
90
  @sceneComponent('container')
93
- export default class GenericContainer extends Base {
91
+ export default class GenericContainer extends GltfComponent(Legendable(Placeable(ContainerAbstract))) {
94
92
  static legends: Record<string, LegendBinding> = {
95
93
  bodyColor: { from: 'fill', legend: BODY_LEGEND },
96
94
  lampEmissive: { from: 'fill', legend: LAMP_EMISSIVE_LEGEND }
package/src/index.ts CHANGED
@@ -10,6 +10,9 @@ export type { BoxMaterial } from './box.js'
10
10
  export { default as Parcel } from './parcel.js'
11
11
 
12
12
  export { default as AsrsRack } from './asrs-rack.js'
13
+ export { default as RackCell } from './rack-cell.js'
14
+ export type { RackCellType } from './rack-cell.js'
15
+ export { RackCell3D } from './rack-cell-3d.js'
13
16
  export { default as AsrsCrane } from './asrs-crane.js'
14
17
  export type { AsrsCraneStatus } from './asrs-crane.js'
15
18
 
package/src/pallet.ts CHANGED
@@ -60,8 +60,6 @@ const NATURE: ComponentNature = {
60
60
  // `ContainerAbstract` (not `Container`) — Container = MixinHTMLElement(ContainerAbstract),
61
61
  // which forces `isHTMLElement(): true` and trips the 3D pipeline's
62
62
  // addObject DOM-skip gate. Pallet renders only as a 3D mesh.
63
- const Base = Carriable(Legendable(Placeable(ContainerAbstract))) as unknown as typeof Component
64
-
65
63
  /**
66
64
  * Pallet — a flat transport structure that goods are stacked and stored on.
67
65
  *
@@ -89,7 +87,7 @@ const Base = Carriable(Legendable(Placeable(ContainerAbstract))) as unknown as t
89
87
  * detection.
90
88
  */
91
89
  @sceneComponent('pallet')
92
- export default class Pallet extends Base {
90
+ export default class Pallet extends Carriable(Legendable(Placeable(ContainerAbstract))) {
93
91
  static legends: Record<string, LegendBinding> = {
94
92
  bodyColor: { from: 'material', legend: BODY_LEGEND }
95
93
  }
package/src/parcel.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
- import { Component, ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
4
+ import { ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
5
5
  import {
6
6
  Carriable,
7
7
  Placeable,
@@ -28,8 +28,6 @@ const NATURE: ComponentNature = {
28
28
  // Carriable: parcel can be a child of any CarrierHolder (Spot, robot-arm
29
29
  // gripper, AGV deck, …). Mixin wraps add() so the parcel's 3D object3d
30
30
  // is reattached to the holder's chosen mount frame.
31
- const Base = Carriable(Placeable(RectPath(Shape))) as unknown as typeof Component
32
-
33
31
  /**
34
32
  * Parcel — a cardboard package, the typical e-commerce / parcel-sortation unit.
35
33
  *
@@ -46,7 +44,7 @@ const Base = Carriable(Placeable(RectPath(Shape))) as unknown as typeof Componen
46
44
  * inspected indicators would add a status legend then.
47
45
  */
48
46
  @sceneComponent('parcel')
49
- export default class Parcel extends Base {
47
+ export default class Parcel extends Carriable(Placeable(RectPath(Shape))) {
50
48
  static placement: PlacementArchetype = 'operation'
51
49
  static align: Alignment = 'bottom'
52
50
  static defaultDepth = 150
@@ -0,0 +1,101 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * RackCell 3D — invisible anchor group positioned at the cell's location
5
+ * within the parent rack's 3D coordinate space.
6
+ *
7
+ * RackCell has no geometry of its own. Its sole 3D purpose is to provide
8
+ * an Object3D that carriers can be attached to (via Three.js `.attach()`),
9
+ * placed at the exact cell position within the rack. The position is derived
10
+ * from the parent AsrsRack's CellMap (by cellId), not from 2D state fields —
11
+ * rack cells occupy 3D levels that have no 2D analogue.
12
+ *
13
+ * updateTransform() override: things-scene's standard updateTransform
14
+ * reads `component.center` (2D) and flattens it to 3D. For rack cells this
15
+ * is wrong — we need the 3D cell position (bay x, level y, row z) from the
16
+ * parent rack. So we override and read it directly from the CellMap.
17
+ */
18
+
19
+ import * as THREE from 'three'
20
+ import { RealObjectGroup } from '@hatiolab/things-scene'
21
+
22
+ export class RackCell3D extends RealObjectGroup {
23
+ build() {
24
+ super.build()
25
+ this._repositionFromCellMap()
26
+ }
27
+
28
+ updateDimension() {
29
+ // intentional no-op — size comes from the cell definition, not state
30
+ }
31
+
32
+ updateTransform() {
33
+ this._repositionFromCellMap()
34
+ }
35
+
36
+ updateAlpha() {
37
+ // invisible — no materials to update
38
+ }
39
+
40
+ /**
41
+ * Position this group at the cell's localPosition within the rack's 3D space.
42
+ *
43
+ * CellMap.grid() places cell origins at:
44
+ * x = b * bayWidth, y = l * levelHeight, z = r * rowDepth
45
+ * (starting from 0,0,0 at the rack's bottom-left-front corner).
46
+ *
47
+ * The rack's object3d is centered: X spans [-width/2, +width/2],
48
+ * Y spans [-depth/2, +depth/2], Z spans [-height/2, +height/2].
49
+ *
50
+ * So the cell centre in rack-local 3D space:
51
+ * x3d = cellPos.x + bayWidth/2 - width/2
52
+ * y3d = cellPos.y + levelHeight/2 - rackDepth/2
53
+ * z3d = cellPos.z + rowDepth/2 - rackHeight/2
54
+ */
55
+ _repositionFromCellMap() {
56
+ const rack = (this.component as any).parent
57
+ if (!rack?.cellMap) return
58
+
59
+ const cellId = (this.component as any).state?.cellId as string | undefined
60
+ if (!cellId) return
61
+
62
+ const cell = rack.cellMap.findById(cellId)
63
+ if (!cell) return
64
+
65
+ const rs = rack.state as any
66
+ const rackWidth = (rs?.width as number) || 1000
67
+ const rackDepth = (rs?.depth as number) || 3000 // Y dimension (floor→ceiling)
68
+ const rackHeight = (rs?.height as number) || 600 // Z dimension (front→back, 2D height)
69
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
70
+ const levels = Math.max(1, Math.floor(rs?.levels || 4))
71
+ const rows = 1
72
+
73
+ const bayWidth = rackWidth / bays
74
+ const levelHeight = rackDepth / levels
75
+ const rowDepth = rackHeight / rows
76
+
77
+ const x3d = cell.localPosition.x + bayWidth / 2 - rackWidth / 2
78
+ const y3d = cell.localPosition.y + levelHeight / 2 - rackDepth / 2
79
+ const z3d = cell.localPosition.z + rowDepth / 2 - rackHeight / 2
80
+
81
+ this.object3d.position.set(x3d, y3d, z3d)
82
+
83
+ // Optionally visualise cells in debug mode (outline box, very faint)
84
+ if (process.env.NODE_ENV !== 'production' && (rack.state as any)?.debugCells) {
85
+ this._addDebugBox(bayWidth, levelHeight, rowDepth)
86
+ }
87
+ }
88
+
89
+ private _debugBox?: THREE.LineSegments
90
+
91
+ private _addDebugBox(w: number, h: number, d: number) {
92
+ if (this._debugBox) return
93
+ const geo = new THREE.BoxGeometry(w * 0.98, h * 0.98, d * 0.98)
94
+ const edges = new THREE.EdgesGeometry(geo)
95
+ this._debugBox = new THREE.LineSegments(
96
+ edges,
97
+ new THREE.LineBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.2 })
98
+ )
99
+ this.object3d.add(this._debugBox)
100
+ }
101
+ }