@operato/scene-storage 10.0.0-beta.48 → 10.0.0-beta.53

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 +36 -0
  2. package/dist/box.js +2 -2
  3. package/dist/box.js.map +1 -1
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.js +6 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pallet.js +2 -2
  8. package/dist/pallet.js.map +1 -1
  9. package/dist/parcel.js +2 -2
  10. package/dist/parcel.js.map +1 -1
  11. package/dist/picking-station-3d.d.ts +20 -0
  12. package/dist/picking-station-3d.js +162 -0
  13. package/dist/picking-station-3d.js.map +1 -0
  14. package/dist/picking-station.d.ts +50 -0
  15. package/dist/picking-station.js +186 -0
  16. package/dist/picking-station.js.map +1 -0
  17. package/dist/rack-capability.d.ts +11 -0
  18. package/dist/rack-capability.js +25 -0
  19. package/dist/rack-capability.js.map +1 -0
  20. package/dist/rack-grid.d.ts +4 -22
  21. package/dist/rack-grid.js +23 -115
  22. package/dist/rack-grid.js.map +1 -1
  23. package/dist/spot.d.ts +1 -0
  24. package/dist/spot.js +6 -2
  25. package/dist/spot.js.map +1 -1
  26. package/dist/stockpile-3d.d.ts +55 -0
  27. package/dist/stockpile-3d.js +387 -0
  28. package/dist/stockpile-3d.js.map +1 -0
  29. package/dist/stockpile-grid-3d.d.ts +30 -0
  30. package/dist/stockpile-grid-3d.js +301 -0
  31. package/dist/stockpile-grid-3d.js.map +1 -0
  32. package/dist/stockpile-grid.d.ts +85 -0
  33. package/dist/stockpile-grid.js +361 -0
  34. package/dist/stockpile-grid.js.map +1 -0
  35. package/dist/stockpile.d.ts +116 -0
  36. package/dist/stockpile.js +345 -0
  37. package/dist/stockpile.js.map +1 -0
  38. package/dist/storage-rack.d.ts +39 -44
  39. package/dist/storage-rack.js +71 -146
  40. package/dist/storage-rack.js.map +1 -1
  41. package/dist/templates/index.d.ts +80 -0
  42. package/dist/templates/index.js +7 -1
  43. package/dist/templates/index.js.map +1 -1
  44. package/dist/templates/picking-station.d.ts +20 -0
  45. package/dist/templates/picking-station.js +22 -0
  46. package/dist/templates/picking-station.js.map +1 -0
  47. package/dist/templates/stockpile-grid.d.ts +37 -0
  48. package/dist/templates/stockpile-grid.js +38 -0
  49. package/dist/templates/stockpile-grid.js.map +1 -0
  50. package/dist/templates/stockpile.d.ts +29 -0
  51. package/dist/templates/stockpile.js +31 -0
  52. package/dist/templates/stockpile.js.map +1 -0
  53. package/package.json +3 -3
  54. package/src/box.ts +2 -1
  55. package/src/index.ts +14 -0
  56. package/src/pallet.ts +2 -1
  57. package/src/parcel.ts +2 -1
  58. package/src/picking-station-3d.ts +164 -0
  59. package/src/picking-station.ts +220 -0
  60. package/src/rack-capability.ts +26 -0
  61. package/src/rack-grid.ts +24 -108
  62. package/src/spot.ts +15 -1
  63. package/src/stockpile-3d.ts +412 -0
  64. package/src/stockpile-grid-3d.ts +327 -0
  65. package/src/stockpile-grid.ts +408 -0
  66. package/src/stockpile.ts +427 -0
  67. package/src/storage-rack.ts +82 -137
  68. package/src/templates/index.ts +7 -1
  69. package/src/templates/picking-station.ts +23 -0
  70. package/src/templates/stockpile-grid.ts +39 -0
  71. package/src/templates/stockpile.ts +32 -0
  72. package/test/test-rack-capability.ts +51 -0
  73. package/translations/en.json +23 -6
  74. package/translations/ja.json +23 -6
  75. package/translations/ko.json +22 -5
  76. package/translations/ms.json +23 -6
  77. package/translations/zh.json +22 -5
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,408 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * StockpileGrid — cols × rows cell 들로 분할된 평치 영역. cell 들은 grid 공통 설정
5
+ * (carrierPreset/stackPattern/크기/capacity 등)을 *_모두 공유_* 하고, 각 cell 은 자기
6
+ * 위치(col, row)에서의 records 만 별도 가진다. cell 별 override 는 의미가 없어
7
+ * 제거됨 — cell 별 다른 설정이 진짜 필요한 경우엔 Stockpile 컴포넌트를 따로 두는
8
+ * 것이 자연스럽다.
9
+ *
10
+ * state.data = grid 의 cell 들 배열 (각 element 는 cell-단위 stockpile-like 데이터).
11
+ * data: [
12
+ * { col: 0, row: 0, data: [{ id, ... }, ...] },
13
+ * { col: 1, row: 0, data: [...] },
14
+ * ...
15
+ * ]
16
+ * cell.data 가 그 cell 의 records. 외부 데이터 매핑은 이 배열을 target.
17
+ */
18
+
19
+ import * as THREE from 'three'
20
+ import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
21
+ import type { State, Material3D } from '@hatiolab/things-scene'
22
+ import {
23
+ CarrierHolder,
24
+ Placeable,
25
+ RecordStorage,
26
+ SlotTarget,
27
+ type AttachFrame,
28
+ type Alignment,
29
+ type Heights,
30
+ type PlacementArchetype
31
+ } from '@operato/scene-base'
32
+
33
+ import { StockpileGrid3D } from './stockpile-grid-3d.js'
34
+ import type { StackPattern, CarrierPreset, StockpileRecord, PickPolicy } from './stockpile.js'
35
+
36
+ /** grid 의 한 cell — 위치 + 그 cell 의 records. */
37
+ export interface StockpileGridCell {
38
+ col: number
39
+ row: number
40
+ data?: StockpileRecord[]
41
+ }
42
+
43
+ export interface StockpileGridState extends State {
44
+ cols?: number
45
+ rows?: number
46
+ /** 한 cell 의 평면 크기 (전체 width/height = cellWidth*cols, cellHeight*rows). */
47
+ cellWidth?: number
48
+ cellHeight?: number
49
+
50
+ /** grid 의 cell 들 — 각 cell 은 { col, row, data: Record[] }. */
51
+ data?: StockpileGridCell[]
52
+
53
+ // ── 공통 carrier 설정 (모든 cell 공유) ──
54
+ stackPattern?: StackPattern
55
+ carrierPreset?: CarrierPreset
56
+ carrierWidth?: number
57
+ carrierHeight?: number
58
+ carrierDepth?: number
59
+ carrierGap?: number
60
+ capacity?: number // cell 별 capacity (모든 cell 공통)
61
+ stackHeightLimit?: number
62
+ pickPolicy?: PickPolicy
63
+
64
+ popupRef?: string
65
+ legendTarget?: string
66
+ material3d?: Material3D
67
+ }
68
+
69
+ const NATURE: ComponentNature = {
70
+ mutable: false,
71
+ resizable: true,
72
+ rotatable: true,
73
+ properties: [
74
+ { type: 'number', label: 'cols', name: 'cols' },
75
+ { type: 'number', label: 'rows', name: 'rows' },
76
+ { type: 'number', label: 'cell-width', name: 'cellWidth' },
77
+ { type: 'number', label: 'cell-height', name: 'cellHeight' },
78
+ { type: 'select', label: 'stack-pattern', name: 'stackPattern',
79
+ property: { options: ['row', 'staggered', 'pyramid', 'column', 'pile'] } },
80
+ { type: 'select', label: 'carrier-preset', name: 'carrierPreset',
81
+ property: { options: ['box', 'pallet', 'drum', 'sack', 'crate', 'bale'] } },
82
+ { type: 'number', label: 'carrier-width', name: 'carrierWidth' },
83
+ { type: 'number', label: 'carrier-height', name: 'carrierHeight' },
84
+ { type: 'number', label: 'carrier-depth', name: 'carrierDepth' },
85
+ { type: 'number', label: 'carrier-gap', name: 'carrierGap' },
86
+ { type: 'number', label: 'capacity', name: 'capacity' },
87
+ { type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
88
+ { type: 'select', label: 'pick-policy', name: 'pickPolicy',
89
+ property: { options: ['lifo', 'fifo'] } },
90
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef',
91
+ property: { component: 'popup' } },
92
+ { type: 'id-input', label: 'legend-target', name: 'legendTarget',
93
+ property: { component: 'legend' },
94
+ placeholder: '미명시 시 scene 의 legend 자동 발견' }
95
+ ],
96
+ help: 'scene/component/stockpile-grid'
97
+ }
98
+
99
+ @sceneComponent('stockpile-grid')
100
+ export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
101
+ CarrierHolder(Placeable(ContainerAbstract))
102
+ ) {
103
+ declare state: StockpileGridState
104
+ declare _realObject?: StockpileGrid3D
105
+
106
+ static placement: PlacementArchetype = 'floor'
107
+ static align: Alignment = 'bottom'
108
+ static defaultDepth = (_h: Heights) => 5
109
+
110
+ get nature(): ComponentNature { return NATURE }
111
+ get anchors() { return [] }
112
+
113
+ // RecordStorage mixin 의 records / inventoryCount / addRecord / removeRecord 는
114
+ // cell-aware 시맨틱과 호환 안 됨. cell-nested data 라 *_recordsOf(col,row)_* 와
115
+ // _setCellRecords 를 사용. legend / popup / onLegendChanged 는 mixin 사용.
116
+ // _rebuildVisual hook 만 override.
117
+ _rebuildVisual(): void {
118
+ this._realObject?.update?.()
119
+ }
120
+
121
+ // ── grid 좌표 helpers ─────────────────────────────────────
122
+ get cols(): number { return Math.max(1, Math.floor(this.state.cols ?? 3)) }
123
+ get rows(): number { return Math.max(1, Math.floor(this.state.rows ?? 3)) }
124
+ get cellW(): number { return this.state.cellWidth ?? 80 }
125
+ get cellH(): number { return this.state.cellHeight ?? 80 }
126
+
127
+ cellIdOf(col: number, row: number): string { return `${col}-${row}` }
128
+ parseCellId(slotId: string): { col: number; row: number } | null {
129
+ const m = slotId.match(/^(\d+)-(\d+)$/)
130
+ if (!m) return null
131
+ return { col: Number(m[1]), row: Number(m[2]) }
132
+ }
133
+
134
+ /** state.data 에서 (col,row) cell 찾기. */
135
+ private _findCell(col: number, row: number): StockpileGridCell | undefined {
136
+ return (this.state.data ?? []).find(c => c.col === col && c.row === row)
137
+ }
138
+
139
+ /** cell 의 records (없으면 빈 배열). */
140
+ recordsOf(col: number, row: number): ReadonlyArray<StockpileRecord> {
141
+ return this._findCell(col, row)?.data ?? []
142
+ }
143
+
144
+ /** capacity 는 모든 cell 공통. */
145
+ capacityOf(_col: number, _row: number): number | undefined {
146
+ return this.state.capacity
147
+ }
148
+
149
+ // ── SlottedHolder — cell 별 slot ─────────────────────────────
150
+ slotIds(): ReadonlyArray<string> {
151
+ const out: string[] = []
152
+ for (let r = 0; r < this.rows; r++) {
153
+ for (let c = 0; c < this.cols; c++) out.push(this.cellIdOf(c, r))
154
+ }
155
+ return out
156
+ }
157
+ hasCarrierAt(slotId: string): boolean {
158
+ const p = this.parseCellId(slotId)
159
+ return !!p && this.recordsOf(p.col, p.row).length > 0
160
+ }
161
+ canReceiveAt(slotId: string, _carrier?: Component): boolean {
162
+ const p = this.parseCellId(slotId)
163
+ if (!p) return false
164
+ const cap = this.capacityOf(p.col, p.row)
165
+ if (typeof cap === 'number' && this.recordsOf(p.col, p.row).length >= cap) return false
166
+ return true
167
+ }
168
+ occupiedSlotIds(): ReadonlyArray<string> {
169
+ const out: string[] = []
170
+ for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
171
+ if (this.recordsOf(c, r).length > 0) out.push(this.cellIdOf(c, r))
172
+ }
173
+ return out
174
+ }
175
+ emptySlotIds(): ReadonlyArray<string> {
176
+ const out: string[] = []
177
+ for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
178
+ if (this.canReceiveAt(this.cellIdOf(c, r))) out.push(this.cellIdOf(c, r))
179
+ }
180
+ return out
181
+ }
182
+
183
+ obtainCarrier(slotId: string): Component | null {
184
+ const p = this.parseCellId(slotId)
185
+ if (!p) return null
186
+ const recs = this.recordsOf(p.col, p.row)
187
+ if (recs.length === 0) return null
188
+ const records = [...recs]
189
+ const policy = (this.state.pickPolicy ?? 'lifo') as PickPolicy
190
+ const record = policy === 'fifo' ? records.shift()! : records.pop()!
191
+ this._setCellRecords(p.col, p.row, records)
192
+ return this._materializeCarrier(record, p.col, p.row)
193
+ }
194
+
195
+ async receiveAt(slotId: string, carrier: Component, _options?: any): Promise<void> {
196
+ const p = this.parseCellId(slotId)
197
+ if (!p) return
198
+ const cstate: any = (carrier as any)?.state ?? {}
199
+ const cid = cstate.id ?? `stkg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
200
+ const record: StockpileRecord = {
201
+ id: String(cid),
202
+ ...(cstate.type ? { type: cstate.type } : {}),
203
+ ...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
204
+ ...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
205
+ ...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
206
+ }
207
+ const records = [...this.recordsOf(p.col, p.row), record]
208
+ this._setCellRecords(p.col, p.row, records)
209
+ ;(carrier as any)?.dispose?.()
210
+ }
211
+
212
+ async accept(carrier: Component, options?: any): Promise<void> {
213
+ const empty = this.emptySlotIds()
214
+ if (empty.length === 0) return
215
+ return this.receiveAt(empty[0], carrier, options)
216
+ }
217
+ async receive(carrier: Component, options?: any): Promise<void> {
218
+ return this.accept(carrier, options)
219
+ }
220
+
221
+ slotTargetAt(slotId: string): SlotTarget { return new SlotTarget(this as any, slotId) }
222
+ getSlotAttachObject3d(slotId: string): any {
223
+ return (this as any)._realObject?.getCellPadMesh?.(slotId)
224
+ }
225
+
226
+ attachPointFor(carrier: Component): AttachFrame | null {
227
+ const ro = this._realObject
228
+ const frame = ro?.getAttachFrame?.()
229
+ if (!frame) return null
230
+ const carrierDepth = resolveDepth(carrier)
231
+ return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
232
+ }
233
+
234
+ private _setCellRecords(col: number, row: number, records: StockpileRecord[]): void {
235
+ const cells = [...((this.state.data as StockpileGridCell[]) ?? [])]
236
+ const idx = cells.findIndex(c => c.col === col && c.row === row)
237
+ if (idx === -1) cells.push({ col, row, data: records })
238
+ else cells[idx] = { ...cells[idx], data: records }
239
+ ;(this.state as any).data = cells
240
+ this._realObject?.update?.()
241
+ }
242
+
243
+ private _materializeCarrier(record: StockpileRecord, col: number, row: number): Component | null {
244
+ const preset = (this.state.carrierPreset ?? 'box') as CarrierPreset
245
+ const PRESET_TO_TYPE: Record<CarrierPreset, string> = {
246
+ box: 'box', pallet: 'pallet', crate: 'box',
247
+ drum: 'parcel', sack: 'parcel', bale: 'parcel'
248
+ }
249
+ const carrierType = (record as any).type ?? PRESET_TO_TYPE[preset] ?? 'parcel'
250
+ const CarrierClass = (Component as any).register(carrierType) as
251
+ | (new (...args: any[]) => Component) | undefined
252
+ if (!CarrierClass) {
253
+ console.warn(`[stockpile-grid] carrier type "${carrierType}" 미등록`)
254
+ return null
255
+ }
256
+
257
+ const cw = this.state.carrierWidth ?? 30
258
+ const ch = this.state.carrierHeight ?? 30
259
+ const cd = this.state.carrierDepth ?? 22
260
+
261
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
262
+ const cellCenterX = col * this.cellW + this.cellW / 2
263
+ const cellCenterZ = row * this.cellH + this.cellH / 2
264
+
265
+ const carrierState: any = {
266
+ ...recordCopy,
267
+ type: carrierType,
268
+ width: cw,
269
+ height: ch,
270
+ depth: cd,
271
+ refid: _nextStockpileGridCarrierRefid(),
272
+ left: cellCenterX - cw / 2,
273
+ top: cellCenterZ - ch / 2
274
+ }
275
+
276
+ const carrier = new CarrierClass(carrierState, (this as any)._app)
277
+ ;(this as any).addComponent(carrier, { silent: true })
278
+ void (carrier as any).realObject
279
+ ;(carrier as any).applyHolderAttachPoint?.()
280
+ return carrier
281
+ }
282
+
283
+ // legendTarget / _onLegendChanged / resolveLegendColor — RecordStorage mixin 제공.
284
+
285
+ // ── Popup + click — mixin 의 _invokePopup 활용, cell payload 만 wrapper ──
286
+ get eventMap() {
287
+ return { '(self)': { '(self)': { click: this._onGridClick } } }
288
+ }
289
+ private _onGridClick = (mouseEvent: MouseEvent) => {
290
+ if (!(this as any).app?.isViewMode) return
291
+ const hit = this._raycastHit(mouseEvent)
292
+ if (!hit) return
293
+ const slotId = (hit.object?.userData?.slotId as string | undefined)
294
+ this._dispatchCellPopup(slotId)
295
+ }
296
+ private _dispatchCellPopup(slotId?: string): void {
297
+ if (!this.state.popupRef) return
298
+ if (slotId) {
299
+ const p = this.parseCellId(slotId)
300
+ this._invokePopup(slotId, {
301
+ cellId: slotId,
302
+ col: p?.col, row: p?.row,
303
+ records: p ? this.recordsOf(p.col, p.row) : [],
304
+ capacity: p ? this.capacityOf(p.col, p.row) : undefined
305
+ })
306
+ } else {
307
+ this._invokePopup(this.cellIdOf(0, 0), {
308
+ componentId: (this.state as any).id,
309
+ cols: this.cols, rows: this.rows
310
+ })
311
+ }
312
+ }
313
+
314
+ private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
315
+ const ro: any = (this as any)._realObject
316
+ if (!ro?.object3d) return undefined
317
+ const tc: any = ro.threeContainer
318
+ if (!tc) return undefined
319
+ const cap: any = tc._threeCapability ?? tc._capability
320
+ let intersects: THREE.Intersection[] | undefined
321
+ if (cap?.getObjectsByRaycast) intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
322
+ if (!intersects || intersects.length === 0) {
323
+ const scene = tc.scene3d as THREE.Scene | undefined
324
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
325
+ const camera =
326
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
327
+ (cap?.activeCamera as THREE.Camera | undefined) ??
328
+ (cap?.camera as THREE.Camera | undefined)
329
+ const canvas = renderer?.domElement
330
+ if (!scene || !canvas || !camera) return undefined
331
+ const rect = canvas.getBoundingClientRect()
332
+ if (rect.width === 0 || rect.height === 0) return undefined
333
+ const ndc = new THREE.Vector2(
334
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
335
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
336
+ )
337
+ const raycaster = new THREE.Raycaster()
338
+ raycaster.setFromCamera(ndc, camera)
339
+ intersects = raycaster.intersectObjects(scene.children, true)
340
+ }
341
+ if (!intersects || intersects.length === 0) return undefined
342
+ const closest = intersects[0]
343
+ let obj: THREE.Object3D | null = closest.object
344
+ while (obj) {
345
+ if (obj.userData?.context === ro) return closest
346
+ obj = obj.parent
347
+ }
348
+ return undefined
349
+ }
350
+
351
+ // ── 2D render ─────────────────────────────────────────────
352
+ render(ctx: CanvasRenderingContext2D) {
353
+ const { left = 0, top = 0 } = this.state
354
+ const totalW = this.cellW * this.cols
355
+ const totalH = this.cellH * this.rows
356
+ const fillStyle = (this.state.fillStyle as string) || '#c89c5c'
357
+ const strokeStyle = (this.state.strokeStyle as string) || '#7a5a2e'
358
+
359
+ ctx.save()
360
+ ctx.fillStyle = fillStyle
361
+ ctx.globalAlpha = 0.15
362
+ ctx.fillRect(left, top, totalW, totalH)
363
+ ctx.restore()
364
+
365
+ ctx.save()
366
+ ctx.strokeStyle = strokeStyle
367
+ ctx.lineWidth = 1.2
368
+ ctx.strokeRect(left + 0.6, top + 0.6, totalW - 1.2, totalH - 1.2)
369
+ for (let c = 1; c < this.cols; c++) {
370
+ const x = left + c * this.cellW
371
+ ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + totalH); ctx.stroke()
372
+ }
373
+ for (let r = 1; r < this.rows; r++) {
374
+ const y = top + r * this.cellH
375
+ ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(left + totalW, y); ctx.stroke()
376
+ }
377
+ ctx.restore()
378
+
379
+ ctx.save()
380
+ const fontSize = Math.min(this.cellW, this.cellH) * 0.22
381
+ ctx.fillStyle = '#333'
382
+ ctx.font = `bold ${fontSize}px sans-serif`
383
+ ctx.textAlign = 'center'
384
+ ctx.textBaseline = 'middle'
385
+ for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
386
+ const count = this.recordsOf(c, r).length
387
+ if (count === 0) continue
388
+ ctx.fillText(`${count}`, left + (c + 0.5) * this.cellW, top + (r + 0.5) * this.cellH)
389
+ }
390
+ ctx.restore()
391
+ }
392
+
393
+ buildRealObject(): RealObject | undefined {
394
+ return new StockpileGrid3D(this)
395
+ }
396
+ }
397
+
398
+ let _stockpileGridCarrierSeq = 0
399
+ function _nextStockpileGridCarrierRefid(): number {
400
+ return 850000 + (_stockpileGridCarrierSeq++)
401
+ }
402
+
403
+ function resolveDepth(c: Component): number {
404
+ const eff = (c as any)._realObject?.effectiveDepth
405
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
406
+ const d = (c as any)?.state?.depth
407
+ return typeof d === 'number' && Number.isFinite(d) ? d : 0
408
+ }