@operato/scene-storage 10.0.0-beta.48 → 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 +16 -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,508 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Stockpile — 평치(block/floor) 보관 영역. 사각형 footprint + `state.data` 의 record[].
5
+ * 실제 carrier component 를 자식으로 두지 않고, 인벤토리(records)의 *_개수와 종류_* 만으로
6
+ * 가상 carrier mesh 를 자동 적치(stackPattern × carrierPreset)해 시각화한다.
7
+ *
8
+ * 데이터 idiom — StorageRack 과 동일 (`state.data: Record[]`). mover 가 pick/place 하면
9
+ * record push/pop, _realObject 가 records.length 기준 mesh 를 재배치.
10
+ *
11
+ * 1단계 — 시각화: 모델에 `data: [{id:'a'},{id:'b'},...]` 가 들어오면 mesh 가 자동 적치.
12
+ * 2단계(추후) — mover 통합: obtainCarrier 가 transient carrier 컴포넌트 materialize,
13
+ * receiveAt 가 record push + carrier dispose.
14
+ */
15
+
16
+ import * as THREE from 'three'
17
+ import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
18
+ import type { State, Material3D } from '@hatiolab/things-scene'
19
+ import {
20
+ CarrierHolder,
21
+ Placeable,
22
+ SlotTarget,
23
+ type AttachFrame,
24
+ type Alignment,
25
+ type Heights,
26
+ type PlacementArchetype
27
+ } from '@operato/scene-base'
28
+
29
+ import { Stockpile3D } from './stockpile-3d.js'
30
+
31
+ export type StackPattern = 'row' | 'staggered' | 'pyramid' | 'column' | 'pile'
32
+ export type CarrierPreset = 'box' | 'pallet' | 'drum' | 'sack' | 'crate' | 'bale'
33
+ export type PickPolicy = 'lifo' | 'fifo'
34
+
35
+ /** 적치된 carrier 의 record. 향후 sku / weight / 입고시각 등 확장. */
36
+ export interface StockpileRecord {
37
+ id: string
38
+ [key: string]: any
39
+ }
40
+
41
+ export interface StockpileState extends State {
42
+ /** 적치 carrier 의 record 목록 — storage-rack 의 state.data 와 동일 idiom. */
43
+ data?: StockpileRecord[]
44
+
45
+ /** 적치 형태 프리셋. default 'row'. */
46
+ stackPattern?: StackPattern
47
+ /** 가상 carrier 종류 프리셋. default 'box'. */
48
+ carrierPreset?: CarrierPreset
49
+
50
+ /** 가상 carrier 한 개의 크기 (없으면 preset 별 기본). */
51
+ carrierWidth?: number
52
+ carrierHeight?: number
53
+ carrierDepth?: number
54
+ /** 적치된 carrier 사이의 평면(xz) 간격 — 0 이면 딱 붙음, default 3. y(단 적층)는 항상 딱 붙음. */
55
+ carrierGap?: number
56
+
57
+ /** 최대 적치 수 (undefined = 무제한). */
58
+ capacity?: number
59
+ /** 위로 몇 단까지 (undefined = stack 형태에 맡김). */
60
+ stackHeightLimit?: number
61
+ /** 어느 끝에서 빼나. default 'lifo'. */
62
+ pickPolicy?: PickPolicy
63
+
64
+ /** click 시 invoke 할 Popup 컴포넌트 id (StorageRack / RackGrid 와 동일 패턴). */
65
+ popupRef?: string
66
+
67
+ /**
68
+ * Legend 컴포넌트 id. legend 의 `state.status = {field, ranges, defaultColor}` 를
69
+ * 참조해 각 record 의 field 값을 색상으로 매핑한다 (StorageRack 와 동일 패턴).
70
+ * 미명시 시 scene 안 `type='legend'` 첫 컴포넌트 자동 발견.
71
+ */
72
+ legendTarget?: string
73
+
74
+ material3d?: Material3D
75
+ }
76
+
77
+ const SLOT_ID = 'pile'
78
+
79
+ const NATURE: ComponentNature = {
80
+ mutable: false,
81
+ resizable: true,
82
+ rotatable: true,
83
+ properties: [
84
+ { type: 'select', label: 'stack-pattern', name: 'stackPattern',
85
+ property: { options: ['row', 'staggered', 'pyramid', 'column', 'pile'] } },
86
+ { type: 'select', label: 'carrier-preset', name: 'carrierPreset',
87
+ property: { options: ['box', 'pallet', 'drum', 'sack', 'crate', 'bale'] } },
88
+ { type: 'number', label: 'carrier-width', name: 'carrierWidth' },
89
+ { type: 'number', label: 'carrier-height', name: 'carrierHeight' },
90
+ { type: 'number', label: 'carrier-depth', name: 'carrierDepth' },
91
+ { type: 'number', label: 'carrier-gap', name: 'carrierGap' },
92
+ { type: 'number', label: 'capacity', name: 'capacity' },
93
+ { type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
94
+ { type: 'select', label: 'pick-policy', name: 'pickPolicy',
95
+ property: { options: ['lifo', 'fifo'] } },
96
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef',
97
+ property: { component: 'popup' } },
98
+ { type: 'id-input', label: 'legend-target', name: 'legendTarget',
99
+ property: { component: 'legend' },
100
+ placeholder: '미명시 시 scene 의 legend 자동 발견' }
101
+ ],
102
+ help: 'scene/component/stockpile'
103
+ }
104
+
105
+ @sceneComponent('stockpile')
106
+ export default class Stockpile extends CarrierHolder(Placeable(ContainerAbstract)) {
107
+ declare state: StockpileState
108
+ declare _realObject?: Stockpile3D
109
+
110
+ static placement: PlacementArchetype = 'floor'
111
+ static align: Alignment = 'bottom'
112
+ static defaultDepth = (_h: Heights) => 5 // pad 두께
113
+
114
+ get nature(): ComponentNature { return NATURE }
115
+ get anchors() { return [] }
116
+
117
+ // ── records (state.data 의 읽기 전용 뷰, storage-rack 패턴) ─────────────
118
+ get records(): ReadonlyArray<StockpileRecord> {
119
+ return (this.state.data as StockpileRecord[]) ?? []
120
+ }
121
+ get inventoryCount(): number {
122
+ return this.records.length
123
+ }
124
+
125
+ // ── SlottedHolder duck-type — 단일 slot ('pile') ────────────────────────
126
+ slotIds(): ReadonlyArray<string> { return [SLOT_ID] }
127
+
128
+ hasCarrierAt(slotId: string): boolean {
129
+ return slotId === SLOT_ID && this.inventoryCount > 0
130
+ }
131
+
132
+ canReceiveAt(slotId: string, _carrier?: Component): boolean {
133
+ if (slotId !== SLOT_ID) return false
134
+ const cap = this.state.capacity
135
+ if (typeof cap === 'number' && this.inventoryCount >= cap) return false
136
+ return true
137
+ }
138
+
139
+ occupiedSlotIds(): ReadonlyArray<string> {
140
+ return this.inventoryCount > 0 ? [SLOT_ID] : []
141
+ }
142
+ emptySlotIds(): ReadonlyArray<string> {
143
+ return this.canReceiveAt(SLOT_ID) ? [SLOT_ID] : []
144
+ }
145
+
146
+ /**
147
+ * record 한 개를 빼서 carrier 컴포넌트로 transient materialize. mover 가 pickup
148
+ * 하면 이걸 reparent 해서 들고 다닌다. storage-rack._materializeCarrier 와 동일
149
+ * 패턴 — Component.register(type) 으로 클래스 lookup, addComponent({silent: true})
150
+ * 로 cascade 차단.
151
+ */
152
+ obtainCarrier(slotId: string): Component | null {
153
+ if (slotId !== SLOT_ID) return null
154
+ if (this.records.length === 0) return null
155
+ const records = [...this.records]
156
+ const policy = (this.state.pickPolicy ?? 'lifo') as PickPolicy
157
+ const record = policy === 'fifo' ? records.shift()! : records.pop()!
158
+ this._setDataSilently(records)
159
+ return this._materializeCarrier(record)
160
+ }
161
+
162
+ private _materializeCarrier(record: StockpileRecord): Component | null {
163
+ // carrierPreset → 등록된 컴포넌트 type. 미등록(drum/sack/bale 등) 은 시각 mesh
164
+ // 전용이라 carrier 컴포넌트로는 fallback ('parcel'/'box').
165
+ const preset = (this.state.carrierPreset ?? 'box') as CarrierPreset
166
+ const PRESET_TO_TYPE: Record<CarrierPreset, string> = {
167
+ box: 'box',
168
+ pallet: 'pallet',
169
+ crate: 'box',
170
+ drum: 'parcel',
171
+ sack: 'parcel',
172
+ bale: 'parcel'
173
+ }
174
+ const carrierType = (record as any).type ?? PRESET_TO_TYPE[preset] ?? 'parcel'
175
+ const CarrierClass = (Component as any).register(carrierType) as
176
+ | (new (...args: any[]) => Component) | undefined
177
+ if (!CarrierClass) {
178
+ console.warn(`[stockpile] carrier type "${carrierType}" 미등록 — obtainCarrier 실패`)
179
+ return null
180
+ }
181
+
182
+ // 크기 — state.carrier* 또는 preset default
183
+ const stockpileW = (this.state as any).width ?? 100
184
+ const stockpileH = (this.state as any).height ?? 100
185
+ const cw = this.state.carrierWidth ?? 30
186
+ const ch = this.state.carrierHeight ?? 30
187
+ const cd = this.state.carrierDepth ?? 22
188
+
189
+ // id/refid/transform 류 제외 — scene 안 기존 component 충돌 회피 (storage-rack 패턴)
190
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
191
+ // stockpile-inner 좌표 — center 에 놓기 (Mover 가 곧 pick 해 들고 감)
192
+ const carrierState: any = {
193
+ ...recordCopy,
194
+ type: carrierType,
195
+ width: cw,
196
+ height: ch,
197
+ depth: cd,
198
+ refid: _nextStockpileCarrierRefid(),
199
+ left: stockpileW / 2 - cw / 2,
200
+ top: stockpileH / 2 - ch / 2
201
+ }
202
+
203
+ const carrier = new CarrierClass(carrierState, (this as any)._app)
204
+ // silent: refreshMappings cascade 차단 (transient 라 매핑 재계산 불필요)
205
+ ;(this as any).addComponent(carrier, { silent: true })
206
+ void (carrier as any).realObject
207
+ ;(carrier as any).applyHolderAttachPoint?.()
208
+ return carrier
209
+ }
210
+
211
+ /**
212
+ * carrier 를 받아 record 로 push, carrier 객체는 dispose (시각은 _realObject 가
213
+ * records 길이 기준 자동 갱신). capacity 초과 시도는 canReceiveAt 가 이미 차단.
214
+ */
215
+ async receiveAt(_slotId: string, carrier: Component, _options?: any): Promise<void> {
216
+ const cstate: any = (carrier as any)?.state ?? {}
217
+ const cid = cstate.id ?? `stk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
218
+ const record: StockpileRecord = {
219
+ id: String(cid),
220
+ ...(cstate.type ? { type: cstate.type } : {}),
221
+ ...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
222
+ ...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
223
+ ...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
224
+ }
225
+ const records = [...this.records, record]
226
+ this._setDataSilently(records)
227
+ ;(carrier as any)?.dispose?.()
228
+ }
229
+
230
+ /** dispatch → handoff 의 accept 분기 — receiveAt 으로 위임. */
231
+ async accept(carrier: Component, options?: any): Promise<void> {
232
+ return this.receiveAt(SLOT_ID, carrier, options)
233
+ }
234
+ async receive(carrier: Component, options?: any): Promise<void> {
235
+ return this.receiveAt(SLOT_ID, carrier, options)
236
+ }
237
+
238
+ /**
239
+ * mover 가 obtainCarrier 로 빼낸 transient carrier 를 pad 위에 안착 (잠깐 보이는
240
+ * 위치). mover 가 pick 하면 즉시 deck 으로 reparent 되므로 잔류는 짧다.
241
+ * Spot 과 동일 idiom — pad-top + carrier 의 halfDepth 만큼 들어올림.
242
+ */
243
+ attachPointFor(carrier: Component): AttachFrame | null {
244
+ const ro = this._realObject
245
+ const frame = ro?.getAttachFrame?.()
246
+ if (!frame) return null
247
+ const carrierDepth = resolveDepth(carrier)
248
+ return {
249
+ attach: frame,
250
+ localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
251
+ }
252
+ }
253
+
254
+ slotTargetAt(slotId: string): SlotTarget {
255
+ return new SlotTarget(this as any, slotId)
256
+ }
257
+ getSlotAttachObject3d(slotId: string): any {
258
+ // slotId 가 'pile' 이면 pad. record.id 면 그 carrier mesh — popup tether 가 그
259
+ // stock 에 정확히 연결되도록.
260
+ return (this as any)._realObject?.getAttachFrame?.(slotId)
261
+ }
262
+
263
+ /** state.data 갱신 + 3D 즉시 재배치. */
264
+ private _setDataSilently(records: StockpileRecord[]): void {
265
+ ;(this.state as any).data = records
266
+ this._realObject?.update?.()
267
+ }
268
+
269
+ /**
270
+ * 2D — outlined pad + 인벤토리 카운트 텍스트. 평치 의도가 한 눈에 — 사각 영역 위에
271
+ * 적재된 carrier 수가 가운데 큰 글씨로.
272
+ */
273
+ render(ctx: CanvasRenderingContext2D) {
274
+ const { left = 0, top = 0, width = 100, height = 100 } = this.state
275
+ const fillStyle = (this.state.fillStyle as string) || '#c89c5c'
276
+ const strokeStyle = (this.state.strokeStyle as string) || fillStyle
277
+
278
+ // pad (반투명)
279
+ ctx.save()
280
+ ctx.fillStyle = fillStyle
281
+ ctx.globalAlpha = 0.18
282
+ ctx.fillRect(left, top, width, height)
283
+ ctx.restore()
284
+
285
+ // outline (dashed)
286
+ ctx.save()
287
+ ctx.strokeStyle = strokeStyle
288
+ ctx.lineWidth = 1.5
289
+ ctx.setLineDash([6, 3])
290
+ ctx.strokeRect(left + 0.75, top + 0.75, width - 1.5, height - 1.5)
291
+ ctx.setLineDash([])
292
+ ctx.restore()
293
+
294
+ // 인벤토리 수 + capacity (있으면)
295
+ ctx.save()
296
+ const fontSize = Math.min(width, height) * 0.22
297
+ ctx.fillStyle = '#333'
298
+ ctx.font = `bold ${fontSize}px sans-serif`
299
+ ctx.textAlign = 'center'
300
+ ctx.textBaseline = 'middle'
301
+ const label = typeof this.state.capacity === 'number'
302
+ ? `${this.inventoryCount}/${this.state.capacity}`
303
+ : `${this.inventoryCount}`
304
+ ctx.fillText(label, left + width / 2, top + height / 2)
305
+ ctx.restore()
306
+ }
307
+
308
+ // ── Popup 연동 — storage-rack 동일 패턴 ───────────────────────────────
309
+ /**
310
+ * things-scene EventManager3D 가 raycast → object3d.userData.context.component 의
311
+ * `trigger("click", mouseEvent)` 을 호출 → eventMap 으로 receive. pad / carrier mesh
312
+ * 어느 쪽을 클릭하든 stockpile 전체 popup invoke (단일 slot 이라 cell 구분 없음).
313
+ */
314
+ get eventMap() {
315
+ return {
316
+ '(self)': {
317
+ '(self)': {
318
+ click: this._onStockpileClick
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ private _onStockpileClick = (mouseEvent: MouseEvent) => {
325
+ // view mode 에서만 동작 (modeling 중 click 은 framework 선택 로직 우선).
326
+ if (!(this as any).app?.isViewMode) return
327
+ const hit = this._raycastStockpileHit(mouseEvent)
328
+ if (!hit) return
329
+ // hit.object 가 carrier mesh 면 userData.recordId 보유 → 그 stock 의 popup.
330
+ // pad / 기타면 stockpile 전체 popup.
331
+ const recordId = hit.object?.userData?.recordId as string | undefined
332
+ this._invokePopup(typeof recordId === 'string' ? recordId : undefined)
333
+ }
334
+
335
+ /**
336
+ * state.popupRef 가 가리키는 Popup 컴포넌트를 invoke.
337
+ * - recordId 명시 → 그 record 의 anchor = mesh. payload = 해당 record.
338
+ * - 미명시 (pad 클릭) → 'pile' anchor (pad). payload = 전체 inventory.
339
+ * anchor 는 SlotTarget — Popup 이 anchor.holder.getSlotAttachObject3d(anchor.slotId)
340
+ * 로 tether 위치 잡음 (storage-rack 와 동일 패턴).
341
+ */
342
+ private _invokePopup(recordId?: string): void {
343
+ const popupRefId = this.state.popupRef
344
+ if (!popupRefId) return
345
+ const popupComp: any = (this as any).root?.findById?.(popupRefId)
346
+ if (!popupComp || typeof popupComp.openPopup !== 'function') {
347
+ console.warn(`[stockpile] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
348
+ return
349
+ }
350
+ if (recordId) {
351
+ const record = this.records.find(r => r.id === recordId) ?? { id: recordId }
352
+ const anchor = this.slotTargetAt(recordId)
353
+ popupComp.openPopup(record, { anchor })
354
+ } else {
355
+ const anchor = this.slotTargetAt(SLOT_ID)
356
+ popupComp.openPopup({
357
+ componentId: (this.state as any).id,
358
+ records: this.records,
359
+ inventoryCount: this.inventoryCount,
360
+ capacity: this.state.capacity,
361
+ carrierPreset: this.state.carrierPreset,
362
+ stackPattern: this.state.stackPattern
363
+ }, { anchor })
364
+ }
365
+ }
366
+
367
+ /**
368
+ * 클릭 시 framework 의 mouse NDC 를 재사용해 raycast → *우리 stockpile* 의 어떤 mesh 가
369
+ * closest hit 인지 반환 (다른 object 가 더 가까우면 undefined). storage-rack._raycastRackHit
370
+ * 와 동일 패턴 — capability.getObjectsByRaycast 우선, 없으면 scene/camera/canvas 재구성.
371
+ */
372
+ private _raycastStockpileHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
373
+ const ro: any = (this as any)._realObject
374
+ if (!ro?.object3d) return undefined
375
+
376
+ const tc: any = ro.threeContainer
377
+ if (!tc) return undefined
378
+
379
+ const cap: any = tc._threeCapability ?? tc._capability
380
+ let intersects: THREE.Intersection[] | undefined
381
+ if (cap?.getObjectsByRaycast) {
382
+ intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
383
+ }
384
+ if (!intersects || intersects.length === 0) {
385
+ const scene = tc.scene3d as THREE.Scene | undefined
386
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
387
+ const camera =
388
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
389
+ (cap?.activeCamera as THREE.Camera | undefined) ??
390
+ (cap?.camera as THREE.Camera | undefined)
391
+ const canvas = renderer?.domElement
392
+ if (!scene || !canvas || !camera) return undefined
393
+ const rect = canvas.getBoundingClientRect()
394
+ if (rect.width === 0 || rect.height === 0) return undefined
395
+ const ndc = new THREE.Vector2(
396
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
397
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
398
+ )
399
+ const raycaster = new THREE.Raycaster()
400
+ raycaster.setFromCamera(ndc, camera)
401
+ intersects = raycaster.intersectObjects(scene.children, true)
402
+ }
403
+ if (!intersects || intersects.length === 0) return undefined
404
+
405
+ // 가장 가까운 hit 이 *이 stockpile* 의 descendant 여야 한다 (다른 mesh 가 사이에 있으면 skip).
406
+ const closest = intersects[0]
407
+ let obj: THREE.Object3D | null = closest.object
408
+ while (obj) {
409
+ if (obj.userData?.context === ro) return closest
410
+ obj = obj.parent
411
+ }
412
+ return undefined
413
+ }
414
+
415
+ // ── Legend — record 의 field 값 → 색상 매핑 (StorageRack 동일 패턴) ───────
416
+ private _legendTarget?: Component
417
+
418
+ /**
419
+ * Legend 컴포넌트 lookup. 우선:
420
+ * 1) state.legendTarget id 명시
421
+ * 2) scene 전체에서 type='legend' 첫 번째 (자동 발견)
422
+ */
423
+ get legendTarget(): Component | undefined {
424
+ if (this._legendTarget) return this._legendTarget
425
+ const id = this.state.legendTarget
426
+ if (id) {
427
+ const found = ((this as any).root)?.findById?.(id) as Component | undefined
428
+ if (found) {
429
+ this._legendTarget = found
430
+ ;(found as any).on?.('change', this._onLegendChanged, this)
431
+ return found
432
+ }
433
+ }
434
+ const visit = (node: any): Component | undefined => {
435
+ if (!node) return undefined
436
+ if (node.state?.type === 'legend') return node as Component
437
+ const children = node.components as Component[] | undefined
438
+ if (children) for (const c of children) {
439
+ const r = visit(c)
440
+ if (r) return r
441
+ }
442
+ return undefined
443
+ }
444
+ const found = visit((this as any).root)
445
+ if (found) {
446
+ this._legendTarget = found
447
+ ;(found as any).on?.('change', this._onLegendChanged, this)
448
+ }
449
+ return found
450
+ }
451
+
452
+ private _onLegendChanged = (): void => {
453
+ ;(this._realObject as any)?.update?.()
454
+ }
455
+
456
+ /**
457
+ * record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
458
+ * - range.value === recordValue (카테고리)
459
+ * - range.min ≤ Number(v) < range.max (수치)
460
+ * - 매칭 없으면 defaultColor
461
+ */
462
+ resolveLegendColor(record: any): string | undefined {
463
+ const legend = this.legendTarget
464
+ if (!legend) return undefined
465
+ const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
466
+ if (!status) return undefined
467
+ const field = status.field as string | undefined
468
+ const ranges = status.ranges as any[] | undefined
469
+ if (!field || !Array.isArray(ranges)) return undefined
470
+
471
+ const value = record?.[field]
472
+ if (value === undefined || value === null) return status.defaultColor
473
+
474
+ for (const range of ranges) {
475
+ if (!range) continue
476
+ if (range.value !== undefined) {
477
+ if (range.value === value) return range.color
478
+ continue
479
+ }
480
+ const num = Number(value)
481
+ if (!Number.isFinite(num)) continue
482
+ const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
483
+ const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
484
+ const minOk = min === undefined || num >= min
485
+ const maxOk = max === undefined || num < max
486
+ if (minOk && maxOk) return range.color
487
+ }
488
+ return status.defaultColor as string | undefined
489
+ }
490
+
491
+ buildRealObject(): RealObject | undefined {
492
+ return new Stockpile3D(this)
493
+ }
494
+ }
495
+
496
+ // transient carrier refid — scene 내 기존 컴포넌트와 충돌 회피용 큰 시작값
497
+ // (storage-rack 의 _nextCarrierRefid 와 같은 idiom, 별도 범위로 분리).
498
+ let _stockpileCarrierSeq = 0
499
+ function _nextStockpileCarrierRefid(): number {
500
+ return 800000 + (_stockpileCarrierSeq++)
501
+ }
502
+
503
+ function resolveDepth(c: Component): number {
504
+ const eff = (c as any)._realObject?.effectiveDepth
505
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
506
+ const d = (c as any)?.state?.depth
507
+ return typeof d === 'number' && Number.isFinite(d) ? d : 0
508
+ }
@@ -10,6 +10,7 @@ import {
10
10
  CarrierHolder,
11
11
  Placeable,
12
12
  SlotTarget,
13
+ componentBoundingBox,
13
14
  type AttachFrame,
14
15
  type Alignment,
15
16
  type Heights,
@@ -19,6 +20,7 @@ import {
19
20
  } from '@operato/scene-base'
20
21
 
21
22
  import { StorageRack3D } from './storage-rack-3d.js'
23
+ import { rackAcceptsMoverTool } from './rack-capability.js'
22
24
 
23
25
  /** Rack 컴포넌트 state */
24
26
  export interface StorageRackState extends State {
@@ -178,14 +180,25 @@ export default class Rack
178
180
  return (this.state as any)?.isObstacle !== false
179
181
  }
180
182
  obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
181
- const s: any = this.state
182
- if (typeof s?.left !== 'number') return null
183
- return {
184
- left: s.left, top: s.top,
185
- width: s.width, height: s.height,
186
- y: typeof s.zPos === 'number' ? s.zPos : 0,
187
- zHeight: typeof s.depth === 'number' ? s.depth : 0
188
- }
183
+ // scene-base componentBoundingBox 위임 — rotation 적용된 AABB.
184
+ return componentBoundingBox(this)
185
+ }
186
+
187
+ /**
188
+ * rack *_놓으려는 mover_* 능력을 수용하는가. canAccept(carrier) 는 carrier
189
+ * 타입만 보지만, 이건 *_적재 mover 물리 능력_* 을 본다.
190
+ *
191
+ * rack 선반 적재는 *_높이 도달_* mover (crane / stacker / forklift) 의 몫이다.
192
+ * 평탄 데크 차량(agv-deck)은 바닥 운반 전용 — 선반에 직접 못 올린다. 따라서
193
+ * agv-deck 류는 거부 → transfer planner 가 자동으로 in-port 경유(환승)를 택한다.
194
+ *
195
+ * 거부 목록은 state.blockedTools 로 override (default ['agv-deck']). 향후 선반
196
+ * 높이(level) vs mover liftHeight 비교로 정교화 가능.
197
+ */
198
+ canAcceptFromMover(mover: any): boolean {
199
+ const toolType = mover?.toolType ?? mover?.state?.toolType
200
+ const blocked = (this.state as any)?.blockedTools ?? ['agv-deck']
201
+ return rackAcceptsMoverTool(toolType, blocked)
189
202
  }
