@operato/scene-storage 10.0.0-beta.47 → 10.0.0-beta.50

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.d.ts +9 -0
  3. package/dist/index.js +6 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/picking-station-3d.d.ts +20 -0
  6. package/dist/picking-station-3d.js +162 -0
  7. package/dist/picking-station-3d.js.map +1 -0
  8. package/dist/picking-station.d.ts +56 -0
  9. package/dist/picking-station.js +212 -0
  10. package/dist/picking-station.js.map +1 -0
  11. package/dist/rack-capability.d.ts +11 -0
  12. package/dist/rack-capability.js +25 -0
  13. package/dist/rack-capability.js.map +1 -0
  14. package/dist/rack-grid.js +3 -10
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/spot.d.ts +19 -1
  17. package/dist/spot.js +63 -1
  18. package/dist/spot.js.map +1 -1
  19. package/dist/stockpile-3d.d.ts +55 -0
  20. package/dist/stockpile-3d.js +387 -0
  21. package/dist/stockpile-3d.js.map +1 -0
  22. package/dist/stockpile-grid-3d.d.ts +30 -0
  23. package/dist/stockpile-grid-3d.js +301 -0
  24. package/dist/stockpile-grid-3d.js.map +1 -0
  25. package/dist/stockpile-grid.d.ts +88 -0
  26. package/dist/stockpile-grid.js +429 -0
  27. package/dist/stockpile-grid.js.map +1 -0
  28. package/dist/stockpile.d.ts +133 -0
  29. package/dist/stockpile.js +439 -0
  30. package/dist/stockpile.js.map +1 -0
  31. package/dist/storage-rack.d.ts +12 -0
  32. package/dist/storage-rack.js +20 -10
  33. package/dist/storage-rack.js.map +1 -1
  34. package/dist/templates/index.d.ts +80 -0
  35. package/dist/templates/index.js +7 -1
  36. package/dist/templates/index.js.map +1 -1
  37. package/dist/templates/picking-station.d.ts +20 -0
  38. package/dist/templates/picking-station.js +22 -0
  39. package/dist/templates/picking-station.js.map +1 -0
  40. package/dist/templates/stockpile-grid.d.ts +37 -0
  41. package/dist/templates/stockpile-grid.js +38 -0
  42. package/dist/templates/stockpile-grid.js.map +1 -0
  43. package/dist/templates/stockpile.d.ts +29 -0
  44. package/dist/templates/stockpile.js +31 -0
  45. package/dist/templates/stockpile.js.map +1 -0
  46. package/package.json +3 -3
  47. package/src/index.ts +14 -0
  48. package/src/picking-station-3d.ts +164 -0
  49. package/src/picking-station.ts +243 -0
  50. package/src/rack-capability.ts +26 -0
  51. package/src/rack-grid.ts +3 -8
  52. package/src/spot.ts +62 -0
  53. package/src/stockpile-3d.ts +412 -0
  54. package/src/stockpile-grid-3d.ts +327 -0
  55. package/src/stockpile-grid.ts +456 -0
  56. package/src/stockpile.ts +508 -0
  57. package/src/storage-rack.ts +21 -8
  58. package/src/templates/index.ts +7 -1
  59. package/src/templates/picking-station.ts +23 -0
  60. package/src/templates/stockpile-grid.ts +39 -0
  61. package/src/templates/stockpile.ts +32 -0
  62. package/test/test-rack-capability.ts +51 -0
  63. package/translations/en.json +18 -6
  64. package/translations/ja.json +18 -6
  65. package/translations/ko.json +17 -5
  66. package/translations/ms.json +18 -6
  67. package/translations/zh.json +17 -5
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,38 @@
1
+ // Reuse parcel.png as a placeholder icon until a dedicated stockpile-grid icon is drawn.
2
+ const icon = new URL('../../icons/parcel.png', import.meta.url).href;
3
+ export default {
4
+ type: 'stockpile-grid',
5
+ description: 'grid of block/floor storage cells — each cell is a stockpile with its own records, capacity, and preset override',
6
+ group: 'storage',
7
+ icon,
8
+ model: {
9
+ type: 'stockpile-grid',
10
+ top: 200,
11
+ left: 400,
12
+ width: 300,
13
+ height: 200,
14
+ depth: 5,
15
+ rotation: 0,
16
+ fillStyle: '#c89c5c',
17
+ strokeStyle: '#7a5a2e',
18
+ cols: 3,
19
+ rows: 2,
20
+ cellWidth: 100,
21
+ cellHeight: 100,
22
+ stackPattern: 'row',
23
+ carrierPreset: 'box',
24
+ carrierWidth: 30,
25
+ carrierHeight: 30,
26
+ carrierDepth: 22,
27
+ carrierGap: 10,
28
+ capacity: 20,
29
+ pickPolicy: 'lifo',
30
+ // 데모 — 일부 cell 에 records 미리 채워서 끌어 놓자 마자 적치 보이게.
31
+ data: [
32
+ { col: 0, row: 0, data: [{ id: 'a1' }, { id: 'a2' }] },
33
+ { col: 1, row: 0, data: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }] },
34
+ { col: 2, row: 1, data: [{ id: 'c1' }] }
35
+ ]
36
+ }
37
+ };
38
+ //# sourceMappingURL=stockpile-grid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stockpile-grid.js","sourceRoot":"","sources":["../../src/templates/stockpile-grid.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,wBAAwB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAA;AAEpE,eAAe;IACb,IAAI,EAAE,gBAAgB;IACtB,WAAW,EACT,kHAAkH;IACpH,KAAK,EAAE,SAAS;IAChB,IAAI;IACJ,KAAK,EAAE;QACL,IAAI,EAAE,gBAAgB;QACtB,GAAG,EAAE,GAAG;QACR,IAAI,EAAE,GAAG;QACT,KAAK,EAAE,GAAG;QACV,MAAM,EAAE,GAAG;QACX,KAAK,EAAE,CAAC;QACR,QAAQ,EAAE,CAAC;QACX,SAAS,EAAE,SAAS;QACpB,WAAW,EAAE,SAAS;QACtB,IAAI,EAAE,CAAC;QACP,IAAI,EAAE,CAAC;QACP,SAAS,EAAE,GAAG;QACd,UAAU,EAAE,GAAG;QACf,YAAY,EAAE,KAAK;QACnB,aAAa,EAAE,KAAK;QACpB,YAAY,EAAE,EAAE;QAChB,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,UAAU,EAAE,EAAE;QACd,QAAQ,EAAE,EAAE;QACZ,UAAU,EAAE,MAAM;QAClB,iDAAiD;QACjD,IAAI,EAAE;YACJ,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;YACtD,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;YACpE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE;SACzC;KACF;CACF,CAAA","sourcesContent":["// Reuse parcel.png as a placeholder icon until a dedicated stockpile-grid icon is drawn.\nconst icon = new URL('../../icons/parcel.png', import.meta.url).href\n\nexport default {\n type: 'stockpile-grid',\n description:\n 'grid of block/floor storage cells — each cell is a stockpile with its own records, capacity, and preset override',\n group: 'storage',\n icon,\n model: {\n type: 'stockpile-grid',\n top: 200,\n left: 400,\n width: 300,\n height: 200,\n depth: 5,\n rotation: 0,\n fillStyle: '#c89c5c',\n strokeStyle: '#7a5a2e',\n cols: 3,\n rows: 2,\n cellWidth: 100,\n cellHeight: 100,\n stackPattern: 'row',\n carrierPreset: 'box',\n carrierWidth: 30,\n carrierHeight: 30,\n carrierDepth: 22,\n carrierGap: 10,\n capacity: 20,\n pickPolicy: 'lifo',\n // 데모 — 일부 cell 에 records 미리 채워서 끌어 놓자 마자 적치 보이게.\n data: [\n { col: 0, row: 0, data: [{ id: 'a1' }, { id: 'a2' }] },\n { col: 1, row: 0, data: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }] },\n { col: 2, row: 1, data: [{ id: 'c1' }] }\n ]\n }\n}\n"]}
@@ -0,0 +1,29 @@
1
+ declare const _default: {
2
+ type: string;
3
+ description: string;
4
+ group: string;
5
+ icon: string;
6
+ model: {
7
+ type: string;
8
+ top: number;
9
+ left: number;
10
+ width: number;
11
+ height: number;
12
+ depth: number;
13
+ rotation: number;
14
+ fillStyle: string;
15
+ strokeStyle: string;
16
+ stackPattern: string;
17
+ carrierPreset: string;
18
+ carrierWidth: number;
19
+ carrierHeight: number;
20
+ carrierDepth: number;
21
+ carrierGap: number;
22
+ capacity: number;
23
+ pickPolicy: string;
24
+ data: {
25
+ id: string;
26
+ }[];
27
+ };
28
+ };
29
+ export default _default;
@@ -0,0 +1,31 @@
1
+ // Reuse parcel.png as a placeholder icon until a dedicated stockpile icon is drawn.
2
+ const icon = new URL('../../icons/parcel.png', import.meta.url).href;
3
+ export default {
4
+ type: 'stockpile',
5
+ description: 'block/floor storage — rectangular footprint with auto-stacked virtual carriers (stackPattern × carrierPreset) driven by state.data records',
6
+ group: 'storage' /* line|shape|textAndMedia|chartAndGauge|table|container|dataSource|3D|facility|storage|conveyance|transport|manufacturing|form|etc */,
7
+ icon,
8
+ model: {
9
+ type: 'stockpile',
10
+ top: 200,
11
+ left: 400,
12
+ width: 200,
13
+ height: 150,
14
+ depth: 5,
15
+ rotation: 0,
16
+ fillStyle: '#c89c5c',
17
+ // 바닥 페인트 라인 의도 — fillStyle 보다 진한 색으로 영역 마킹이 눈에 띄게.
18
+ strokeStyle: '#7a5a2e',
19
+ stackPattern: 'row',
20
+ carrierPreset: 'box',
21
+ carrierWidth: 30,
22
+ carrierHeight: 30,
23
+ carrierDepth: 22,
24
+ carrierGap: 10,
25
+ capacity: 30,
26
+ pickPolicy: 'lifo',
27
+ // 데모용 — 새로 끌어다 놓았을 때 적치된 모습이 즉시 보이도록 record 몇 개.
28
+ data: [{ id: 'p-1' }, { id: 'p-2' }, { id: 'p-3' }]
29
+ }
30
+ };
31
+ //# sourceMappingURL=stockpile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stockpile.js","sourceRoot":"","sources":["../../src/templates/stockpile.ts"],"names":[],"mappings":"AAAA,oFAAoF;AACpF,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,wBAAwB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAA;AAEpE,eAAe;IACb,IAAI,EAAE,WAAW;IACjB,WAAW,EACT,4IAA4I;IAC9I,KAAK,EAAE,SAAS,CAAC,sIAAsI;IACvJ,IAAI;IACJ,KAAK,EAAE;QACL,IAAI,EAAE,WAAW;QACjB,GAAG,EAAE,GAAG;QACR,IAAI,EAAE,GAAG;QACT,KAAK,EAAE,GAAG;QACV,MAAM,EAAE,GAAG;QACX,KAAK,EAAE,CAAC;QACR,QAAQ,EAAE,CAAC;QACX,SAAS,EAAE,SAAS;QACpB,mDAAmD;QACnD,WAAW,EAAE,SAAS;QACtB,YAAY,EAAE,KAAK;QACnB,aAAa,EAAE,KAAK;QACpB,YAAY,EAAE,EAAE;QAChB,aAAa,EAAE,EAAE;QACjB,YAAY,EAAE,EAAE;QAChB,UAAU,EAAE,EAAE;QACd,QAAQ,EAAE,EAAE;QACZ,UAAU,EAAE,MAAM;QAClB,iDAAiD;QACjD,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;KACpD;CACF,CAAA","sourcesContent":["// Reuse parcel.png as a placeholder icon until a dedicated stockpile icon is drawn.\nconst icon = new URL('../../icons/parcel.png', import.meta.url).href\n\nexport default {\n type: 'stockpile',\n description:\n 'block/floor storage — rectangular footprint with auto-stacked virtual carriers (stackPattern × carrierPreset) driven by state.data records',\n group: 'storage' /* line|shape|textAndMedia|chartAndGauge|table|container|dataSource|3D|facility|storage|conveyance|transport|manufacturing|form|etc */,\n icon,\n model: {\n type: 'stockpile',\n top: 200,\n left: 400,\n width: 200,\n height: 150,\n depth: 5,\n rotation: 0,\n fillStyle: '#c89c5c',\n // 바닥 페인트 라인 의도 — fillStyle 보다 진한 색으로 영역 마킹이 눈에 띄게.\n strokeStyle: '#7a5a2e',\n stackPattern: 'row',\n carrierPreset: 'box',\n carrierWidth: 30,\n carrierHeight: 30,\n carrierDepth: 22,\n carrierGap: 10,\n capacity: 30,\n pickPolicy: 'lifo',\n // 데모용 — 새로 끌어다 놓았을 때 적치된 모습이 즉시 보이도록 record 몇 개.\n data: [{ id: 'p-1' }, { id: 'p-2' }, { id: 'p-3' }]\n }\n}\n"]}
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.47",
5
+ "version": "10.0.0-beta.50",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "module": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@hatiolab/things-scene": "^10.0.0-beta.1",
29
- "@operato/scene-base": "^10.0.0-beta.47",
29
+ "@operato/scene-base": "^10.0.0-beta.50",
30
30
  "three": "^0.183.0"
