@operato/scene-storage 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 (78) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +59 -0
  3. package/dist/asrs-crane-3d.d.ts +7 -0
  4. package/dist/asrs-crane-3d.js +164 -0
  5. package/dist/asrs-crane-3d.js.map +1 -0
  6. package/dist/asrs-crane.d.ts +47 -0
  7. package/dist/asrs-crane.js +104 -0
  8. package/dist/asrs-crane.js.map +1 -0
  9. package/dist/asrs-rack-3d.d.ts +7 -0
  10. package/dist/asrs-rack-3d.js +129 -0
  11. package/dist/asrs-rack-3d.js.map +1 -0
  12. package/dist/asrs-rack.d.ts +45 -0
  13. package/dist/asrs-rack.js +99 -0
  14. package/dist/asrs-rack.js.map +1 -0
  15. package/dist/box-3d.d.ts +11 -0
  16. package/dist/box-3d.js +166 -0
  17. package/dist/box-3d.js.map +1 -0
  18. package/dist/box.d.ts +36 -0
  19. package/dist/box.js +73 -0
  20. package/dist/box.js.map +1 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +11 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/pallet-3d.d.ts +11 -0
  25. package/dist/pallet-3d.js +162 -0
  26. package/dist/pallet-3d.js.map +1 -0
  27. package/dist/pallet.d.ts +56 -0
  28. package/dist/pallet.js +99 -0
  29. package/dist/pallet.js.map +1 -0
  30. package/dist/parcel-3d.d.ts +7 -0
  31. package/dist/parcel-3d.js +82 -0
  32. package/dist/parcel-3d.js.map +1 -0
  33. package/dist/parcel.d.ts +30 -0
  34. package/dist/parcel.js +67 -0
  35. package/dist/parcel.js.map +1 -0
  36. package/dist/spot-3d.d.ts +30 -0
  37. package/dist/spot-3d.js +176 -0
  38. package/dist/spot-3d.js.map +1 -0
  39. package/dist/spot.d.ts +41 -0
  40. package/dist/spot.js +177 -0
  41. package/dist/spot.js.map +1 -0
  42. package/dist/templates/index.d.ts +92 -0
  43. package/dist/templates/index.js +115 -0
  44. package/dist/templates/index.js.map +1 -0
  45. package/dist/templates/spot.d.ts +24 -0
  46. package/dist/templates/spot.js +26 -0
  47. package/dist/templates/spot.js.map +1 -0
  48. package/icons/asrs-crane.png +0 -0
  49. package/icons/asrs-rack.png +0 -0
  50. package/icons/box-plastic.png +0 -0
  51. package/icons/box-wood.png +0 -0
  52. package/icons/pallet-plastic.png +0 -0
  53. package/icons/pallet-wood.png +0 -0
  54. package/icons/parcel.png +0 -0
  55. package/package.json +44 -0
  56. package/src/asrs-crane-3d.ts +191 -0
  57. package/src/asrs-crane.ts +130 -0
  58. package/src/asrs-rack-3d.ts +146 -0
  59. package/src/asrs-rack.ts +109 -0
  60. package/src/box-3d.ts +189 -0
  61. package/src/box.ts +99 -0
  62. package/src/index.ts +17 -0
  63. package/src/pallet-3d.ts +181 -0
  64. package/src/pallet.ts +125 -0
  65. package/src/parcel-3d.ts +90 -0
  66. package/src/parcel.ts +76 -0
  67. package/src/spot-3d.ts +200 -0
  68. package/src/spot.ts +197 -0
  69. package/src/templates/index.ts +115 -0
  70. package/src/templates/spot.ts +26 -0
  71. package/things-scene.config.js +5 -0
  72. package/translations/en.json +12 -0
  73. package/translations/ja.json +12 -0
  74. package/translations/ko.json +12 -0
  75. package/translations/ms.json +12 -0
  76. package/translations/zh.json +12 -0
  77. package/tsconfig.json +23 -0
  78. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { Component, ComponentNature, Container, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
+ import {
6
+ Placeable,
7
+ type Alignment,
8
+ type Heights,
9
+ type PlacementArchetype
10
+ } from '@operato/scene-base'
11
+
12
+ import { AsrsRack3D } from './asrs-rack-3d.js'
13
+
14
+ const NATURE: ComponentNature = {
15
+ mutable: false,
16
+ resizable: true,
17
+ rotatable: true,
18
+ properties: [
19
+ {
20
+ type: 'number',
21
+ label: 'levels',
22
+ name: 'levels',
23
+ placeholder: '# of vertical levels (default 4)'
24
+ },
25
+ {
26
+ type: 'number',
27
+ label: 'bays',
28
+ name: 'bays',
29
+ placeholder: '# of horizontal bays (default 5)'
30
+ }
31
+ ],
32
+ help: 'scene/component/asrs-rack'
33
+ }
34
+
35
+ const Base = Placeable(Container) as unknown as typeof Component
36
+
37
+ /**
38
+ * AsrsRack — a multi-level high-bay storage rack, the structural backbone of
39
+ * an AS/RS (Automated Storage / Retrieval System).
40
+ *
41
+ * `levels` × `bays` cells form a vertical grid. Each cell holds one logistics
42
+ * package (Pallet / Box / Parcel). A pair of AsrsRacks separated by an aisle
43
+ * (where an AsrsCrane runs) is the typical AS/RS configuration; v1 ships the
44
+ * single-rack unit and lets users compose multi-rack systems by placing them
45
+ * side by side. A future `AsrsAisle` composite may bundle the pair + crane.
46
+ *
47
+ * **Placement**: `floor` archetype, full ceiling depth by default — AS/RS
48
+ * racks typically span floor to ceiling, with levels sized to fit the tallest
49
+ * pallet load. Users can shorten via explicit `state.depth` for warehouses
50
+ * with smaller envelopes.
51
+ *
52
+ * **Container-based**. Cells host stored cargo as children — each child's
53
+ * left/top within the rack's bounds determines which cell it occupies. The
54
+ * stacking pass in `Placeable.computeDefaultZPos` ensures each child cargo's
55
+ * z lands on the rack's overall bottom (parent.zPos + parent.depth = ceiling),
56
+ * which isn't quite cell-level resolution — true per-cell z positioning is
57
+ * a v3 concern (the cargo would need to know which cell-row it's in).
58
+ *
59
+ * No Legendable for v1 — racks are passive structures; their per-cell
60
+ * occupancy state is implicit in the children, not a status flag.
61
+ */
62
+ @sceneComponent('asrs-rack')
63
+ export default class AsrsRack extends Base {
64
+ static placement: PlacementArchetype = 'floor'
65
+ static align: Alignment = 'bottom'
66
+ static defaultDepth = (h: Heights) => h.ceiling - h.floor
67
+
68
+ get nature() {
69
+ return NATURE
70
+ }
71
+
72
+ get anchors() {
73
+ return []
74
+ }
75
+
76
+ /** Operation cargo (pallets / boxes / parcels) goes in the rack's cells. */
77
+ containable(component: Component) {
78
+ const archetype = (component.constructor as any).placement
79
+ if (archetype === 'operation') return true
80
+ return component.isDescendible(this as any)
81
+ }
82
+
83
+ /**
84
+ * 2D — top-down rectangle showing the rack footprint, with subdivisions
85
+ * suggesting the bay layout (lines parallel to the aisle).
86
+ */
87
+ render(ctx: CanvasRenderingContext2D) {
88
+ const { width, height, left, top } = this.state
89
+ const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
90
+
91
+ ctx.beginPath()
92
+ // Outer rectangle
93
+ ctx.rect(left, top, width, height)
94
+ // Bay subdivisions (vertical lines)
95
+ for (let i = 1; i < bays; i++) {
96
+ const x = left + (width * i) / bays
97
+ ctx.moveTo(x, top)
98
+ ctx.lineTo(x, top + height)
99
+ }
100
+ }
101
+
102
+ get fillStyle() {
103
+ return '#a0a0a8'
104
+ }
105
+
106
+ buildRealObject(): RealObject | undefined {
107
+ return new AsrsRack3D(this as any)
108
+ }
109
+ }
package/src/box-3d.ts ADDED
@@ -0,0 +1,189 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Box 3D — wood crate and plastic tote variants.
5
+ *
6
+ * Wood crate: 4 vertical corner posts + horizontal slats with gaps → the
7
+ * typical industrial wooden crate look. Forklift-friendly.
8
+ * Plastic tote: solid 4 walls + visible top lip / handle cutouts. Stackable.
9
+ *
10
+ * Both have a defined floor (so they look like containers, not just walls)
11
+ * and an opening at top — as you'd expect from a real crate / tote that's
12
+ * open or has a removable lid.
13
+ */
14
+
15
+ import * as THREE from 'three'
16
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
17
+ import { RealObjectGroup } from '@hatiolab/things-scene'
18
+
19
+ export class Box3D extends RealObjectGroup {
20
+ build() {
21
+ super.build()
22
+
23
+ const { width, height, depth = 300 } = this.component.state
24
+ const material = (this.component.state.material as string) || 'wood'
25
+ const bodyColor = (this.component.state.bodyColor as string) || '#a87644'
26
+
27
+ if (material === 'plastic') {
28
+ this.buildPlasticTote(width, height, depth, bodyColor)
29
+ } else {
30
+ this.buildWoodCrate(width, height, depth, bodyColor)
31
+ }
32
+ }
33
+
34
+ /** Wood crate — visible slats, 4 corner posts, open top. */
35
+ private buildWoodCrate(width: number, height: number, depth: number, bodyColor: string) {
36
+ const baseY = -depth / 2
37
+ const wallThickness = Math.min(width, height) * 0.04
38
+ const postW = wallThickness * 1.6
39
+ const slatH = depth * 0.10
40
+ const slatGap = slatH * 0.6
41
+ const floorH = depth * 0.05
42
+
43
+ const woodMaterial = new THREE.MeshStandardMaterial({
44
+ color: bodyColor,
45
+ metalness: 0,
46
+ roughness: 0.85
47
+ })
48
+ const postColor = new THREE.Color(bodyColor).multiplyScalar(0.8)
49
+ const postMaterial = new THREE.MeshStandardMaterial({
50
+ color: postColor,
51
+ metalness: 0,
52
+ roughness: 0.9
53
+ })
54
+
55
+ // ── 4 corner posts ───────────────────────────────────────────────
56
+ const postGeos: THREE.BufferGeometry[] = []
57
+ for (const xSign of [-1, 1]) {
58
+ for (const zSign of [-1, 1]) {
59
+ const post = new THREE.BoxGeometry(postW, depth, postW)
60
+ post.translate(
61
+ xSign * (width / 2 - postW / 2),
62
+ baseY + depth / 2,
63
+ zSign * (height / 2 - postW / 2)
64
+ )
65
+ postGeos.push(post)
66
+ }
67
+ }
68
+ const postMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(postGeos), postMaterial)
69
+ postMesh.castShadow = true
70
+ this.object3d.add(postMesh)
71
+
72
+ // ── Slatted walls (horizontal slats with gaps) ───────────────────
73
+ const slatRowCount = Math.max(2, Math.floor((depth - floorH) / (slatH + slatGap)))
74
+ const slatGeos: THREE.BufferGeometry[] = []
75
+
76
+ for (let row = 0; row < slatRowCount; row++) {
77
+ const y = baseY + floorH + slatGap + row * (slatH + slatGap) + slatH / 2
78
+ // Long walls (front / back)
79
+ for (const zSign of [-1, 1]) {
80
+ const slat = new THREE.BoxGeometry(width - postW * 2, slatH, wallThickness)
81
+ slat.translate(0, y, zSign * (height / 2 - wallThickness / 2))
82
+ slatGeos.push(slat)
83
+ }
84
+ // Short walls (left / right)
85
+ for (const xSign of [-1, 1]) {
86
+ const slat = new THREE.BoxGeometry(wallThickness, slatH, height - postW * 2)
87
+ slat.translate(xSign * (width / 2 - wallThickness / 2), y, 0)
88
+ slatGeos.push(slat)
89
+ }
90
+ }
91
+ const slatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(slatGeos), woodMaterial)
92
+ slatMesh.castShadow = true
93
+ slatMesh.receiveShadow = true
94
+ this.object3d.add(slatMesh)
95
+
96
+ // ── Floor (bottom panel) ─────────────────────────────────────────
97
+ const floorGeo = new THREE.BoxGeometry(width - postW * 2, floorH, height - postW * 2)
98
+ const floorMesh = new THREE.Mesh(floorGeo, woodMaterial)
99
+ floorMesh.position.set(0, baseY + floorH / 2, 0)
100
+ floorMesh.receiveShadow = true
101
+ this.object3d.add(floorMesh)
102
+ }
103
+
104
+ /** Plastic tote — solid molded walls + top stackable lip. */
105
+ private buildPlasticTote(width: number, height: number, depth: number, bodyColor: string) {
106
+ const baseY = -depth / 2
107
+ const wallThickness = Math.min(width, height) * 0.05
108
+ const lipH = depth * 0.06
109
+ const floorH = depth * 0.06
110
+
111
+ const totMaterial = new THREE.MeshStandardMaterial({
112
+ color: bodyColor,
113
+ metalness: 0.05,
114
+ roughness: 0.55
115
+ })
116
+ const lipColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
117
+ const lipMaterial = new THREE.MeshStandardMaterial({
118
+ color: lipColor,
119
+ metalness: 0.05,
120
+ roughness: 0.55
121
+ })
122
+
123
+ // ── 4 solid walls ────────────────────────────────────────────────
124
+ const wallGeos: THREE.BufferGeometry[] = []
125
+ const wallH = depth - lipH - floorH
126
+ const wallY = baseY + floorH + wallH / 2
127
+
128
+ // Long walls
129
+ for (const zSign of [-1, 1]) {
130
+ const wall = new THREE.BoxGeometry(width, wallH, wallThickness)
131
+ wall.translate(0, wallY, zSign * (height / 2 - wallThickness / 2))
132
+ wallGeos.push(wall)
133
+ }
134
+ // Short walls
135
+ for (const xSign of [-1, 1]) {
136
+ const wall = new THREE.BoxGeometry(wallThickness, wallH, height - 2 * wallThickness)
137
+ wall.translate(xSign * (width / 2 - wallThickness / 2), wallY, 0)
138
+ wallGeos.push(wall)
139
+ }
140
+ const wallMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(wallGeos), totMaterial)
141
+ wallMesh.castShadow = true
142
+ wallMesh.receiveShadow = true
143
+ this.object3d.add(wallMesh)
144
+
145
+ // ── Top lip (stackable rim — slightly wider than walls) ──────────
146
+ const lipGeos: THREE.BufferGeometry[] = []
147
+ const lipY = baseY + depth - lipH / 2
148
+ // Long sides
149
+ for (const zSign of [-1, 1]) {
150
+ const lip = new THREE.BoxGeometry(width * 1.02, lipH, wallThickness * 1.5)
151
+ lip.translate(0, lipY, zSign * (height / 2 - wallThickness * 0.75))
152
+ lipGeos.push(lip)
153
+ }
154
+ // Short sides
155
+ for (const xSign of [-1, 1]) {
156
+ const lip = new THREE.BoxGeometry(wallThickness * 1.5, lipH, height * 1.02 - 2 * wallThickness * 1.5)
157
+ lip.translate(xSign * (width / 2 - wallThickness * 0.75), lipY, 0)
158
+ lipGeos.push(lip)
159
+ }
160
+ const lipMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(lipGeos), lipMaterial)
161
+ lipMesh.castShadow = true
162
+ this.object3d.add(lipMesh)
163
+
164
+ // ── Floor (solid bottom) ─────────────────────────────────────────
165
+ const floorGeo = new THREE.BoxGeometry(width, floorH, height)
166
+ const floorMesh = new THREE.Mesh(floorGeo, totMaterial)
167
+ floorMesh.position.set(0, baseY + floorH / 2, 0)
168
+ floorMesh.receiveShadow = true
169
+ this.object3d.add(floorMesh)
170
+ }
171
+
172
+ updateDimension() {}
173
+
174
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
175
+ if (
176
+ 'material' in after ||
177
+ 'bodyColor' in after ||
178
+ 'width' in after ||
179
+ 'height' in after ||
180
+ 'depth' in after
181
+ ) {
182
+ this.update()
183
+ return
184
+ }
185
+ super.onchange(after, before)
186
+ }
187
+
188
+ updateAlpha() {}
189
+ }
package/src/box.ts ADDED
@@ -0,0 +1,99 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { Component, ComponentNature, RealObject, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
5
+ import {
6
+ Carriable,
7
+ Legendable,
8
+ Placeable,
9
+ type Alignment,
10
+ type LegendBinding,
11
+ type PlacementArchetype
12
+ } from '@operato/scene-base'
13
+
14
+ import { Box3D } from './box-3d.js'
15
+
16
+ /**
17
+ * Box material — drives 3D structure and color.
18
+ *
19
+ * - `wood` — wood crate: visible vertical slats, gaps between, open or
20
+ * semi-open top. Used for heavy / industrial parts.
21
+ * - `plastic` — plastic tote / bin: solid molded walls with stackable lip
22
+ * at top. Used for fulfillment, parts kitting.
23
+ *
24
+ * Cardboard parcels are a separate component (see `parcel.ts`) — they have
25
+ * different proportions, taping, and labels that warrant a distinct class.
26
+ */
27
+ export type BoxMaterial = 'wood' | 'plastic'
28
+
29
+ const BODY_LEGEND = {
30
+ wood: '#a87644',
31
+ plastic: '#3a5078',
32
+ default: '#a87644'
33
+ }
34
+
35
+ const NATURE: ComponentNature = {
36
+ mutable: false,
37
+ resizable: true,
38
+ rotatable: true,
39
+ properties: [
40
+ {
41
+ type: 'select',
42
+ label: 'material',
43
+ name: 'material',
44
+ property: {
45
+ options: [
46
+ { display: 'Wood', value: 'wood' },
47
+ { display: 'Plastic', value: 'plastic' }
48
+ ]
49
+ }
50
+ }
51
+ ],
52
+ help: 'scene/component/box'
53
+ }
54
+
55
+ // Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,
56
+ // AGV deck, robot-arm gripper, Spot for staging).
57
+ const Base = Carriable(Legendable(Placeable(RectPath(Shape)))) as unknown as typeof Component
58
+
59
+ /**
60
+ * Box — a generic stackable container for goods. Wood crate or plastic tote
61
+ * variants distinguished by `material` prop.
62
+ *
63
+ * Shape-based (not Container) — boxes nesting other components is rare in
64
+ * logistics visualization (a *case* of items inside a box is data, not
65
+ * scene-tree). If a future use case needs nested boxes, extend Container.
66
+ */
67
+ @sceneComponent('box')
68
+ export default class Box extends Base {
69
+ static legends: Record<string, LegendBinding> = {
70
+ bodyColor: { from: 'material', legend: BODY_LEGEND }
71
+ }
72
+
73
+ static placement: PlacementArchetype = 'operation'
74
+ static align: Alignment = 'bottom'
75
+ static defaultDepth = 300
76
+
77
+ get nature() {
78
+ return NATURE
79
+ }
80
+
81
+ get anchors() {
82
+ return []
83
+ }
84
+
85
+ /** 2D — top-down rectangle. */
86
+ render(ctx: CanvasRenderingContext2D) {
87
+ const { width, height, left, top } = this.state
88
+ ctx.beginPath()
89
+ ctx.rect(left, top, width, height)
90
+ }
91
+
92
+ get fillStyle() {
93
+ return (this.state.bodyColor as string) || '#a87644'
94
+ }
95
+
96
+ buildRealObject(): RealObject | undefined {
97
+ return new Box3D(this as any)
98
+ }
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ export { default as Pallet } from './pallet.js'
5
+ export type { PalletMaterial } from './pallet.js'
6
+
7
+ export { default as Box } from './box.js'
8
+ export type { BoxMaterial } from './box.js'
9
+
10
+ export { default as Parcel } from './parcel.js'
11
+
12
+ export { default as AsrsRack } from './asrs-rack.js'
13
+ export { default as AsrsCrane } from './asrs-crane.js'
14
+ export type { AsrsCraneStatus } from './asrs-crane.js'
15
+
16
+ export { default as Spot } from './spot.js'
17
+ export { Spot3D } from './spot-3d.js'
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Pallet 3D — wood and plastic variants.
5
+ *
6
+ * LO-POLY but structurally distinguishing the two materials:
7
+ *
8
+ * - wood: parallel top slats with gaps between + 3 perpendicular
9
+ * stringers (the classic EUR pallet silhouette) + parallel
10
+ * bottom slats. The forklift entry holes between stringers are
11
+ * the wood pallet's visual signature.
12
+ * - plastic: solid molded top deck (with a few suggestion cutouts as visual
13
+ * detail) + ribbed underside / feet. No discrete slats — the
14
+ * plastic pallet's signature is the seamless one-piece look.
15
+ *
16
+ * Color comes from `state.bodyColor` (Legendable, driven by `material`).
17
+ * Stringer / underside colors are slightly darker tints derived from bodyColor.
18
+ */
19
+
20
+ import * as THREE from 'three'
21
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
22
+ import { RealObjectGroup } from '@hatiolab/things-scene'
23
+
24
+ export class Pallet3D extends RealObjectGroup {
25
+ build() {
26
+ super.build()
27
+
28
+ const { width, height, depth = 150 } = this.component.state
29
+ const material = (this.component.state.material as string) || 'wood'
30
+ const bodyColor = (this.component.state.bodyColor as string) || '#a87644'
31
+
32
+ if (material === 'plastic') {
33
+ this.buildPlastic(width, height, depth, bodyColor)
34
+ } else {
35
+ this.buildWood(width, height, depth, bodyColor)
36
+ }
37
+ }
38
+
39
+ /** Wood EUR-style: 7 top slats + 3 stringers + 5 bottom slats. */
40
+ private buildWood(width: number, height: number, depth: number, bodyColor: string) {
41
+ const baseY = -depth / 2
42
+ const slatThickness = depth * 0.15
43
+ const stringerThickness = depth * 0.45
44
+ const bottomSlatThickness = depth * 0.13
45
+
46
+ const woodMaterial = new THREE.MeshStandardMaterial({
47
+ color: bodyColor,
48
+ metalness: 0.0,
49
+ roughness: 0.85
50
+ })
51
+ const stringerColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
52
+ const stringerMaterial = new THREE.MeshStandardMaterial({
53
+ color: stringerColor,
54
+ metalness: 0.0,
55
+ roughness: 0.9
56
+ })
57
+
58
+ // ── Top + bottom slats — same count, same z-positions, paired vertically ─
59
+ // EUR-pallet style: 5 boards on top, 5 below (under the same z ranges so
60
+ // they read as a single skeleton rather than two unrelated grids).
61
+ const slatCount = 5
62
+ const slatW = width
63
+ const slatD = (height * 0.92) / (slatCount + (slatCount - 1) * 0.4)
64
+ const gapD = slatD * 0.4
65
+ const totalSpan = slatCount * slatD + (slatCount - 1) * gapD
66
+ const startZ = -totalSpan / 2
67
+
68
+ const slatPositions: number[] = []
69
+ for (let i = 0; i < slatCount; i++) {
70
+ slatPositions.push(startZ + i * (slatD + gapD) + slatD / 2)
71
+ }
72
+
73
+ const topSlatGeos: THREE.BufferGeometry[] = []
74
+ for (const z of slatPositions) {
75
+ const slat = new THREE.BoxGeometry(slatW, slatThickness, slatD)
76
+ slat.translate(0, baseY + depth - slatThickness / 2, z)
77
+ topSlatGeos.push(slat)
78
+ }
79
+ const topSlatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(topSlatGeos), woodMaterial)
80
+ topSlatMesh.castShadow = true
81
+ topSlatMesh.receiveShadow = true
82
+ this.object3d.add(topSlatMesh)
83
+
84
+ // ── Stringers (3 perpendicular blocks between top and bottom decks) ─
85
+ const stringerCount = 3
86
+ const stringerW = width * 0.07
87
+ const stringerY = baseY + bottomSlatThickness + stringerThickness / 2
88
+ const stringerGeos: THREE.BufferGeometry[] = []
89
+ for (let i = 0; i < stringerCount; i++) {
90
+ const xFrac = i / (stringerCount - 1) - 0.5
91
+ const x = xFrac * (width * 0.85)
92
+ const stringer = new THREE.BoxGeometry(stringerW, stringerThickness, height)
93
+ stringer.translate(x, stringerY, 0)
94
+ stringerGeos.push(stringer)
95
+ }
96
+ const stringerMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(stringerGeos), stringerMaterial)
97
+ stringerMesh.castShadow = true
98
+ this.object3d.add(stringerMesh)
99
+
100
+ // ── Bottom slats — same z-positions as top so the deck reads as paired ─
101
+ const botSlatGeos: THREE.BufferGeometry[] = []
102
+ for (const z of slatPositions) {
103
+ const slat = new THREE.BoxGeometry(width, bottomSlatThickness, slatD)
104
+ slat.translate(0, baseY + bottomSlatThickness / 2, z)
105
+ botSlatGeos.push(slat)
106
+ }
107
+ const botSlatMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(botSlatGeos), woodMaterial)
108
+ botSlatMesh.receiveShadow = true
109
+ this.object3d.add(botSlatMesh)
110
+ }
111
+
112
+ /** Plastic molded: solid top deck + ribbed underside / feet. */
113
+ private buildPlastic(width: number, height: number, depth: number, bodyColor: string) {
114
+ const baseY = -depth / 2
115
+ const deckThickness = depth * 0.30
116
+ const footH = depth * 0.55
117
+ const footW = width * 0.12
118
+
119
+ const deckMaterial = new THREE.MeshStandardMaterial({
120
+ color: bodyColor,
121
+ metalness: 0.1,
122
+ roughness: 0.55
123
+ })
124
+ const footColor = new THREE.Color(bodyColor).multiplyScalar(0.85)
125
+ const footMaterial = new THREE.MeshStandardMaterial({
126
+ color: footColor,
127
+ metalness: 0.1,
128
+ roughness: 0.65
129
+ })
130
+
131
+ // ── Solid top deck ───────────────────────────────────────────────
132
+ const deckGeo = new THREE.BoxGeometry(width * 0.98, deckThickness, height * 0.98)
133
+ const deckMesh = new THREE.Mesh(deckGeo, deckMaterial)
134
+ deckMesh.position.set(0, baseY + depth - deckThickness / 2, 0)
135
+ deckMesh.castShadow = true
136
+ deckMesh.receiveShadow = true
137
+ this.object3d.add(deckMesh)
138
+
139
+ // ── 9 feet (3×3 grid — typical plastic pallet underside) ─────────
140
+ const footGeos: THREE.BufferGeometry[] = []
141
+ for (let i = -1; i <= 1; i++) {
142
+ for (let j = -1; j <= 1; j++) {
143
+ const x = i * (width * 0.4)
144
+ const z = j * (height * 0.4)
145
+ const foot = new THREE.BoxGeometry(footW, footH, footW)
146
+ foot.translate(x, baseY + footH / 2, z)
147
+ footGeos.push(foot)
148
+ }
149
+ }
150
+ const footMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(footGeos), footMaterial)
151
+ footMesh.castShadow = true
152
+ this.object3d.add(footMesh)
153
+
154
+ // ── Cross-bracing along underside (suggests molded reinforcement) ─
155
+ const braceH = depth * 0.10
156
+ const braceGeo = new THREE.BoxGeometry(width * 0.95, braceH, height * 0.04)
157
+ for (const zSign of [-1, 0, 1]) {
158
+ const brace = new THREE.Mesh(braceGeo.clone(), footMaterial)
159
+ brace.position.set(0, baseY + footH - braceH / 2, zSign * height * 0.4)
160
+ this.object3d.add(brace)
161
+ }
162
+ }
163
+
164
+ updateDimension() {}
165
+
166
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
167
+ if (
168
+ 'material' in after ||
169
+ 'bodyColor' in after ||
170
+ 'width' in after ||
171
+ 'height' in after ||
172
+ 'depth' in after
173
+ ) {
174
+ this.update()
175
+ return
176
+ }
177
+ super.onchange(after, before)
178
+ }
179
+
180
+ updateAlpha() {}
181
+ }