@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,427 @@
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
+ RecordStorage,
23
+ SlotTarget,
24
+ type AttachFrame,
25
+ type Alignment,
26
+ type Heights,
27
+ type PlacementArchetype
28
+ } from '@operato/scene-base'
29
+
30
+ import { Stockpile3D } from './stockpile-3d.js'
31
+
32
+ export type StackPattern = 'row' | 'staggered' | 'pyramid' | 'column' | 'pile'
33
+ export type CarrierPreset = 'box' | 'pallet' | 'drum' | 'sack' | 'crate' | 'bale'
34
+ export type PickPolicy = 'lifo' | 'fifo'
35
+
36
+ /** 적치된 carrier 의 record. 향후 sku / weight / 입고시각 등 확장. */
37
+ export interface StockpileRecord {
38
+ id: string
39
+ [key: string]: any
40
+ }
41
+
42
+ export interface StockpileState extends State {
43
+ /** 적치 carrier 의 record 목록 — storage-rack 의 state.data 와 동일 idiom. */
44
+ data?: StockpileRecord[]
45
+
46
+ /** 적치 형태 프리셋. default 'row'. */
47
+ stackPattern?: StackPattern
48
+ /** 가상 carrier 종류 프리셋. default 'box'. */
49
+ carrierPreset?: CarrierPreset
50
+
51
+ /** 가상 carrier 한 개의 크기 (없으면 preset 별 기본). */
52
+ carrierWidth?: number
53
+ carrierHeight?: number
54
+ carrierDepth?: number
55
+ /** 적치된 carrier 사이의 평면(xz) 간격 — 0 이면 딱 붙음, default 3. y(단 적층)는 항상 딱 붙음. */
56
+ carrierGap?: number
57
+
58
+ /** 최대 적치 수 (undefined = 무제한). */
59
+ capacity?: number
60
+ /** 위로 몇 단까지 (undefined = stack 형태에 맡김). */
61
+ stackHeightLimit?: number
62
+ /** 어느 끝에서 빼나. default 'lifo'. */
63
+ pickPolicy?: PickPolicy
64
+
65
+ /** click 시 invoke 할 Popup 컴포넌트 id (StorageRack / RackGrid 와 동일 패턴). */
66
+ popupRef?: string
67
+
68
+ /**
69
+ * Legend 컴포넌트 id. legend 의 `state.status = {field, ranges, defaultColor}` 를
70
+ * 참조해 각 record 의 field 값을 색상으로 매핑한다 (StorageRack 와 동일 패턴).
71
+ * 미명시 시 scene 안 `type='legend'` 첫 컴포넌트 자동 발견.
72
+ */
73
+ legendTarget?: string
74
+
75
+ material3d?: Material3D
76
+ }
77
+
78
+ const SLOT_ID = 'pile'
79
+
80
+ const NATURE: ComponentNature = {
81
+ mutable: false,
82
+ resizable: true,
83
+ rotatable: true,
84
+ properties: [
85
+ { type: 'select', label: 'stack-pattern', name: 'stackPattern',
86
+ property: { options: ['row', 'staggered', 'pyramid', 'column', 'pile'] } },
87
+ { type: 'select', label: 'carrier-preset', name: 'carrierPreset',
88
+ property: { options: ['box', 'pallet', 'drum', 'sack', 'crate', 'bale'] } },
89
+ { type: 'number', label: 'carrier-width', name: 'carrierWidth' },
90
+ { type: 'number', label: 'carrier-height', name: 'carrierHeight' },
91
+ { type: 'number', label: 'carrier-depth', name: 'carrierDepth' },
92
+ { type: 'number', label: 'carrier-gap', name: 'carrierGap' },
93
+ { type: 'number', label: 'capacity', name: 'capacity' },
94
+ { type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
95
+ { type: 'select', label: 'pick-policy', name: 'pickPolicy',
96
+ property: { options: ['lifo', 'fifo'] } },
97
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef',
98
+ property: { component: 'popup' } },
99
+ { type: 'id-input', label: 'legend-target', name: 'legendTarget',
100
+ property: { component: 'legend' },
101
+ placeholder: '미명시 시 scene 의 legend 자동 발견' }
102
+ ],
103
+ help: 'scene/component/stockpile'
104
+ }
105
+
106
+ @sceneComponent('stockpile')
107
+ export default class Stockpile extends RecordStorage<StockpileRecord>()(
108
+ CarrierHolder(Placeable(ContainerAbstract))
109
+ ) {
110
+ declare state: StockpileState
111
+ declare _realObject?: Stockpile3D
112
+
113
+ static placement: PlacementArchetype = 'floor'
114
+ static align: Alignment = 'bottom'
115
+ static defaultDepth = (_h: Heights) => 5 // pad 두께
116
+
117
+ get nature(): ComponentNature { return NATURE }
118
+ get anchors() { return [] }
119
+
120
+ // ── records / inventoryCount: RecordStorage mixin 제공.
121
+
122
+ // ── SlottedHolder duck-type override — 단일 slot ('pile') ───────────────
123
+ // mixin default 는 record 마다 slotId. Stockpile 은 *_단일 슬롯_* 시맨틱이라
124
+ // 자기 구현 유지.
125
+ slotIds(): ReadonlyArray<string> { return [SLOT_ID] }
126
+
127
+ hasCarrierAt(slotId: string): boolean {
128
+ return slotId === SLOT_ID && this.inventoryCount > 0
129
+ }
130
+
131
+ canReceiveAt(slotId: string, _carrier?: Component): boolean {
132
+ if (slotId !== SLOT_ID) return false
133
+ const cap = this.state.capacity
134
+ if (typeof cap === 'number' && this.inventoryCount >= cap) return false
135
+ return true
136
+ }
137
+
138
+ occupiedSlotIds(): ReadonlyArray<string> {
139
+ return this.inventoryCount > 0 ? [SLOT_ID] : []
140
+ }
141
+ emptySlotIds(): ReadonlyArray<string> {
142
+ return this.canReceiveAt(SLOT_ID) ? [SLOT_ID] : []
143
+ }
144
+
145
+ /**
146
+ * record 한 개를 빼서 carrier 컴포넌트로 transient materialize. mover 가 pickup
147
+ * 하면 이걸 reparent 해서 들고 다닌다. storage-rack._materializeCarrier 와 동일
148
+ * 패턴 — Component.register(type) 으로 클래스 lookup, addComponent({silent: true})
149
+ * 로 cascade 차단.
150
+ */
151
+ obtainCarrier(slotId: string): Component | null {
152
+ if (slotId !== SLOT_ID) return null
153
+ if (this.records.length === 0) return null
154
+ const records = [...this.records]
155
+ const policy = (this.state.pickPolicy ?? 'lifo') as PickPolicy
156
+ const record = policy === 'fifo' ? records.shift()! : records.pop()!
157
+ this._setDataSilently(records)
158
+ return this._materializeCarrier(record)
159
+ }
160
+
161
+ private _materializeCarrier(record: StockpileRecord): Component | null {
162
+ // carrierPreset → 등록된 컴포넌트 type. 미등록(drum/sack/bale 등) 은 시각 mesh
163
+ // 전용이라 carrier 컴포넌트로는 fallback ('parcel'/'box').
164
+ const preset = (this.state.carrierPreset ?? 'box') as CarrierPreset
165
+ const PRESET_TO_TYPE: Record<CarrierPreset, string> = {
166
+ box: 'box',
167
+ pallet: 'pallet',
168
+ crate: 'box',
169
+ drum: 'parcel',
170
+ sack: 'parcel',
171
+ bale: 'parcel'
172
+ }
173
+ const carrierType = (record as any).type ?? PRESET_TO_TYPE[preset] ?? 'parcel'
174
+ const CarrierClass = (Component as any).register(carrierType) as
175
+ | (new (...args: any[]) => Component) | undefined
176
+ if (!CarrierClass) {
177
+ console.warn(`[stockpile] carrier type "${carrierType}" 미등록 — obtainCarrier 실패`)
178
+ return null
179
+ }
180
+
181
+ // 크기 — state.carrier* 또는 preset default
182
+ const stockpileW = (this.state as any).width ?? 100
183
+ const stockpileH = (this.state as any).height ?? 100
184
+ const cw = this.state.carrierWidth ?? 30
185
+ const ch = this.state.carrierHeight ?? 30
186
+ const cd = this.state.carrierDepth ?? 22
187
+
188
+ // id/refid/transform 류 제외 — scene 안 기존 component 충돌 회피 (storage-rack 패턴)
189
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
190
+ // stockpile-inner 좌표 — center 에 놓기 (Mover 가 곧 pick 해 들고 감)
191
+ const carrierState: any = {
192
+ ...recordCopy,
193
+ type: carrierType,
194
+ width: cw,
195
+ height: ch,
196
+ depth: cd,
197
+ refid: _nextStockpileCarrierRefid(),
198
+ left: stockpileW / 2 - cw / 2,
199
+ top: stockpileH / 2 - ch / 2
200
+ }
201
+
202
+ const carrier = new CarrierClass(carrierState, (this as any)._app)
203
+ // silent: refreshMappings cascade 차단 (transient 라 매핑 재계산 불필요)
204
+ ;(this as any).addComponent(carrier, { silent: true })
205
+ void (carrier as any).realObject
206
+ ;(carrier as any).applyHolderAttachPoint?.()
207
+ return carrier
208
+ }
209
+
210
+ /**
211
+ * carrier 를 받아 record 로 push, carrier 객체는 dispose (시각은 _realObject 가
212
+ * records 길이 기준 자동 갱신). capacity 초과 시도는 canReceiveAt 가 이미 차단.
213
+ */
214
+ async receiveAt(_slotId: string, carrier: Component, _options?: any): Promise<void> {
215
+ const cstate: any = (carrier as any)?.state ?? {}
216
+ const cid = cstate.id ?? `stk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
217
+ const record: StockpileRecord = {
218
+ id: String(cid),
219
+ ...(cstate.type ? { type: cstate.type } : {}),
220
+ ...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
221
+ ...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
222
+ ...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
223
+ }
224
+ const records = [...this.records, record]
225
+ this._setDataSilently(records)
226
+ ;(carrier as any)?.dispose?.()
227
+ }
228
+
229
+ /** dispatch → handoff 의 accept 분기 — receiveAt 으로 위임. */
230
+ async accept(carrier: Component, options?: any): Promise<void> {
231
+ return this.receiveAt(SLOT_ID, carrier, options)
232
+ }
233
+ async receive(carrier: Component, options?: any): Promise<void> {
234
+ return this.receiveAt(SLOT_ID, carrier, options)
235
+ }
236
+
237
+ /**
238
+ * mover 가 obtainCarrier 로 빼낸 transient carrier 를 pad 위에 안착 (잠깐 보이는
239
+ * 위치). mover 가 pick 하면 즉시 deck 으로 reparent 되므로 잔류는 짧다.
240
+ * Spot 과 동일 idiom — pad-top + carrier 의 halfDepth 만큼 들어올림.
241
+ */
242
+ attachPointFor(carrier: Component): AttachFrame | null {
243
+ const ro = this._realObject
244
+ const frame = ro?.getAttachFrame?.()
245
+ if (!frame) return null
246
+ const carrierDepth = resolveDepth(carrier)
247
+ return {
248
+ attach: frame,
249
+ localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
250
+ }
251
+ }
252
+
253
+ slotTargetAt(slotId: string): SlotTarget {
254
+ return new SlotTarget(this as any, slotId)
255
+ }
256
+ getSlotAttachObject3d(slotId: string): any {
257
+ // slotId 가 'pile' 이면 pad. record.id 면 그 carrier mesh — popup tether 가 그
258
+ // stock 에 정확히 연결되도록.
259
+ return (this as any)._realObject?.getAttachFrame?.(slotId)
260
+ }
261
+
262
+ // _setDataSilently 는 RecordStorage mixin 제공 — 다만 mixin 은 _rebuildVisual
263
+ // 호출. Stockpile 의 3D 갱신은 update() 라서 hook override.
264
+ _rebuildVisual(): void {
265
+ this._realObject?.update?.()
266
+ }
267
+
268
+ /**
269
+ * 2D — outlined pad + 인벤토리 카운트 텍스트. 평치 의도가 한 눈에 — 사각 영역 위에
270
+ * 적재된 carrier 수가 가운데 큰 글씨로.
271
+ */
272
+ render(ctx: CanvasRenderingContext2D) {
273
+ const { left = 0, top = 0, width = 100, height = 100 } = this.state
274
+ const fillStyle = (this.state.fillStyle as string) || '#c89c5c'
275
+ const strokeStyle = (this.state.strokeStyle as string) || fillStyle
276
+
277
+ // pad (반투명)
278
+ ctx.save()
279
+ ctx.fillStyle = fillStyle
280
+ ctx.globalAlpha = 0.18
281
+ ctx.fillRect(left, top, width, height)
282
+ ctx.restore()
283
+
284
+ // outline (dashed)
285
+ ctx.save()
286
+ ctx.strokeStyle = strokeStyle
287
+ ctx.lineWidth = 1.5
288
+ ctx.setLineDash([6, 3])
289
+ ctx.strokeRect(left + 0.75, top + 0.75, width - 1.5, height - 1.5)
290
+ ctx.setLineDash([])
291
+ ctx.restore()
292
+
293
+ // 인벤토리 수 + capacity (있으면)
294
+ ctx.save()
295
+ const fontSize = Math.min(width, height) * 0.22
296
+ ctx.fillStyle = '#333'
297
+ ctx.font = `bold ${fontSize}px sans-serif`
298
+ ctx.textAlign = 'center'
299
+ ctx.textBaseline = 'middle'
300
+ const label = typeof this.state.capacity === 'number'
301
+ ? `${this.inventoryCount}/${this.state.capacity}`
302
+ : `${this.inventoryCount}`
303
+ ctx.fillText(label, left + width / 2, top + height / 2)
304
+ ctx.restore()
305
+ }
306
+
307
+ // ── Popup 연동 — storage-rack 동일 패턴 ───────────────────────────────
308
+ /**
309
+ * things-scene EventManager3D 가 raycast → object3d.userData.context.component 의
310
+ * `trigger("click", mouseEvent)` 을 호출 → eventMap 으로 receive. pad / carrier mesh
311
+ * 어느 쪽을 클릭하든 stockpile 전체 popup invoke (단일 slot 이라 cell 구분 없음).
312
+ */
313
+ get eventMap() {
314
+ return {
315
+ '(self)': {
316
+ '(self)': {
317
+ click: this._onStockpileClick
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ private _onStockpileClick = (mouseEvent: MouseEvent) => {
324
+ // view mode 에서만 동작 (modeling 중 click 은 framework 선택 로직 우선).
325
+ if (!(this as any).app?.isViewMode) return
326
+ const hit = this._raycastStockpileHit(mouseEvent)
327
+ if (!hit) return
328
+ // hit.object 가 carrier mesh 면 userData.recordId 보유 → 그 stock 의 popup.
329
+ // pad / 기타면 stockpile 전체 popup.
330
+ const recordId = hit.object?.userData?.recordId as string | undefined
331
+ this._dispatchStockpilePopup(typeof recordId === 'string' ? recordId : undefined)
332
+ }
333
+
334
+ /**
335
+ * state.popupRef 가 가리키는 Popup 컴포넌트를 invoke.
336
+ * - recordId 명시 → 그 record 의 anchor = mesh. payload = 해당 record.
337
+ * - 미명시 (pad 클릭) → 'pile' anchor (pad). payload = 전체 inventory.
338
+ *
339
+ * RecordStorage mixin 의 `_invokePopup(slotId, payload)` 를 활용. 단일 slot
340
+ * 시맨틱이 record/pile 두 모드라 mixin 위 wrapper 로 dispatch.
341
+ */
342
+ private _dispatchStockpilePopup(recordId?: string): void {
343
+ if (!this.state.popupRef) return
344
+ if (recordId) {
345
+ const record = (this.records as ReadonlyArray<StockpileRecord>)
346
+ .find(r => r.id === recordId) ?? { id: recordId }
347
+ this._invokePopup(recordId, record)
348
+ } else {
349
+ this._invokePopup(SLOT_ID, {
350
+ componentId: (this.state as any).id,
351
+ records: this.records,
352
+ inventoryCount: this.inventoryCount,
353
+ capacity: this.state.capacity,
354
+ carrierPreset: this.state.carrierPreset,
355
+ stackPattern: this.state.stackPattern
356
+ })
357
+ }
358
+ }
359
+
360
+ /**
361
+ * 클릭 시 framework 의 mouse NDC 를 재사용해 raycast → *우리 stockpile* 의 어떤 mesh 가
362
+ * closest hit 인지 반환 (다른 object 가 더 가까우면 undefined). storage-rack._raycastRackHit
363
+ * 와 동일 패턴 — capability.getObjectsByRaycast 우선, 없으면 scene/camera/canvas 재구성.
364
+ */
365
+ private _raycastStockpileHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
366
+ const ro: any = (this as any)._realObject
367
+ if (!ro?.object3d) return undefined
368
+
369
+ const tc: any = ro.threeContainer
370
+ if (!tc) return undefined
371
+
372
+ const cap: any = tc._threeCapability ?? tc._capability
373
+ let intersects: THREE.Intersection[] | undefined
374
+ if (cap?.getObjectsByRaycast) {
375
+ intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
376
+ }
377
+ if (!intersects || intersects.length === 0) {
378
+ const scene = tc.scene3d as THREE.Scene | undefined
379
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
380
+ const camera =
381
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
382
+ (cap?.activeCamera as THREE.Camera | undefined) ??
383
+ (cap?.camera as THREE.Camera | undefined)
384
+ const canvas = renderer?.domElement
385
+ if (!scene || !canvas || !camera) return undefined
386
+ const rect = canvas.getBoundingClientRect()
387
+ if (rect.width === 0 || rect.height === 0) return undefined
388
+ const ndc = new THREE.Vector2(
389
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
390
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
391
+ )
392
+ const raycaster = new THREE.Raycaster()
393
+ raycaster.setFromCamera(ndc, camera)
394
+ intersects = raycaster.intersectObjects(scene.children, true)
395
+ }
396
+ if (!intersects || intersects.length === 0) return undefined
397
+
398
+ // 가장 가까운 hit 이 *이 stockpile* 의 descendant 여야 한다 (다른 mesh 가 사이에 있으면 skip).
399
+ const closest = intersects[0]
400
+ let obj: THREE.Object3D | null = closest.object
401
+ while (obj) {
402
+ if (obj.userData?.context === ro) return closest
403
+ obj = obj.parent
404
+ }
405
+ return undefined
406
+ }
407
+
408
+ // legendTarget / _onLegendChanged / resolveLegendColor — RecordStorage mixin 제공.
409
+
410
+ buildRealObject(): RealObject | undefined {
411
+ return new Stockpile3D(this)
412
+ }
413
+ }
414
+
415
+ // transient carrier refid — scene 내 기존 컴포넌트와 충돌 회피용 큰 시작값
416
+ // (storage-rack 의 _nextCarrierRefid 와 같은 idiom, 별도 범위로 분리).
417
+ let _stockpileCarrierSeq = 0
418
+ function _nextStockpileCarrierRefid(): number {
419
+ return 800000 + (_stockpileCarrierSeq++)
420
+ }
421
+
422
+ function resolveDepth(c: Component): number {
423
+ const eff = (c as any)._realObject?.effectiveDepth
424
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
425
+ const d = (c as any)?.state?.depth
426
+ return typeof d === 'number' && Number.isFinite(d) ? d : 0
427
+ }