31
31
  },
32
32
  "devDependencies": {
@@ -45,5 +45,5 @@
45
45
  "typescript": "^5.0.4"
46
46
  },
47
47
  "prettier": "@hatiolab/prettier-config",
48
- "gitHead": "153a7b98b92b1f03d8e5612ad2ef8cc8c7c3a345"
48
+ "gitHead": "9578b7fbd4cdde41a9610de591de086e5f231eb1"
49
49
  }
package/src/index.ts CHANGED
@@ -25,6 +25,20 @@ export { Crane3D } from './crane-3d.js'
25
25
  export { default as Spot } from './spot.js'
26
26
  export { Spot3D } from './spot-3d.js'
27
27
 
28
+ export { default as Stockpile } from './stockpile.js'
29
+ export type {
30
+ StockpileState, StockpileRecord, StackPattern, CarrierPreset, PickPolicy
31
+ } from './stockpile.js'
32
+ export { Stockpile3D } from './stockpile-3d.js'
33
+
34
+ export { default as StockpileGrid } from './stockpile-grid.js'
35
+ export type { StockpileGridState, StockpileGridCell } from './stockpile-grid.js'
36
+ export { StockpileGrid3D } from './stockpile-grid-3d.js'
37
+
38
+ export { default as PickingStation } from './picking-station.js'
39
+ export type { PickingStationState, PickingStationStatus } from './picking-station.js'
40
+ export { PickingStation3D } from './picking-station-3d.js'
41
+
28
42
  export { default as GenericContainer } from './generic-container.js'
