@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
package/src/pallet.ts ADDED
@@ -0,0 +1,125 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ import { Component, ComponentNature, Container, RealObject, 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 { Pallet3D } from './pallet-3d.js'
15
+
16
+ /**
17
+ * Pallet material — drives both 2D fill color and 3D structure.
18
+ *
19
+ * - `wood` — traditional EUR / EPAL pallet: parallel slats on top and
20
+ * bottom, three perpendicular stringers between them.
21
+ * - `plastic` — molded one-piece pallet: solid top deck with cutouts,
22
+ * hollow underside with feet. Distinct ribbed underside.
23
+ *
24
+ * Adding a third material (e.g. metal, composite) is a one-line change to the
25
+ * legend + a 3D variant in pallet-3d.ts.
26
+ */
27
+ export type PalletMaterial = 'wood' | 'plastic'
28
+
29
+ const BODY_LEGEND = {
30
+ wood: '#a87644',
31
+ plastic: '#5a6a78',
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/pallet'
53
+ }
54
+
55
+ // Carriable: a pallet can sit on AGV / Forklift / robot-arm gripper / Spot
56
+ // and also accept boxes / parcels as children (Container base provides the
57
+ // child-container behavior; Carriable only adds the holder-mount hook).
58
+ const Base = Carriable(Legendable(Placeable(Container))) as unknown as typeof Component
59
+
60
+ /**
61
+ * Pallet — a flat transport structure that goods are stacked and stored on.
62
+ *
63
+ * Standard EUR pallet is 1200 × 800mm × 144mm; we don't enforce these
64
+ * dimensions but they're a good starting point for the catalog templates.
65
+ *
66
+ * **Container-based.** Boxes / parcels stacked on the pallet are added as
67
+ * children — same `containable()` archetype-filter pattern as Forklift / Agv.
68
+ * Visual stacking (children rendering on top of the pallet rather than at
69
+ * absolute operation level) is a v2 concern; see ARCHITECTURE NOTES below.
70
+ *
71
+ * **Placement = `operation`.** A pallet's *normal* state is loaded and in
72
+ * transit on a conveyor / AGV / forklift fork — at operation level. Empty
73
+ * pallets in a floor-storage area are an exceptional state where the user
74
+ * sets `state.zPos = 0` explicitly. Default to the common case.
75
+ *
76
+ * ## ARCHITECTURE NOTES — visual stacking
77
+ *
78
+ * When a Box (also `placement: 'operation'`) is added as a child of a
79
+ * Pallet, both default to z = operation_height. They overlap visually
80
+ * rather than the box sitting on top of the pallet. Solving this cleanly
81
+ * (parent-relative z derivation when the parent is a structural carrier)
82
+ * is a follow-up — fmsim's pattern is to detect parent type at render time
83
+ * (machine-3d.ts:113-122). v1 accepts the visual overlap; v2 will add the
84
+ * detection.
85
+ */
86
+ @sceneComponent('pallet')
87
+ export default class Pallet extends Base {
88
+ static legends: Record<string, LegendBinding> = {
89
+ bodyColor: { from: 'material', legend: BODY_LEGEND }
90
+ }
91
+
92
+ static placement: PlacementArchetype = 'operation'
93
+ static align: Alignment = 'bottom'
94
+ static defaultDepth = 150 // EUR pallet is 144mm; 150 is the round number convention
95
+
96
+ get nature() {
97
+ return NATURE
98
+ }
99
+
100
+ get anchors() {
101
+ return []
102
+ }
103
+
104
+ /** Accept other operation-archetype cargo (boxes, parcels, smaller pallets) as stacked children. */
105
+ containable(component: Component) {
106
+ const archetype = (component.constructor as any).placement
107
+ if (archetype === 'operation') return true
108
+ return component.isDescendible(this as any)
109
+ }
110
+
111
+ /** 2D — top-down rectangle. */
112
+ render(ctx: CanvasRenderingContext2D) {
113
+ const { width, height, left, top } = this.state
114
+ ctx.beginPath()
115
+ ctx.rect(left, top, width, height)
116
+ }
117
+
118
+ get fillStyle() {
119
+ return (this.state.bodyColor as string) || '#a87644'
120
+ }
121
+
122
+ buildRealObject(): RealObject | undefined {
123
+ return new Pallet3D(this as any)
124
+ }
125
+ }
@@ -0,0 +1,90 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Parcel 3D — a cardboard package.
5
+ *
6
+ * Structure:
7
+ * - main body box (cardboard color)
8
+ * - tape line running across the top (the visual signature — what makes
9
+ * this read as a "shipping parcel" rather than a generic box)
10
+ * - small label area on top (white rectangle suggesting a shipping label)
11
+ *
12
+ * Kept very simple — parcels in a logistics scene are typically present in
13
+ * large numbers (sortation lines, fulfillment bays), so polygon count
14
+ * matters more than it does for one-off equipment.
15
+ */
16
+
17
+ import * as THREE from 'three'
18
+ import { RealObjectGroup } from '@hatiolab/things-scene'
19
+
20
+ const CARDBOARD_COLOR = 0xc8a878
21
+ const TAPE_COLOR = 0xddc899
22
+ const LABEL_COLOR = 0xeeeeee
23
+
24
+ export class Parcel3D extends RealObjectGroup {
25
+ build() {
26
+ super.build()
27
+
28
+ const { width, height, depth = 150 } = this.component.state
29
+ const baseY = -depth / 2
30
+
31
+ // ── Main body ────────────────────────────────────────────────────
32
+ const bodyGeo = new THREE.BoxGeometry(width, depth, height)
33
+ const bodyMaterial = new THREE.MeshStandardMaterial({
34
+ color: CARDBOARD_COLOR,
35
+ metalness: 0,
36
+ roughness: 0.9
37
+ })
38
+ const bodyMesh = new THREE.Mesh(bodyGeo, bodyMaterial)
39
+ bodyMesh.position.set(0, 0, 0)
40
+ bodyMesh.castShadow = true
41
+ bodyMesh.receiveShadow = true
42
+ this.object3d.add(bodyMesh)
43
+
44
+ // ── Tape line on top (running along the long axis) ───────────────
45
+ const tapeW = Math.min(width, height) * 0.10
46
+ const tapeT = depth * 0.02
47
+ const tapeAlongLong = width >= height
48
+ const tapeGeo = tapeAlongLong
49
+ ? new THREE.BoxGeometry(width * 1.005, tapeT, tapeW)
50
+ : new THREE.BoxGeometry(tapeW, tapeT, height * 1.005)
51
+ const tapeMaterial = new THREE.MeshStandardMaterial({
52
+ color: TAPE_COLOR,
53
+ metalness: 0.05,
54
+ roughness: 0.5
55
+ })
56
+ const tapeMesh = new THREE.Mesh(tapeGeo, tapeMaterial)
57
+ tapeMesh.position.set(0, baseY + depth + tapeT / 2 - 0.01, 0)
58
+ this.object3d.add(tapeMesh)
59
+
60
+ // ── Shipping label (small white rectangle on top) ────────────────
61
+ const labelW = Math.min(width, height) * 0.35
62
+ const labelH = labelW * 0.6
63
+ const labelGeo = new THREE.BoxGeometry(labelW, depth * 0.005, labelH)
64
+ const labelMaterial = new THREE.MeshStandardMaterial({
65
+ color: LABEL_COLOR,
66
+ metalness: 0,
67
+ roughness: 0.4
68
+ })
69
+ const labelMesh = new THREE.Mesh(labelGeo, labelMaterial)
70
+ // Position on top, off-center by ~25% of long axis
71
+ if (tapeAlongLong) {
72
+ labelMesh.position.set(width * 0.2, baseY + depth + depth * 0.0025, -height * 0.15)
73
+ } else {
74
+ labelMesh.position.set(width * 0.15, baseY + depth + depth * 0.0025, height * 0.2)
75
+ }
76
+ this.object3d.add(labelMesh)
77
+ }
78
+
79
+ updateDimension() {}
80
+
81
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
82
+ if ('width' in after || 'height' in after || 'depth' in after) {
83
+ this.update()
84
+ return
85
+ }
86
+ super.onchange(after, before)
87
+ }
88
+
89
+ updateAlpha() {}
90
+ }
package/src/parcel.ts ADDED
@@ -0,0 +1,76 @@
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
+ Placeable,
8
+ type Alignment,
9
+ type PlacementArchetype
10
+ } from '@operato/scene-base'
11
+
12
+ import { Parcel3D } from './parcel-3d.js'
13
+
14
+ const NATURE: ComponentNature = {
15
+ mutable: false,
16
+ resizable: true,
17
+ rotatable: true,
18
+ properties: [
19
+ {
20
+ type: 'string',
21
+ label: 'tracking-id',
22
+ name: 'trackingId'
23
+ }
24
+ ],
25
+ help: 'scene/component/parcel'
26
+ }
27
+
28
+ // Carriable: parcel can be a child of any CarrierHolder (Spot, robot-arm
29
+ // gripper, AGV deck, …). Mixin wraps add() so the parcel's 3D object3d
30
+ // is reattached to the holder's chosen mount frame.
31
+ const Base = Carriable(Placeable(RectPath(Shape))) as unknown as typeof Component
32
+
33
+ /**
34
+ * Parcel — a cardboard package, the typical e-commerce / parcel-sortation unit.
35
+ *
36
+ * Distinct from `Box` because parcels have:
37
+ * - cardboard appearance (tan/brown corrugate, not wood / plastic)
38
+ * - tape line down the center (the visual signature that says "package")
39
+ * - typically a label on top (where shipping info goes)
40
+ * - flatter / more elongated proportions in real-world parcel networks
41
+ *
42
+ * No `material` prop — parcels are always cardboard. If a future shipping
43
+ * domain needs metal cases or polybags, those become separate components.
44
+ *
45
+ * No Legendable for v1 — parcel color is fixed cardboard. Future damaged /
46
+ * inspected indicators would add a status legend then.
47
+ */
48
+ @sceneComponent('parcel')
49
+ export default class Parcel extends Base {
50
+ static placement: PlacementArchetype = 'operation'
51
+ static align: Alignment = 'bottom'
52
+ static defaultDepth = 150
53
+
54
+ get nature() {
55
+ return NATURE
56
+ }
57
+
58
+ get anchors() {
59
+ return []
60
+ }
61
+
62
+ /** 2D — top-down rectangle in cardboard tan. */
63
+ render(ctx: CanvasRenderingContext2D) {
64
+ const { width, height, left, top } = this.state
65
+ ctx.beginPath()
66
+ ctx.rect(left, top, width, height)
67
+ }
68
+
69
+ get fillStyle() {
70
+ return '#c8a878'
71
+ }
72
+
73
+ buildRealObject(): RealObject | undefined {
74
+ return new Parcel3D(this as any)
75
+ }
76
+ }
package/src/spot-3d.ts ADDED
@@ -0,0 +1,200 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Spot 3D — translucent floor pad.
5
+ *
6
+ * Renders only the FLOOR face of the conceptual zone box; side walls are
7
+ * absent. Children (carriers) sit on the top face via an explicit attach
8
+ * frame; the pad doesn't occlude them (`depthWrite: false`).
9
+ *
10
+ * Standard things-scene properties read directly:
11
+ * - state.fillStyle → pad color (sole color source)
12
+ * - state.strokeStyle → outline color (defaults to fillStyle)
13
+ * - state.alpha → pad transparency, multiplied with the base 0.35 tint
14
+ * - state.text → label, rendered as a CanvasTexture quad on the pad
15
+ * - state.fontColor → label fill (defaults to fillStyle)
16
+ * - state.fontSize / fontFamily / bold / italic → label typography
17
+ * (composed via the things-scene fontStyle helper)
18
+ * - state.material3d → metalness / roughness / castShadow / receiveShadow
19
+ * (resolved + applied via the things-scene helpers,
20
+ * no hard-coded numbers)
21
+ */
22
+
23
+ import * as THREE from 'three'
24
+ import {
25
+ RealObjectGroup,
26
+ resolveMaterial3d,
27
+ applyMaterial3dProps,
28
+ fontStyle,
29
+ opaqueColor,
30
+ type Material3D
31
+ } from '@hatiolab/things-scene'
32
+
33
+ const DEFAULT_PAD_COLOR = '#3a8fbd'
34
+ const BASE_PAD_OPACITY = 0.35 // multiplied by state.alpha
35
+
36
+ export class Spot3D extends RealObjectGroup {
37
+ build() {
38
+ super.build()
39
+
40
+ const state = this.component.state as any
41
+ const w = Math.max(Math.abs(numOr(state.width, 100)), 1)
42
+ const h = Math.max(Math.abs(numOr(state.height, 100)), 1)
43
+ const d = this.effectiveDepth // 2 by default (thin pad)
44
+ // opaqueColor strips alpha from rgba/hsla strings — THREE.Color doesn't
45
+ // honor alpha, would emit a console warning, and ignore the alpha bit.
46
+ // The actual transparency comes through the material.opacity below.
47
+ const padColor = opaqueColor((state.fillStyle as string) || DEFAULT_PAD_COLOR)
48
+ const alpha = clamp(numOr(state.alpha, 1), 0, 1)
49
+ const padOpacity = clamp(BASE_PAD_OPACITY * alpha, 0.05, 1)
50
+
51
+ // material3d: pulls user-set metalness / roughness / shadow / side.
52
+ // Local defaults (transparent + DoubleSide + depthWrite:false) come
53
+ // from the constructor below; user values override via applyMaterial3dProps.
54
+ const resolved = resolveMaterial3d(state.material3d as Material3D | undefined)
55
+
56
+ // ── Floor pad (the only visible surface of the conceptual zone box) ──
57
+ const padThickness = Math.max(d * 0.4, 0.5)
58
+ const padMat = new THREE.MeshStandardMaterial({
59
+ color: padColor,
60
+ transparent: true,
61
+ opacity: padOpacity,
62
+ side: THREE.DoubleSide,
63
+ depthWrite: false
64
+ })
65
+ applyMaterial3dProps(padMat, resolved)
66
+ const pad = new THREE.Mesh(new THREE.BoxGeometry(w, padThickness, h), padMat)
67
+ pad.position.set(0, -d / 2 + padThickness / 2, 0)
68
+ pad.castShadow = resolved.castShadow
69
+ pad.receiveShadow = resolved.receiveShadow
70
+ this.object3d.add(pad)
71
+
72
+ // ── Outline of the zone footprint (line on the floor) ──
73
+ const outlineGeo = new THREE.BufferGeometry().setFromPoints([
74
+ new THREE.Vector3(-w / 2, -d / 2 + padThickness + 0.05, -h / 2),
75
+ new THREE.Vector3(w / 2, -d / 2 + padThickness + 0.05, -h / 2),
76
+ new THREE.Vector3(w / 2, -d / 2 + padThickness + 0.05, h / 2),
77
+ new THREE.Vector3(-w / 2, -d / 2 + padThickness + 0.05, h / 2),
78
+ new THREE.Vector3(-w / 2, -d / 2 + padThickness + 0.05, -h / 2)
79
+ ])
80
+ const outlineMat = new THREE.LineBasicMaterial({
81
+ color: opaqueColor((state.strokeStyle as string) || padColor),
82
+ transparent: true,
83
+ opacity: 0.7 * alpha
84
+ })
85
+ const outline = new THREE.Line(outlineGeo, outlineMat)
86
+ this.object3d.add(outline)
87
+
88
+ // ── Label (uses standard text + font fields) ──
89
+ const text = state.text
90
+ if (typeof text === 'string' && text.length > 0) {
91
+ const label = this._buildLabel(text, state, padColor, w, h)
92
+ if (label) {
93
+ label.position.set(0, -d / 2 + padThickness + Math.max(w, h) * 0.05, 0)
94
+ this.object3d.add(label)
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Build the label as a canvas-textured quad. Uses the same `fontStyle`
101
+ * helper things-scene uses for its 2D text rendering, so the label
102
+ * here matches what the property panel previews.
103
+ */
104
+ private _buildLabel(text: string, state: any, defaultColor: string, w: number, h: number): THREE.Mesh | null {
105
+ const fontSize = clamp(numOr(state.fontSize, 36), 8, 200)
106
+ const fontFamily = String(state.fontFamily ?? 'sans-serif')
107
+ const bold = !!state.bold
108
+ const italic = !!state.italic
109
+ const color = (state.fontColor as string) || defaultColor
110
+
111
+ const canvas = document.createElement('canvas')
112
+ canvas.width = 512
113
+ canvas.height = 128
114
+ const ctx = canvas.getContext('2d')
115
+ if (!ctx) return null
116
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
117
+ ctx.font = fontStyle(bold, italic, fontSize, fontFamily)
118
+ ctx.fillStyle = color
119
+ ctx.textAlign = 'center'
120
+ ctx.textBaseline = 'middle'
121
+ ctx.fillText(text, canvas.width / 2, canvas.height / 2)
122
+ const tex = new THREE.CanvasTexture(canvas)
123
+ tex.needsUpdate = true
124
+ const labelMat = new THREE.MeshBasicMaterial({
125
+ map: tex,
126
+ transparent: true,
127
+ depthWrite: false,
128
+ side: THREE.DoubleSide
129
+ })
130
+ const labelW = Math.min(w, h) * 0.6
131
+ const labelH = labelW * (canvas.height / canvas.width)
132
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(labelW, labelH), labelMat)
133
+ mesh.rotation.x = -Math.PI / 2 // lay flat, readable from above
134
+ return mesh
135
+ }
136
+
137
+ /**
138
+ * The sub-frame that carrier components should mount onto.
139
+ *
140
+ * Semantically Spot is a virtual cuboid SPACE — it marks "stuff goes
141
+ * here" — and carriers are placed INSIDE that space, resting on the
142
+ * cuboid's BOTTOM face. So the attach frame sits at the cuboid's
143
+ * bottom (`y = -d/2` in spot-local), NOT at the top of the rendered
144
+ * pad. The pad is just a translucent visual marker for the zone; the
145
+ * floor of the conceptual volume is what carriers stand on.
146
+ *
147
+ * The Spot.attachPointFor mixin lifts the carrier by its own halfDepth
148
+ * (in the +Y direction within this frame), placing the carrier's
149
+ * BOTTOM face exactly at the cuboid floor.
150
+ */
151
+ getAttachFrame(): THREE.Object3D {
152
+ if (!this._attachFrame) {
153
+ const d = this.effectiveDepth
154
+ this._attachFrame = new THREE.Object3D()
155
+ this._attachFrame.position.set(0, -d / 2, 0)
156
+ this.object3d.add(this._attachFrame)
157
+ }
158
+ return this._attachFrame
159
+ }
160
+
161
+ private _attachFrame?: THREE.Object3D
162
+
163
+ updateDimension() {}
164
+
165
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
166
+ if (
167
+ 'width' in after ||
168
+ 'height' in after ||
169
+ 'depth' in after ||
170
+ 'fillStyle' in after ||
171
+ 'strokeStyle' in after ||
172
+ 'alpha' in after ||
173
+ 'text' in after ||
174
+ 'fontColor' in after ||
175
+ 'fontSize' in after ||
176
+ 'fontFamily' in after ||
177
+ 'bold' in after ||
178
+ 'italic' in after ||
179
+ 'material3d' in after
180
+ ) {
181
+ this._attachFrame = undefined
182
+ this.update()
183
+ return
184
+ }
185
+ super.onchange(after, before)
186
+ }
187
+
188
+ // alpha is rebuilt into the materials on every build; opt out of the
189
+ // base RealObject's "multiply existing material opacity" pass to avoid
190
+ // double-application.
191
+ updateAlpha() {}
192
+ }
193
+
194
+ function clamp(v: number, lo: number, hi: number) {
195
+ return Math.max(lo, Math.min(hi, v))
196
+ }
197
+
198
+ function numOr(v: unknown, dflt: number): number {
199
+ return typeof v === 'number' && Number.isFinite(v) ? v : dflt
200
+ }
package/src/spot.ts ADDED
@@ -0,0 +1,197 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Spot — virtual pickup / drop zone.
5
+ *
6
+ * A modeling-time anchor for "this is where things land" — the destination
7
+ * of a robot arm pick-and-place, the slot of an AGV stop, the staging
8
+ * zone next to a conveyor. Spot itself does not move and does
9
+ * not perform any logistics action; it only marks a location and accepts
10
+ * carrier components as children.
11
+ *
12
+ * Visual identity:
13
+ * - 2D: outlined rectangle with corner "L" marks (so it reads as a
14
+ * virtual zone, not a solid object).
15
+ * - 3D: a thin translucent floor pad — only the floor of the conceptual
16
+ * box is rendered, the side walls are absent.
17
+ *
18
+ * Standard things-scene properties used (no component-specific extras —
19
+ * keep the property-panel UX uniform with other components):
20
+ * - `fillStyle` — pad / outline color (sole color source)
21
+ * - `strokeStyle` — outline color override (defaults to fillStyle)
22
+ * - `lineWidth` / `lineDash` — outline stroke style
23
+ * - `alpha` — overall transparency, framework-applied
24
+ * - `text` / `fontColor` / `fontSize` / `fontFamily` / `bold` / `italic`
25
+ * — label rendered by the standard text pipeline
26
+ * - `material3d` (3D) — metalness / roughness / castShadow / receiveShadow
27
+ *
28
+ * Role: `CarrierHolder` — accepts any Carrier as a child and lays it on
29
+ * the top face of the pad (overrides default attachPointFor).
30
+ */
31
+
32
+ import { Component, ComponentNature, Container, RealObject, sceneComponent } from '@hatiolab/things-scene'
33
+ import {
34
+ CarrierHolder,
35
+ Placeable,
36
+ type Alignment,
37
+ type Heights,
38
+ type PlacementArchetype
39
+ } from '@operato/scene-base'
40
+
41
+ import { Spot3D } from './spot-3d.js'
42
+
43
+ const NATURE: ComponentNature = {
44
+ mutable: false,
45
+ resizable: true,
46
+ rotatable: true,
47
+ // No component-specific properties — fillStyle / strokeStyle / lineWidth /
48
+ // alpha / text / font* are framework-standard, surfaced by the property
49
+ // panel automatically.
50
+ properties: [],
51
+ help: 'scene/component/spot'
52
+ }
53
+
54
+ // Container base — Spot accepts carrier children (parcel/box/pallet/...).
55
+ // CarrierHolder mixin only publishes the attach-point hook; the actual
56
+ // child-list management comes from the things-scene Container.
57
+ const Base = CarrierHolder(Placeable(Container)) as unknown as typeof Component
58
+
59
+ @sceneComponent('spot')
60
+ export default class Spot extends Base {
61
+ static placement: PlacementArchetype = 'floor'
62
+ static align: Alignment = 'bottom'
63
+ static defaultDepth = (_h: Heights) => 2 // a thin pad
64
+
65
+ get nature(): ComponentNature {
66
+ return NATURE
67
+ }
68
+
69
+ get anchors() {
70
+ return []
71
+ }
72
+
73
+ /**
74
+ * 2D — outlined rectangle + corner L marks. The pad body is drawn with a
75
+ * fixed low alpha (0.15) on top of the user's `fillStyle` so the zone reads
76
+ * as virtual even when fillStyle is fully opaque. Outline + corner marks
77
+ * use `strokeStyle` (or fall back to `fillStyle`) at the user's `lineWidth`
78
+ * and `lineDash`. The text label is drawn by the framework's standard
79
+ * postrender pipeline using `text` / `fontColor` / `fontSize` / etc.
80
+ */
81
+ render(ctx: CanvasRenderingContext2D) {
82
+ const { left = 0, top = 0, width = 100, height = 100 } = this.state
83
+ const fillStyle = (this.state.fillStyle as string) || '#3a8fbd'
84
+ const strokeStyle = (this.state.strokeStyle as string) || fillStyle
85
+ const lineWidth = numOr(this.state.lineWidth, 1)
86
+ const lineDashStyle = String(this.state.lineDash ?? 'dash')
87
+
88
+ // ── Pad body (fixed-low-alpha tint of fillStyle) ───────────────────
89
+ ctx.save()
90
+ ctx.fillStyle = fillStyle
91
+ ctx.globalAlpha = 0.15
92
+ ctx.fillRect(left, top, width, height)
93
+ ctx.restore()
94
+
95
+ // ── Outline ────────────────────────────────────────────────────────
96
+ ctx.save()
97
+ ctx.strokeStyle = strokeStyle
98
+ ctx.lineWidth = lineWidth
99
+ applyLineDash(ctx, lineDashStyle, lineWidth)
100
+ ctx.strokeRect(
101
+ left + lineWidth / 2,
102
+ top + lineWidth / 2,
103
+ width - lineWidth,
104
+ height - lineWidth
105
+ )
106
+ ctx.setLineDash([])
107
+ ctx.restore()
108
+
109
+ // ── Corner L marks (solid, slightly heavier than outline) ──────────
110
+ const ml = Math.min(width, height) * 0.18
111
+ const cornerW = Math.max(lineWidth * 1.5, 1.5)
112
+ ctx.save()
113
+ ctx.strokeStyle = strokeStyle
114
+ ctx.lineWidth = cornerW
115
+ for (const [cx, cy, sx, sy] of [
116
+ [left, top, 1, 1],
117
+ [left + width, top, -1, 1],
118
+ [left + width, top + height, -1, -1],
119
+ [left, top + height, 1, -1]
120
+ ] as [number, number, number, number][]) {
121
+ ctx.beginPath()
122
+ ctx.moveTo(cx + sx * ml, cy)
123
+ ctx.lineTo(cx, cy)
124
+ ctx.lineTo(cx, cy + sy * ml)
125
+ ctx.stroke()
126
+ }
127
+ ctx.restore()
128
+ }
129
+
130
+ buildRealObject(): RealObject | undefined {
131
+ return new Spot3D(this as any)
132
+ }
133
+
134
+ /**
135
+ * Mount carriers on the TOP of the pad (Spot3D's `getAttachFrame` is
136
+ * already at pad-top in spot-local). Then lift the carrier by its
137
+ * own halfDepth so the carrier's BOTTOM rests ON the pad surface, not
138
+ * its volumetric center — without this lift, half the carrier would
139
+ * sink below the pad / floor.
140
+ *
141
+ * Reads `_realObject.effectiveDepth` first (the framework-resolved
142
+ * value, accounting for `static defaultDepth` and parent context),
143
+ * falling back to raw `state.depth` for components built before
144
+ * RealObject creation.
145
+ */
146
+ attachPointFor(carrier: Component) {
147
+ const ro = (this as any)._realObject as Spot3D | undefined
148
+ const frame = ro?.getAttachFrame?.()
149
+ if (!frame) return undefined
150
+ const carrierDepth = resolveDepth(carrier)
151
+ return {
152
+ attach: frame,
153
+ localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
154
+ }
155
+ }
156
+ }
157
+
158
+ function resolveDepth(c: Component): number {
159
+ const eff = (c as any)._realObject?.effectiveDepth
160
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
161
+ return numOr((c as any)?.state?.depth, 0)
162
+ }
163
+
164
+ function numOr(v: unknown, dflt: number): number {
165
+ return typeof v === 'number' && Number.isFinite(v) ? v : dflt
166
+ }
167
+
168
+ /**
169
+ * Map a things-scene `lineDash` string to a Canvas dash pattern. Mirrors
170
+ * the keys understood by things-scene's `drawer/stroke.ts` so users see
171
+ * consistent options across components. Unknown strings fall through to
172
+ * a plain dashed pattern instead of throwing on setLineDash.
173
+ */
174
+ function applyLineDash(ctx: CanvasRenderingContext2D, style: string, lw: number) {
175
+ switch (style) {
176
+ case 'solid':
177
+ ctx.setLineDash([])
178
+ return
179
+ case 'round-dot':
180
+ ctx.setLineDash([0.1, lw * 2])
181
+ ctx.lineCap = 'round'
182
+ return
183
+ case 'square-dot':
184
+ ctx.setLineDash([lw, lw])
185
+ return
186
+ case 'long-dash':
187
+ ctx.setLineDash([lw * 6, lw * 3])
188
+ return
189
+ case 'dash-dot':
190
+ ctx.setLineDash([lw * 4, lw * 2, lw, lw * 2])
191
+ return
192
+ case 'dash':
193
+ default:
194
+ ctx.setLineDash([lw * 4, lw * 1.5])
195
+ return
196
+ }
197
+ }