@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.
- package/CHANGELOG.md +25 -0
- package/README.md +59 -0
- package/dist/asrs-crane-3d.d.ts +7 -0
- package/dist/asrs-crane-3d.js +164 -0
- package/dist/asrs-crane-3d.js.map +1 -0
- package/dist/asrs-crane.d.ts +47 -0
- package/dist/asrs-crane.js +104 -0
- package/dist/asrs-crane.js.map +1 -0
- package/dist/asrs-rack-3d.d.ts +7 -0
- package/dist/asrs-rack-3d.js +129 -0
- package/dist/asrs-rack-3d.js.map +1 -0
- package/dist/asrs-rack.d.ts +45 -0
- package/dist/asrs-rack.js +99 -0
- package/dist/asrs-rack.js.map +1 -0
- package/dist/box-3d.d.ts +11 -0
- package/dist/box-3d.js +166 -0
- package/dist/box-3d.js.map +1 -0
- package/dist/box.d.ts +36 -0
- package/dist/box.js +73 -0
- package/dist/box.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/pallet-3d.d.ts +11 -0
- package/dist/pallet-3d.js +162 -0
- package/dist/pallet-3d.js.map +1 -0
- package/dist/pallet.d.ts +56 -0
- package/dist/pallet.js +99 -0
- package/dist/pallet.js.map +1 -0
- package/dist/parcel-3d.d.ts +7 -0
- package/dist/parcel-3d.js +82 -0
- package/dist/parcel-3d.js.map +1 -0
- package/dist/parcel.d.ts +30 -0
- package/dist/parcel.js +67 -0
- package/dist/parcel.js.map +1 -0
- package/dist/spot-3d.d.ts +30 -0
- package/dist/spot-3d.js +176 -0
- package/dist/spot-3d.js.map +1 -0
- package/dist/spot.d.ts +41 -0
- package/dist/spot.js +177 -0
- package/dist/spot.js.map +1 -0
- package/dist/templates/index.d.ts +92 -0
- package/dist/templates/index.js +115 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/spot.d.ts +24 -0
- package/dist/templates/spot.js +26 -0
- package/dist/templates/spot.js.map +1 -0
- package/icons/asrs-crane.png +0 -0
- package/icons/asrs-rack.png +0 -0
- package/icons/box-plastic.png +0 -0
- package/icons/box-wood.png +0 -0
- package/icons/pallet-plastic.png +0 -0
- package/icons/pallet-wood.png +0 -0
- package/icons/parcel.png +0 -0
- package/package.json +44 -0
- package/src/asrs-crane-3d.ts +191 -0
- package/src/asrs-crane.ts +130 -0
- package/src/asrs-rack-3d.ts +146 -0
- package/src/asrs-rack.ts +109 -0
- package/src/box-3d.ts +189 -0
- package/src/box.ts +99 -0
- package/src/index.ts +17 -0
- package/src/pallet-3d.ts +181 -0
- package/src/pallet.ts +125 -0
- package/src/parcel-3d.ts +90 -0
- package/src/parcel.ts +76 -0
- package/src/spot-3d.ts +200 -0
- package/src/spot.ts +197 -0
- package/src/templates/index.ts +115 -0
- package/src/templates/spot.ts +26 -0
- package/things-scene.config.js +5 -0
- package/translations/en.json +12 -0
- package/translations/ja.json +12 -0
- package/translations/ko.json +12 -0
- package/translations/ms.json +12 -0
- package/translations/zh.json +12 -0
- package/tsconfig.json +23 -0
- 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
|
+
}
|
package/src/parcel-3d.ts
ADDED
|
@@ -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
|
+
}
|