29
43
  export type { ContainerStatus } from './generic-container.js'
30
44
  export { GenericContainer3D } from './generic-container-3d.js'
@@ -0,0 +1,164 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * PickingStation3D — 사람 작업 위치의 3D 시각화. pad(영역) + 작업대(가운데 box, top
5
+ * face 가 carrier 안착면). status 별 색 변화 (idle/processing/busy) 로 작업 진행
6
+ * 인지성.
7
+ */
8
+
9
+ import * as THREE from 'three'
10
+ import { RealObjectGroup } from '@hatiolab/things-scene'
11
+
12
+ import type PickingStation from './picking-station.js'
13
+ import type { PickingStationStatus } from './picking-station.js'
14
+
15
+ const PAD_DEPTH = 2
16
+
17
+ const STATUS_TINT: Record<PickingStationStatus, number> = {
18
+ idle: 0x4a7a9b, // 차분한 blue-grey
19
+ processing: 0xd9943a, // 작업 중 — 주황
20
+ busy: 0xc04040 // busy/blocked — 빨강
21
+ }
22
+
23
+ export class PickingStation3D extends RealObjectGroup {
24
+ private _padMesh?: THREE.Mesh
25
+ private _padOutline?: THREE.LineSegments
26
+ private _tableMesh?: THREE.Mesh
27
+
28
+ build() {
29
+ super.build()
30
+ const state = this.component.state as any
31
+ const w = state.width ?? 100
32
+ const h = state.height ?? 100
33
+ const depth = state.depth ?? 5
34
+
35
+ // ── pad (영역) ─────────────────────────────────────────────
36
+ const padGeom = new THREE.BoxGeometry(w, PAD_DEPTH, h)
37
+ const padMat = new THREE.MeshStandardMaterial({
38
+ color: this._padColor(), roughness: 0.85, transparent: true, opacity: 0.5
39
+ })
40
+ this._padMesh = new THREE.Mesh(padGeom, padMat)
41
+ this._padMesh.position.y = PAD_DEPTH / 2
42
+ this._padMesh.receiveShadow = true
43
+ this.object3d.add(this._padMesh)
44
+
45
+ // pad outline
46
+ const outline = new THREE.LineBasicMaterial({ color: this._strokeColor() })
47
+ this._padOutline = new THREE.LineSegments(new THREE.EdgesGeometry(padGeom), outline)
48
+ this._padOutline.position.y = PAD_DEPTH / 2
49
+ this.object3d.add(this._padOutline)
50
+
51
+ // ── 작업대 (table) — pad 위 가운데 box. top face = carrier 안착면. ──
52
+ const tableW = w * 0.55
53
+ const tableH = h * 0.45
54
+ const tableD = Math.max(8, depth) // 작업면 높이 — operation height 근사
55
+ const tableGeom = new THREE.BoxGeometry(tableW, tableD, tableH)
56
+ const tableMat = new THREE.MeshStandardMaterial({
57
+ color: this._statusColor(), roughness: 0.6, metalness: 0.1
58
+ })
59
+ this._tableMesh = new THREE.Mesh(tableGeom, tableMat)
60
+ this._tableMesh.position.y = PAD_DEPTH + tableD / 2
61
+ this._tableMesh.castShadow = true
62
+ this._tableMesh.receiveShadow = true
63
+ this.object3d.add(this._tableMesh)
64
+ }
65
+
66
+ /** carrier 안착 anchor — 작업대 top face. */
67
+ getAttachFrame(): THREE.Object3D | undefined {
68
+ return this._tableMesh ?? this._padMesh
69
+ }
70
+
71
+ private _padColor(): THREE.Color {
72
+ const raw = (this.component.state as any)?.fillStyle
73
+ if (typeof raw === 'string' && raw.length > 0) {
74
+ try { return new THREE.Color(raw) } catch { /* fallthrough */ }
75
+ }
76
+ return new THREE.Color(0x5a8ab8)
77
+ }
78
+
79
+ private _strokeColor(): THREE.Color {
80
+ const raw = (this.component.state as any)?.strokeStyle
81
+ if (typeof raw === 'string' && raw.length > 0) {
82
+ try { return new THREE.Color(raw) } catch { /* fallthrough */ }
83
+ }
84
+ return this._padColor().clone().multiplyScalar(0.6)
85
+ }
86
+
87
+ /** status 에 따른 작업대 색 — idle/processing/busy. */
88
+ private _statusColor(): THREE.Color {
89
+ const s = (this.component.state as any)?.status as PickingStationStatus | undefined
90
+ return new THREE.Color(STATUS_TINT[s ?? 'idle'] ?? STATUS_TINT.idle)
91
+ }
92
+
93
+ private _applyPadColor(): void {
94
+ if (this._padMesh) {
95
+ const m = this._padMesh.material as THREE.MeshStandardMaterial
96
+ m.color.copy(this._padColor())
97
+ m.needsUpdate = true
98
+ }
99
+ }
100
+ private _applyStrokeColor(): void {
101
+ if (this._padOutline) {
102
+ const m = this._padOutline.material as THREE.LineBasicMaterial
103
+ m.color.copy(this._strokeColor())
104
+ m.needsUpdate = true
105
+ }
106
+ }
107
+ private _applyStatusColor(): void {
108
+ if (this._tableMesh) {
109
+ const m = this._tableMesh.material as THREE.MeshStandardMaterial
110
+ m.color.copy(this._statusColor())
111
+ m.needsUpdate = true
112
+ }
113
+ }
114
+
115
+ updateAlpha(): void {
116
+ const alpha = typeof (this.component.state as any).alpha === 'number'
117
+ ? (this.component.state as any).alpha : 1
118
+ if (this._padMesh) {
119
+ const m = this._padMesh.material as THREE.MeshStandardMaterial
120
+ m.opacity = 0.5 * alpha
121
+ m.transparent = m.opacity < 1
122
+ m.needsUpdate = true
123
+ }
124
+ }
125
+
126
+ updateDimension(): void {
127
+ if (this._padMesh) {
128
+ const state = this.component.state as any
129
+ const w = state.width ?? 100
130
+ const h = state.height ?? 100
131
+ const depth = state.depth ?? 5
132
+ this._padMesh.geometry.dispose()
133
+ this._padMesh.geometry = new THREE.BoxGeometry(w, PAD_DEPTH, h)
134
+ if (this._padOutline) {
135
+ this._padOutline.geometry.dispose()
136
+ this._padOutline.geometry = new THREE.EdgesGeometry(this._padMesh.geometry)
137
+ }
138
+ if (this._tableMesh) {
139
+ const tableW = w * 0.55
140
+ const tableH = h * 0.45
141
+ const tableD = Math.max(8, depth)
142
+ this._tableMesh.geometry.dispose()
143
+ this._tableMesh.geometry = new THREE.BoxGeometry(tableW, tableD, tableH)
144
+ this._tableMesh.position.y = PAD_DEPTH + tableD / 2
145
+ }
146
+ }
147
+ }
148
+
149
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>): void {
150
+ if ('width' in after || 'height' in after || 'depth' in after) {
151
+ this.updateDimension()
152
+ return
153
+ }
154
+ if ('alpha' in after) { this.updateAlpha(); return }
155
+ if ('fillStyle' in after) {
156
+ this._applyPadColor()
157
+ if ((this.component.state as any).strokeStyle == null) this._applyStrokeColor()
158
+ return
159
+ }
160
+ if ('strokeStyle' in after) { this._applyStrokeColor(); return }
161
+ if ('status' in after) { this._applyStatusColor(); return }
162
+ super.onchange?.(after, before)
163
+ }
164
+ }
@@ -0,0 +1,243 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * PickingStation — 사람(또는 자동화) 작업 위치. carrier 가 도착하면 *_processingTimeMs_*
5
+ * 동안 머문 뒤 status='idle' 로 자동 전환되어 다음 mover 가 가져갈 수 있다.
6
+ *
7
+ * 단일 slot SlottedHolder (Spot 비슷) + 처리 시간/상태 + popupRef + click raycast.
8
+ * 3D 는 pad(영역) + 작업대(가운데 box) — picking/QC 작업 자리의 인지성을 위해.
9
+ */
10
+
11
+ import * as THREE from 'three'
12
+ import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
13
+ import type { State, Material3D } from '@hatiolab/things-scene'
14
+ import {
15
+ CarrierHolder,
16
+ Placeable,
17
+ SlotTarget,
18
+ type AttachFrame,
19
+ type Alignment,
20
+ type Heights,
21
+ type PlacementArchetype
22
+ } from '@operato/scene-base'
23
+
24
+ import { PickingStation3D } from './picking-station-3d.js'
25
+
26
+ export type PickingStationStatus = 'idle' | 'processing' | 'busy'
27
+
28
+ export interface PickingStationState extends State {
29
+ /** carrier 가 머무는 처리 시간 (ms). 0/미설정이면 즉시 idle 유지. */
30
+ processingTimeMs?: number
31
+ /** 현재 상태 (자동). */
32
+ status?: PickingStationStatus
33
+ /** click 시 invoke 할 Popup 컴포넌트 id. */
34
+ popupRef?: string
35
+ material3d?: Material3D
36
+ }
37
+
38
+ const SLOT_ID = 'station'
39
+
40
+ const NATURE: ComponentNature = {
41
+ mutable: false,
42
+ resizable: true,
43
+ rotatable: true,
44
+ properties: [
45
+ { type: 'number', label: 'processing-time-ms', name: 'processingTimeMs' },
46
+ { type: 'select', label: 'status', name: 'status',
47
+ property: { options: ['idle', 'processing', 'busy'] } },
48
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef',
49
+ property: { component: 'popup' } }
50
+ ],
51
+ help: 'scene/component/picking-station'
52
+ }
53
+
54
+ @sceneComponent('picking-station')
55
+ export default class PickingStation extends CarrierHolder(Placeable(ContainerAbstract)) {
56
+ declare state: PickingStationState
57
+ declare _realObject?: PickingStation3D
58
+
59
+ static placement: PlacementArchetype = 'floor'
60
+ static align: Alignment = 'bottom'
61
+ static defaultDepth = (h: Heights) => h.operation - h.floor
62
+
63
+ get nature(): ComponentNature { return NATURE }
64
+ get anchors() { return [] }
65
+
66
+ // ── SlottedHolder duck-type (단일 slot 'station') ─────────────
67
+ slotIds(): ReadonlyArray<string> { return [SLOT_ID] }
68
+
69
+ hasCarrierAt(slotId: string): boolean {
70
+ return slotId === SLOT_ID &&
71
+ ((this as any).components ?? []).some((c: any) => c?.isCarriable)
72
+ }
73
+ canReceiveAt(slotId: string, _carrier?: Component): boolean {
74
+ return slotId === SLOT_ID && !this.hasCarrierAt(slotId)
75
+ }
76
+ occupiedSlotIds(): ReadonlyArray<string> {
77
+ return this.hasCarrierAt(SLOT_ID) ? [SLOT_ID] : []
78
+ }
79
+ emptySlotIds(): ReadonlyArray<string> {
80
+ return this.hasCarrierAt(SLOT_ID) ? [] : [SLOT_ID]
81
+ }
82
+ obtainCarrier(slotId: string): Component | null {
83
+ if (slotId !== SLOT_ID) return null
84
+ return ((this as any).components ?? []).find((c: any) => c?.isCarriable) ?? null
85
+ }
86
+
87
+ /**
88
+ * carrier 도착 — components + 3D 로 reparent (Spot 패턴). processingTimeMs > 0
89
+ * 이면 status='processing' 으로 전환했다가 timer 후 'idle' 로 복귀.
90
+ *
91
+ * 단순 setTimeout — frameClock 통합(view-mode/일시정지 동기) 은 추후.
92
+ */
93
+ async receiveAt(_slotId: string, carrier: Component, options?: any): Promise<void> {
94
+ ;(this as any).reparent?.(carrier, { ...(options ?? {}), animated: false })
95
+ const procMs = this.state.processingTimeMs ?? 0
96
+ if (procMs > 0) {
97
+ this.setState({ status: 'processing' as PickingStationStatus })
98
+ setTimeout(() => {
99
+ if ((this.state.status as PickingStationStatus) === 'processing') {
100
+ this.setState({ status: 'idle' as PickingStationStatus })
101
+ }
102
+ }, procMs)
103
+ }
104
+ }
105
+
106
+ async accept(carrier: Component, options?: any): Promise<void> {
107
+ return this.receiveAt(SLOT_ID, carrier, options)
108
+ }
109
+ async receive(carrier: Component, options?: any): Promise<void> {
110
+ return this.receiveAt(SLOT_ID, carrier, options)
111
+ }
112
+
113
+ slotTargetAt(slotId: string): SlotTarget { return new SlotTarget(this as any, slotId) }
114
+ getSlotAttachObject3d(_slotId: string): any {
115
+ return (this as any)._realObject?.getAttachFrame?.()
116
+ }
117
+
118
+ /** carrier 를 작업대(table) 상단에 안착. */
119
+ attachPointFor(carrier: Component): AttachFrame | null {
120
+ const ro = this._realObject
121
+ const frame = ro?.getAttachFrame?.()
122
+ if (!frame) return null
123
+ const carrierDepth = resolveDepth(carrier)
124
+ return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
125
+ }
126
+
127
+ // ── 2D render ───────────────────────────────────────────────
128
+ render(ctx: CanvasRenderingContext2D) {
129
+ const { left = 0, top = 0, width = 100, height = 100 } = this.state
130
+ const fillStyle = (this.state.fillStyle as string) || '#5a8ab8'
131
+ const strokeStyle = (this.state.strokeStyle as string) || '#3d6a8f'
132
+ const status = (this.state.status ?? 'idle') as PickingStationStatus
133
+
134
+ // pad
135
+ ctx.save()
136
+ ctx.fillStyle = fillStyle
137
+ ctx.globalAlpha = 0.15
138
+ ctx.fillRect(left, top, width, height)
139
+ ctx.restore()
140
+
141
+ // outline
142
+ ctx.save()
143
+ ctx.strokeStyle = strokeStyle
144
+ ctx.lineWidth = 1.5
145
+ ctx.strokeRect(left + 0.75, top + 0.75, width - 1.5, height - 1.5)
146
+ ctx.restore()
147
+
148
+ // 작업대 (가운데 작은 사각)
149
+ const tw = width * 0.55, th = height * 0.45
150
+ const tx = left + (width - tw) / 2
151
+ const ty = top + (height - th) / 2
152
+ ctx.save()
153
+ ctx.fillStyle = strokeStyle
154
+ ctx.globalAlpha = 0.35
155
+ ctx.fillRect(tx, ty, tw, th)
156
+ ctx.restore()
157
+
158
+ // 상태
159
+ ctx.save()
160
+ const fontSize = Math.min(width, height) * 0.16
161
+ ctx.fillStyle = '#222'
162
+ ctx.font = `bold ${fontSize}px sans-serif`
163
+ ctx.textAlign = 'center'
164
+ ctx.textBaseline = 'middle'
165
+ ctx.fillText(status.toUpperCase(), left + width / 2, top + height / 2)
166
+ ctx.restore()
167
+ }
168
+
169
+ // ── Popup + click ────────────────────────────────────────────
170
+ get eventMap() {
171
+ return { '(self)': { '(self)': { click: this._onStationClick } } }
172
+ }
173
+
174
+ private _onStationClick = (mouseEvent: MouseEvent) => {
175
+ if (!(this as any).app?.isViewMode) return
176
+ const hit = this._raycastStationHit(mouseEvent)
177
+ if (!hit) return
178
+ this._invokePopup()
179
+ }
180
+
181
+ private _invokePopup(): void {
182
+ const popupRefId = this.state.popupRef
183
+ if (!popupRefId) return
184
+ const popupComp: any = (this as any).root?.findById?.(popupRefId)
185
+ if (!popupComp || typeof popupComp.openPopup !== 'function') return
186
+ const anchor = this.slotTargetAt(SLOT_ID)
187
+ const carrier = this.obtainCarrier(SLOT_ID)
188
+ popupComp.openPopup({
189
+ componentId: (this.state as any).id,
190
+ status: this.state.status ?? 'idle',
191
+ processingTimeMs: this.state.processingTimeMs,
192
+ currentCarrierId: (carrier as any)?.state?.id ?? null
193
+ }, { anchor })
194
+ }
195
+
196
+ private _raycastStationHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
197
+ const ro: any = (this as any)._realObject
198
+ if (!ro?.object3d) return undefined
199
+ const tc: any = ro.threeContainer
200
+ if (!tc) return undefined
201
+ const cap: any = tc._threeCapability ?? tc._capability
202
+ let intersects: THREE.Intersection[] | undefined
203
+ if (cap?.getObjectsByRaycast) intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
204
+ if (!intersects || intersects.length === 0) {
205
+ const scene = tc.scene3d as THREE.Scene | undefined
206
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
207
+ const camera =
208
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
209
+ (cap?.activeCamera as THREE.Camera | undefined) ??
210
+ (cap?.camera as THREE.Camera | undefined)
211
+ const canvas = renderer?.domElement
212
+ if (!scene || !canvas || !camera) return undefined
213
+ const rect = canvas.getBoundingClientRect()
214
+ if (rect.width === 0 || rect.height === 0) return undefined
215
+ const ndc = new THREE.Vector2(
216
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
217
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
218
+ )
219
+ const raycaster = new THREE.Raycaster()
220
+ raycaster.setFromCamera(ndc, camera)
221
+ intersects = raycaster.intersectObjects(scene.children, true)
222
+ }
223
+ if (!intersects || intersects.length === 0) return undefined
224
+ const closest = intersects[0]
225
+ let obj: THREE.Object3D | null = closest.object
226
+ while (obj) {
227
+ if (obj.userData?.context === ro) return closest
228
+ obj = obj.parent
229
+ }
230
+ return undefined
231
+ }
232
+
233
+ buildRealObject(): RealObject | undefined {
234
+ return new PickingStation3D(this)
235
+ }
236
+ }
237
+
238
+ function resolveDepth(c: Component): number {
239
+ const eff = (c as any)._realObject?.effectiveDepth
240
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
241
+ const d = (c as any)?.state?.depth
242
+ return typeof d === 'number' && Number.isFinite(d) ? d : 0
243
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Rack 적재 mover 능력 판정 — *_순수 로직_* (things-scene 무관). storage-rack 의
5
+ * canAcceptFromMover 가 위임. mocha 환경에서 StorageRack 직접 import 불가하므로
6
+ * 판정 로직만 분리해 단위 검증.
7
+ */
8
+
9
+ /**
10
+ * rack 이 *_이 mover 의 toolType_* 을 선반 적재용으로 수용하는가.
11
+ *
12
+ * rack 선반 적재는 높이 도달 mover (crane / stacker / forklift) 의 몫. 평탄 데크
13
+ * 차량(agv-deck)은 바닥 운반 전용 — 선반 직접 적재 불가 → 거부. 거부된 mover 는
14
+ * transfer planner 가 자동으로 in-port 경유(환승)를 택하게 만든다.
15
+ *
16
+ * @param moverToolType 적재하려는 mover 의 toolType (undefined 면 능력 미상 → 허용)
17
+ * @param blockedTools 거부 toolType 목록 (default ['agv-deck'])
18
+ */
19
+ export function rackAcceptsMoverTool(
20
+ moverToolType: string | undefined | null,
21
+ blockedTools: readonly string[] = ['agv-deck']
22
+ ): boolean {
23
+ if (moverToolType == null) return true
24
+ if (!Array.isArray(blockedTools)) return true
25
+ return !blockedTools.includes(moverToolType)
26
+ }
package/src/rack-grid.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  CarrierHolder,
40
40
  Placeable,
41
41
  SlotTarget,
42
+ componentBoundingBox,
42
43
  type AttachFrame,
43
44
  type Alignment,
44
45
  type Heights,
@@ -278,14 +279,8 @@ export default class RackGrid
278
279
  return (this.state as any)?.isObstacle !== false
279
280
  }
280
281
  obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
281
- const s: any = this.state
282
- if (typeof s?.left !== 'number') return null
283
- return {
284
- left: s.left, top: s.top,
285
- width: s.width, height: s.height,
286
- y: typeof s.zPos === 'number' ? s.zPos : 0,
287
- zHeight: typeof s.depth === 'number' ? s.depth : 0
288
- }
282
+ // scene-base componentBoundingBox 위임 — rotation 적용된 AABB.
283
+ return componentBoundingBox(this)
289
284
  }
290
285
 
291
286
  static placement: PlacementArchetype = 'floor'