190
203
 
191
204
  get nature() {
@@ -10,6 +10,9 @@
10
10
  * - spot — virtual placement marker
11
11
  */
12
12
  import spot from './spot.js'
13
+ import stockpile from './stockpile.js'
14
+ import stockpileGrid from './stockpile-grid.js'
15
+ import pickingStation from './picking-station.js'
13
16
  const pallet = new URL('../../icons/pallet.png', import.meta.url).href
14
17
  const box = new URL('../../icons/box.png', import.meta.url).href
15
18
  const parcel = new URL('../../icons/parcel.png', import.meta.url).href
@@ -196,5 +199,8 @@ export default [
196
199
  ]
197
200
  }
198
201
  },
199
- spot
202
+ spot,
203
+ stockpile,
204
+ stockpileGrid,
205
+ pickingStation
200
206
  ]
@@ -0,0 +1,23 @@
1
+ // Reuse parcel.png as a placeholder icon until a dedicated picking-station icon is drawn.
2
+ const icon = new URL('../../icons/parcel.png', import.meta.url).href
3
+
4
+ export default {
5
+ type: 'picking-station',
6
+ description:
7
+ 'work station — carrier stays for processingTimeMs then becomes idle. anchors AMR↔human handoff',
8
+ group: 'storage',
9
+ icon,
10
+ model: {
11
+ type: 'picking-station',
12
+ top: 200,
13
+ left: 400,
14
+ width: 120,
15
+ height: 100,
16
+ depth: 60,
17
+ rotation: 0,
18
+ fillStyle: '#5a8ab8',
19
+ strokeStyle: '#3d6a8f',
20
+ processingTimeMs: 3000,
21
+ status: 'idle'
22
+ }
23
+ }
@@ -0,0 +1,39 @@
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
+
4
+ export default {
5
+ type: 'stockpile-grid',
6
+ description:
7
+ 'grid of block/floor storage cells — each cell is a stockpile with its own records, capacity, and preset override',
8
+ group: 'storage',
9
+ icon,
10
+ model: {
11
+ type: 'stockpile-grid',
12
+ top: 200,
13
+ left: 400,
14
+ width: 300,
15
+ height: 200,
16
+ depth: 5,
17
+ rotation: 0,
18
+ fillStyle: '#c89c5c',
19
+ strokeStyle: '#7a5a2e',
20
+ cols: 3,
21
+ rows: 2,
22
+ cellWidth: 100,
23
+ cellHeight: 100,
24
+ stackPattern: 'row',
25
+ carrierPreset: 'box',
26
+ carrierWidth: 30,
27
+ carrierHeight: 30,
28
+ carrierDepth: 22,
29
+ carrierGap: 10,
30
+ capacity: 20,
31
+ pickPolicy: 'lifo',
32
+ // 데모 — 일부 cell 에 records 미리 채워서 끌어 놓자 마자 적치 보이게.
33
+ data: [
34
+ { col: 0, row: 0, data: [{ id: 'a1' }, { id: 'a2' }] },
35
+ { col: 1, row: 0, data: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }] },
36
+ { col: 2, row: 1, data: [{ id: 'c1' }] }
37
+ ]
38
+ }
39
+ }
@@ -0,0 +1,32 @@
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
+
4
+ export default {
5
+ type: 'stockpile',
6
+ description:
7
+ 'block/floor storage — rectangular footprint with auto-stacked virtual carriers (stackPattern × carrierPreset) driven by state.data records',
8
+ group: 'storage' /* line|shape|textAndMedia|chartAndGauge|table|container|dataSource|3D|facility|storage|conveyance|transport|manufacturing|form|etc */,
9
+ icon,
10
+ model: {
11
+ type: 'stockpile',
12
+ top: 200,
13
+ left: 400,
14
+ width: 200,
15
+ height: 150,
16
+ depth: 5,
17
+ rotation: 0,
18
+ fillStyle: '#c89c5c',
19
+ // 바닥 페인트 라인 의도 — fillStyle 보다 진한 색으로 영역 마킹이 눈에 띄게.
20
+ strokeStyle: '#7a5a2e',
21
+ stackPattern: 'row',
22
+ carrierPreset: 'box',
23
+ carrierWidth: 30,
24
+ carrierHeight: 30,
25
+ carrierDepth: 22,
26
+ carrierGap: 10,
27
+ capacity: 30,
28
+ pickPolicy: 'lifo',
29
+ // 데모용 — 새로 끌어다 놓았을 때 적치된 모습이 즉시 보이도록 record 몇 개.
30
+ data: [{ id: 'p-1' }, { id: 'p-2' }, { id: 'p-3' }]
31
+ }
32
+ }