@operato/scene-storage 10.0.0-beta.40 → 10.0.0-beta.42

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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/box.js +18 -0
  5. package/dist/box.js.map +1 -1
  6. package/dist/crane-3d.d.ts +47 -2
  7. package/dist/crane-3d.js +246 -89
  8. package/dist/crane-3d.js.map +1 -1
  9. package/dist/crane.d.ts +96 -12
  10. package/dist/crane.js +395 -100
  11. package/dist/crane.js.map +1 -1
  12. package/dist/index.d.ts +3 -4
  13. package/dist/index.js +1 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/pallet.d.ts +15 -0
  16. package/dist/pallet.js +38 -2
  17. package/dist/pallet.js.map +1 -1
  18. package/dist/parcel-3d.js +22 -18
  19. package/dist/parcel-3d.js.map +1 -1
  20. package/dist/parcel.d.ts +4 -3
  21. package/dist/parcel.js +24 -5
  22. package/dist/parcel.js.map +1 -1
  23. package/dist/rack-grid-3d.d.ts +18 -7
  24. package/dist/rack-grid-3d.js +372 -69
  25. package/dist/rack-grid-3d.js.map +1 -1
  26. package/dist/rack-grid-cell.d.ts +21 -72
  27. package/dist/rack-grid-cell.js +147 -243
  28. package/dist/rack-grid-cell.js.map +1 -1
  29. package/dist/rack-grid.d.ts +277 -56
  30. package/dist/rack-grid.js +1230 -695
  31. package/dist/rack-grid.js.map +1 -1
  32. package/dist/rack-materials.d.ts +9 -0
  33. package/dist/rack-materials.js +55 -0
  34. package/dist/rack-materials.js.map +1 -0
  35. package/dist/storage-rack-3d.d.ts +15 -0
  36. package/dist/storage-rack-3d.js +165 -29
  37. package/dist/storage-rack-3d.js.map +1 -1
  38. package/dist/storage-rack.d.ts +253 -32
  39. package/dist/storage-rack.js +726 -66
  40. package/dist/storage-rack.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/box.ts +18 -0
  43. package/src/crane-3d.ts +258 -93
  44. package/src/crane.ts +445 -110
  45. package/src/index.ts +3 -4
  46. package/src/pallet.ts +50 -1
  47. package/src/parcel-3d.ts +23 -18
  48. package/src/parcel.ts +24 -5
  49. package/src/rack-grid-3d.ts +383 -80
  50. package/src/rack-grid-cell.ts +161 -305
  51. package/src/rack-grid.ts +1263 -762
  52. package/src/rack-materials.ts +61 -0
  53. package/src/storage-rack-3d.ts +182 -29
  54. package/src/storage-rack.ts +819 -67
  55. package/test/test-carrier-lifecycle.ts +361 -0
  56. package/test/test-coord-alignment.ts +201 -0
  57. package/test/test-crane-geometry.ts +167 -0
  58. package/test/test-external-to-rack.ts +461 -0
  59. package/test/test-mover-concurrent-bug.ts +304 -0
  60. package/test/test-mover-rollback.ts +290 -0
  61. package/test/test-phase-h-carrier-pickable.ts +4 -3
  62. package/test/test-r19-place-absorb.ts +174 -0
  63. package/test/test-rack-3d-attach-real.ts +301 -0
  64. package/test/test-rack-concurrent.ts +254 -0
  65. package/test/test-rack-edge-cases.ts +323 -0
  66. package/test/test-rack-grid-cell.ts +318 -0
  67. package/test/test-rack-grid-location.ts +657 -0
  68. package/test/test-real-3d-positioning.ts +158 -0
  69. package/test/test-slot-center-convention.ts +116 -0
  70. package/test/test-slot-target.ts +189 -0
  71. package/test/test-storage-rack-batched.ts +606 -0
  72. package/test/test-storage-rack-click.ts +329 -0
  73. package/test/test-storage-rack-slot-api.ts +357 -0
  74. package/test/test-toscene-convention.ts +162 -0
  75. package/test/test-user-scenario-sequential.ts +334 -0
  76. package/translations/en.json +7 -1
  77. package/translations/ja.json +7 -1
  78. package/translations/ko.json +7 -1
  79. package/translations/ms.json +7 -1
  80. package/translations/zh.json +7 -1
  81. package/tsconfig.tsbuildinfo +1 -1
  82. package/dist/rack-column.d.ts +0 -35
  83. package/dist/rack-column.js +0 -258
  84. package/dist/rack-column.js.map +0 -1
  85. package/dist/rack-grid-helpers.d.ts +0 -28
  86. package/dist/rack-grid-helpers.js +0 -71
  87. package/dist/rack-grid-helpers.js.map +0 -1
  88. package/dist/rack-grid-location.d.ts +0 -37
  89. package/dist/rack-grid-location.js +0 -227
  90. package/dist/rack-grid-location.js.map +0 -1
  91. package/dist/storage-cell-3d.d.ts +0 -25
  92. package/dist/storage-cell-3d.js +0 -88
  93. package/dist/storage-cell-3d.js.map +0 -1
  94. package/dist/storage-cell.d.ts +0 -70
  95. package/dist/storage-cell.js +0 -197
  96. package/dist/storage-cell.js.map +0 -1
  97. package/src/rack-column.ts +0 -340
  98. package/src/rack-grid-helpers.ts +0 -77
  99. package/src/rack-grid-location.ts +0 -286
  100. package/src/storage-cell-3d.ts +0 -101
  101. package/src/storage-cell.ts +0 -247
  102. package/test/test-rack-grid.ts +0 -77
@@ -3,15 +3,19 @@
3
3
  */
4
4
  import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
5
  import type { State, Material3D } from '@hatiolab/things-scene'
6
+ import * as THREE from 'three'
6
7
  import {
7
8
  CellContainer,
8
9
  CellMap,
9
10
  CarrierHolder,
10
11
  Placeable,
12
+ SlotTarget,
11
13
  type AttachFrame,
12
14
  type Alignment,
13
15
  type Heights,
14
- type PlacementArchetype
16
+ type PlacementArchetype,
17
+ type SlotRecord,
18
+ type SlottedHolder
15
19
  } from '@operato/scene-base'
16
20
 
17
21
  import { StorageRack3D } from './storage-rack-3d.js'
@@ -22,6 +26,51 @@ export interface StorageRackState extends State {
22
26
  bays?: number
23
27
  levels?: number
24
28
 
29
+ /**
30
+ * Level 1 (첫 shelf) 의 *시작 높이* (mm, rack 의 3D Y 축, 바닥부터). 미명시 0
31
+ * (바닥 = 첫 shelf). 양수 시 그만큼 위로 올라가 stocker port / conveyor 같은
32
+ * 컴포넌트가 들어갈 *빈 공간* 확보. Frame uprights 는 바닥 ~ 천장 그대로.
33
+ */
34
+ shelfBaseHeight?: number
35
+
36
+ /**
37
+ * 적재 시각화 데이터. 배열의 각 record 는 최소 `cellId` 필드가 필요. cellMap 에
38
+ * 존재하는 cellId 만 InstancedMesh 의 instance 로 렌더링됨 (한 박스 = 한 cell).
39
+ * Plan A: 이 record 들이 *"매트릭스 안"* 의 carrier — obtainCarrier 가 materialize
40
+ * 시 record 빠짐, receiveAt 시 record push. pickAndPlace 와 양방향 atomic sync.
41
+ */
42
+ data?: Array<{ cellId: string; [key: string]: any }>
43
+
44
+ /**
45
+ * RackCell 컴포넌트의 eager 생성 여부.
46
+ * - `undefined` (default): state.data 있으면 false (batched, 메모리 절약),
47
+ * 없으면 true (legacy non-batched, RackCell-based Mover 호환).
48
+ * - `true`: 명시적 eager — 모든 cell 을 RackCell 로 생성.
49
+ * - `false`: 명시적 skip — Plan A 의 slot API 만 사용 (obtainCarrier / slotTargetAt).
50
+ */
51
+ eagerCells?: boolean
52
+
53
+ /**
54
+ * Legend 컴포넌트의 id. legend 의 `state.status = {field, ranges:[{value|min,max, color}], defaultColor}`
55
+ * 를 사용해 각 record 의 `field` 값을 색상으로 매핑. 미명시 시 scene-wide auto-discovery
56
+ * (type='legend' 인 첫 번째 컴포넌트).
57
+ */
58
+ legendTarget?: string
59
+
60
+ /**
61
+ * Cell 클릭 시 invoke 할 things-scene `Popup` 컴포넌트의 id. Popup 컴포넌트는 scene
62
+ * 어딘가에 일반 컴포넌트로 배치되어 있고, 그 자체의 state (board / modal / closable /
63
+ * draggable / tether / billboard 등) 가 popup 동작을 정의. rack 은 *anchor 만 동적으로*
64
+ * override 해 호출 — 클릭된 cell 위에 popup 이 뜸.
65
+ *
66
+ * 미명시 시 popup 비활성 (rack-cell-click 이벤트는 여전히 발사 — 외부 consumer 가
67
+ * 직접 처리 가능).
68
+ */
69
+ popupRef?: string
70
+
71
+ /** 가로 frame (beam) 만 숨김 — uprights 는 유지. */
72
+ hideHorizontalFrame?: boolean
73
+
25
74
  // ── 디버그 ──
26
75
  debugCells?: boolean
27
76
 
@@ -45,6 +94,31 @@ const NATURE: ComponentNature = {
45
94
  label: 'bays',
46
95
  name: 'bays',
47
96
  placeholder: '# of horizontal bays (default 5)'
97
+ },
98
+ {
99
+ type: 'number',
100
+ label: 'shelf-base-height',
101
+ name: 'shelfBaseHeight',
102
+ placeholder: 'mm — level 1 시작 높이 (바닥부터). stocker port / conveyor 공간.'
103
+ },
104
+ {
105
+ type: 'id-input',
106
+ label: 'legend-target',
107
+ name: 'legendTarget',
108
+ property: { component: 'legend' },
109
+ placeholder: '미명시 시 scene 의 legend 자동 발견'
110
+ },
111
+ {
112
+ type: 'id-input',
113
+ label: 'popup-ref',
114
+ name: 'popupRef',
115
+ property: { component: 'popup' },
116
+ placeholder: 'click 시 invoke 할 Popup 컴포넌트 id (anchor 는 클릭된 cell 로 override)'
117
+ },
118
+ {
119
+ type: 'checkbox',
120
+ label: 'hide-horizontal-frame',
121
+ name: 'hideHorizontalFrame'
48
122
  }
49
123
  ],
50
124
  help: 'scene/component/rack'
@@ -59,22 +133,28 @@ const NATURE: ComponentNature = {
59
133
  // CarrierHolder: 3D attach-point protocol (attachPointFor, containable gates)
60
134
  // Placeable: floor-archetype positioning
61
135
  // ContainerAbstract: child component management
136
+
137
+ // Transient carrier refid generator — load 시 root._addTraverse 가 자식 부터 처리
138
+ // 하면서 cell.model.refid 가 비어있으면 root.getNewRefid() = 1,2,3... 작은 값 부여
139
+ // → *부모 Rack + 모델 안 다른 컴포넌트* refid 와 충돌 ("Refid Index replaced" 경고).
140
+ // transient materialize 시 *큰 시작값 + monotonic counter* 로 자체 부여 → 충돌 0.
141
+ // hierarchy override / state.data 만 저장 → 모델 파일엔 refid 안 새겨짐.
142
+ let _carrierRefidCounter = 200_000_000
143
+ function _nextCarrierRefid(): number {
144
+ return _carrierRefidCounter++
145
+ }
62
146
  /**
63
147
  * Rack — a multi-level storage shelf system. A *Storage* whose role is to hold
64
148
  * carriers in a (bay × level) grid of cells.
65
149
  *
66
150
  * `levels` × `bays` cells form a vertical grid. Each cell holds one logistics
67
151
  * package (Pallet / Box / Parcel). A picker (Crane / Forklift / robot arm)
68
- * accesses individual cells via Phase G/H Pickable contract — the picker is
69
- * Rack-agnostic, knowing only how to interact with a cell.
152
+ * accesses individual cells via the Plan A slot API — the picker interacts
153
+ * with `SlotTarget` (no explicit cell-component required).
70
154
  *
71
- * **Monitoring mode** (default): carriers are direct children of the rack,
72
- * placed by external data binding. No RackCell children are created.
73
- *
74
- * **Simulation mode**: call `rack._buildCells()` after placing the rack on the
75
- * scene. This creates RackCell children at the correct 3D positions. A picker
76
- * (Crane / Forklift / ...) then navigates to individual RackCells for
77
- * pick-and-place.
155
+ * **Plan A**: carriers are direct children of the rack, addressed by
156
+ * `state.cellId`. Stock visualization uses InstancedMesh (batched). Slot
157
+ * lookup / pick / place via `obtainCarrier` / `receiveAt` / `slotTargetAt`.
78
158
  *
79
159
  * **Placement**: `floor` archetype, full ceiling depth by default.
80
160
  *
@@ -83,7 +163,10 @@ const NATURE: ComponentNature = {
83
163
  * the cell topology and pickable contract stay the same.
84
164
  */
85
165
  @sceneComponent('storage-rack')
86
- export default class Rack extends CellContainer(CarrierHolder(Placeable(ContainerAbstract))) {
166
+ export default class Rack
167
+ extends CellContainer(CarrierHolder(Placeable(ContainerAbstract)))
168
+ implements SlottedHolder
169
+ {
87
170
  declare state: StorageRackState
88
171
 
89
172
  static placement: PlacementArchetype = 'floor'
@@ -98,6 +181,27 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
98
181
  return []
99
182
  }
100
183
 
184
+ /**
185
+ * Runtime — bays / levels 변경 시 anchor 캐시 무효화. cell 위치가 바뀌므로 다음
186
+ * `_ensureCellAttachObject3d` 호출이 새 좌표로 갱신.
187
+ */
188
+ onchange(after: Record<string, unknown>, _before: Record<string, unknown>): void {
189
+ super.onchange?.(after, _before)
190
+ if (
191
+ 'bays' in after ||
192
+ 'levels' in after ||
193
+ 'shelfBaseHeight' in after ||
194
+ 'width' in after ||
195
+ 'height' in after ||
196
+ 'depth' in after
197
+ ) {
198
+ this._attachAnchorByCell.clear()
199
+ }
200
+ if ('hideHorizontalFrame' in after) {
201
+ ;(this._realObject as any)?.applyFrameVisibility?.()
202
+ }
203
+ }
204
+
101
205
  // ── CellContainer ─────────────────────────────────────────────────────────
102
206
 
103
207
  /**
@@ -116,6 +220,11 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
116
220
  const width = (this.state.width as number) || 1000
117
221
  const rackDepth = (this.state.depth as number) || 3000 // Y: floor→ceiling
118
222
  const rackHeight = (this.state.height as number) || 600 // Z: front→back
223
+ const shelfBase = Math.max(0, Math.min(
224
+ (this.state.shelfBaseHeight as number) || 0,
225
+ rackDepth * 0.9 // clamp ≤ 90% — 최소 shelf zone
226
+ ))
227
+ const shelfZone = rackDepth - shelfBase // 실제 shelf 가 차지하는 Y 영역
119
228
 
120
229
  return CellMap.grid({
121
230
  bays,
@@ -123,108 +232,751 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
123
232
  levels,
124
233
  bayWidth: width / bays,
125
234
  rowDepth: rackHeight,
126
- levelHeight: rackDepth / levels
235
+ levelHeight: shelfZone / levels,
236
+ origin: { x: 0, y: shelfBase, z: 0 } // 첫 cell 의 Y = shelfBase
127
237
  })
128
238
  }
129
239
 
240
+ // ── Container gates ───────────────────────────────────────────────────────
241
+
130
242
  /**
131
- * Create RackCell child components for each cell in the CellMap.
243
+ * Allow:
244
+ * - Carriable components (pallets, boxes, parcels) — direct children, operation archetype.
132
245
  *
133
- * Called explicitly to enter simulation mode — monitoring-mode racks
134
- * never call this (carriers are direct children, no explicit cells).
246
+ * Block:
247
+ * - Everything else (sensors, labels, etc. can be siblings of the rack, not children).
248
+ */
249
+ containable(component: Component): boolean {
250
+ const archetype = (component.constructor as any).placement
251
+ if (archetype === 'operation') return true
252
+ return component.isDescendible(this)
253
+ }
254
+
255
+ // ── CarrierHolder — attach frame for direct carrier children ─────────────
256
+
257
+ /**
258
+ * Attach frame for direct-child carriers — Plan A 의 모든 carrier 가 rack 의
259
+ * 직접 자식이므로 매번 호출됨. carrier 의 state.cellId 에 해당하는 *cell-local
260
+ * anchor object3d* 를 반환 → carrier 의 object3d 가 자동으로 셀 위치에 정렬됨.
261
+ */
262
+ attachPointFor(carrier: Component): AttachFrame | null {
263
+ // Plan A: rack 의 직접 자식 carrier 는 *state.cellId* 로 슬롯 위치를 표시 (매트릭스
264
+ // 내 주소). attachPointFor 는 그 cellId 의 *셀-로컬 anchor object3d* 를 반환 →
265
+ // carrier 의 object3d 가 자동으로 셀 위치에 정렬됨.
266
+ //
267
+ // localPosition: {0,0,0} 명시 — Carriable.applyHolderAttachPoint 가 Three.js
268
+ // attach() 후 *world pose 보존* 만 하고 local 을 reset 하지 않는 결함 우회.
269
+ // 명시 시 (Carriable + CarrierHolder.reparent 모두) 그대로 anchor origin 에 snap.
270
+ const cellId = (carrier as any)?.state?.cellId as string | undefined
271
+ if (cellId) {
272
+ const obj = this._ensureCellAttachObject3d(cellId)
273
+ if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
274
+ }
275
+ // Fallback — cellId 없는 (legacy) 호출 시 rack root.
276
+ const root = this._realObject?.object3d
277
+ if (!root) return null
278
+ return { attach: root, localPosition: { x: 0, y: 0, z: 0 } }
279
+ }
280
+
281
+ // ── Plan A — Slot API (LoopSorter-style; "rack 안 = 데이터, 밖 = 컴포넌트") ───
282
+ //
283
+ // Conceptual model:
284
+ // - rack 안의 carrier 는 *state.data 의 한 record* (데이터)
285
+ // - rack 밖의 carrier 는 *Component* (실재)
286
+ // - 경계 통과 시 transient materialize / atomic absorb
287
+ //
288
+ // 불변식: 동일 cellId 가 state.data 의 record 와 rack-child carrier 양쪽에 동시
289
+ // 존재하지 않음. obtainCarrier 와 receiveAt 가 atomic 하게 전환.
290
+ //
291
+ // 호출 흐름:
292
+ // - Pick: const c = rack.obtainCarrier('A-0-0') → c 는 rack child, record 빠짐
293
+ // await crane.pick(c) → c.parent = crane (rack child 에서도 빠짐)
294
+ // - Place: await crane.place(c, destRack.slotTargetAt('B-0-0'))
295
+ // SlotTarget.receive → destRack.receiveAt → c dispose + record push
296
+
297
+ /** state.data 의 record 목록 (읽기 전용 뷰). */
298
+ get records(): ReadonlyArray<{ cellId: string; [key: string]: any }> {
299
+ return (this.state.data as any) ?? []
300
+ }
301
+
302
+ /**
303
+ * 1-based (bay, row, level) → 0-based cellId 문자열.
135
304
  *
136
- * Idempotent: removes existing rack-cell children first.
305
+ * rack.cellIdOf(1, 1, 6) → '0-0-5'
306
+ * rack.cellIdOf(3, 1, 4) → '2-0-3'
137
307
  */
138
- _buildCells(): void {
139
- // Remove existing rack-cell children
140
- const existing = (this.components as Component[] | undefined) ?? []
141
- for (const child of [...existing]) {
142
- if ((child as any).state?.type === 'storage-cell') {
143
- this.removeComponent(child)
144
- }
308
+ cellIdOf(bay: number, row: number = 1, level: number = 1): string {
309
+ return `${bay - 1}-${row - 1}-${level - 1}`
310
+ }
311
+
312
+ /** cellId 에 carrier 가 있는가 — child carrier 또는 state.data record 어느 쪽이든. */
313
+ hasCarrierAt(cellId: string): boolean {
314
+ if (this._carrierChildAt(cellId)) return true
315
+ return this.records.some(r => r.cellId === cellId)
316
+ }
317
+
318
+ /** cellId 매칭되는 rack 의 직접 자식 carrier (operation archetype). */
319
+ private _carrierChildAt(cellId: string): Component | undefined {
320
+ const children = (this.components as Component[] | undefined) ?? []
321
+ return children.find(c => {
322
+ const placement = (c.constructor as any).placement
323
+ return placement === 'operation' && (c.state as any)?.cellId === cellId
324
+ })
325
+ }
326
+
327
+ /**
328
+ * carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
329
+ * materialize 후 rack 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
330
+ * record 도 child 도 없으면 null.
331
+ *
332
+ * Signature overloads:
333
+ * obtainCarrier('0-0-5') — string cellId 직접
334
+ * obtainCarrier(1, 1, 6) — 1-based (bay, row, level)
335
+ * obtainCarrier(1) ≡ obtainCarrier(1,1,1)
336
+ */
337
+ obtainCarrier(idOrBay: string | number, row?: number, level?: number): Component | null {
338
+ const cellId = typeof idOrBay === 'string'
339
+ ? idOrBay
340
+ : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
341
+ const existing = this._carrierChildAt(cellId)
342
+ if (existing) return existing
343
+
344
+ const records = this.records as Array<any>
345
+ const idx = records.findIndex(r => r?.cellId === cellId)
346
+ if (idx === -1) return null
347
+ const record = records[idx]
348
+
349
+ const carrierType = (record.type as string) || 'parcel'
350
+ const CarrierClass = (Component as any).register(carrierType) as
351
+ | (new (...args: any[]) => Component) | undefined
352
+ if (!CarrierClass) {
353
+ console.warn(`[storage-rack] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`)
354
+ return null
145
355
  }
146
356
 
147
- // Create a RackCell for each cell in the map
148
- const RackCellClass = (Component as any).register('storage-cell') as (new (...args: any[]) => Component) | undefined
149
- if (!RackCellClass) {
150
- console.warn('Rack._buildCells: rack-cell type not registered. Import rack-cell.ts first.')
357
+ // 크기 기본값 rack 기하에서 derive (record 명시 안 한 경우)
358
+ const rs: any = this.state
359
+ const rackWidth = rs?.width || 1000
360
+ const rackDepth = rs?.depth || 3000
361
+ const rackHeight = rs?.height || 600
362
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
363
+ const levels = Math.max(1, Math.floor(rs?.levels || 4))
364
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9))
365
+ const shelfZone = rackDepth - shelfBase
366
+ const bayWidth = rackWidth / bays
367
+ const levelHeight = shelfZone / levels
368
+
369
+ // record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
370
+ // component 와 충돌해 parent 가 잘못 잡힘 (model-layer 의 원본을 재사용 등).
371
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
372
+ const carrierW = record.width ?? bayWidth * 0.85
373
+ const carrierH = record.height ?? rackHeight * 0.85
374
+ // cell 의 *rack-inner 좌표* (= carrier 의 parent=rack 의 inner 좌표계 의 점).
375
+ // carrier.center = carrier.state.left + carrierW/2 가 *rack-inner 좌표 의 cell center*
376
+ // 이어야 Crane.moveTo (target.center → target.toScene) 가 정확한 layer 좌표로 변환.
377
+ // 이전엔 left=0, top=0 → center=(carrierW/2, carrierH/2) = bay 0 의 좌상단 근처 → fork 어긋남.
378
+ const cellInfo = this.cellMap?.findById(cellId)
379
+ const bayIdx = (cellInfo?.bay ?? 1) - 1
380
+ const cellCenterInnerX = bayIdx * bayWidth + bayWidth / 2
381
+ const cellCenterInnerY = rackHeight / 2
382
+ const carrierState: any = {
383
+ ...recordCopy,
384
+ type: carrierType,
385
+ cellId, // 슬롯 주소
386
+ refid: _nextCarrierRefid(), // refid 충돌 회피
387
+ width: carrierW,
388
+ height: carrierH,
389
+ depth: record.depth ?? levelHeight * 0.7,
390
+ left: cellCenterInnerX - carrierW / 2,
391
+ top: cellCenterInnerY - carrierH / 2
392
+ }
393
+
394
+ const carrier = new CarrierClass(carrierState, (this as any)._app)
395
+ // *silent: true* — Plan A 의 transient materialize 는 사용자가 직접 컴포넌트
396
+ // 만든 게 아니라 *내부적으로 잠시 빌리는 것*. 새 컴포넌트 추가 → root.onadded
397
+ // → refreshMappings (1s debounce) → 모든 컴포넌트 매핑 재실행 의 cascade 차단.
398
+ // (silent 모드: _silentAdd 플래그 set → refreshMappings skip)
399
+ ;(this as any).addComponent(carrier, { silent: true })
400
+
401
+ // 3D 강제 빌드 + attach (pipeline tick 없이도 즉시 표시 — Mover 가 곧 pick).
402
+ void (carrier as any).realObject
403
+ ;(carrier as any).applyHolderAttachPoint?.()
404
+
405
+ // record 제거 — atomic: child 추가 직후. 동일 cellId 의 *모든* record 정리.
406
+ // *silent* 갱신 — mapping cascade (onchangeData → executeMappings → script fire)
407
+ // 회피. external (WMS push) 호출은 setState 거치므로 그쪽 mapping 은 정상 발사.
408
+ this._setDataSilently(records.filter(r => r?.cellId !== cellId))
409
+
410
+ return carrier
411
+ }
412
+
413
+ /**
414
+ * State.data 의 *internal* 갱신 — Plan A 의 obtainCarrier / receiveAt 가 사용.
415
+ * `setState` 와 달리 *'change' 이벤트 / onchangeData / mapping cascade 를 우회*.
416
+ *
417
+ * 이유: mapping 시스템이 state.data 변경 시 *자동으로 script fire*. Plan A 의
418
+ * setState 가 그 cascade 를 트리거하면 사용자 script 가 *재귀적으로 자기 자신을 호출*
419
+ * 하는 회귀 (board 의 의도된 binding 일 수도, 우연일 수도) 발생.
420
+ *
421
+ * 대신 *직접 _state 갱신 + rebuildStockMesh 직접 호출* — 시각화는 갱신되지만 외부
422
+ * mapping 은 fire 안 됨. External (WMS / application setState) 호출은 그대로 setState
423
+ * 거치므로 그쪽 mapping 은 정상 동작.
424
+ */
425
+ private _setDataSilently(newData: any[]): void {
426
+ const self = this as any
427
+ if (!self._state) self._state = {}
428
+ self._state.data = newData
429
+ self._cachedState = null // state getter 가 다음 read 때 fresh build
430
+ ;(this._realObject as any)?.rebuildStockMesh?.()
431
+ }
432
+
433
+ /**
434
+ * cell 이 carrier 를 받을 수 있는가.
435
+ *
436
+ * 규칙:
437
+ * - state.data 에 record 가 있으면 점유 → false
438
+ * - carrier-child 가 있고 *그 child 가 들여오려는 carrier 자기 자신이 아니면* → false
439
+ * - 들여오려는 carrier 가 *바로 그 cell 의 child 자기 자신* 이면 → true (idempotent —
440
+ * obtain('A') 직후 receive('A', sameCarrier) 가 *자기 자리 복귀* 로 동작)
441
+ */
442
+ canReceiveAt(cellId: string, carrier?: Component): boolean {
443
+ const records = this.records as Array<any>
444
+ if (records.some(r => r?.cellId === cellId)) return false
445
+ const existingChild = this._carrierChildAt(cellId)
446
+ if (existingChild && existingChild !== carrier) return false
447
+ return true
448
+ }
449
+
450
+ /**
451
+ * Carrier 가 rack 의 slot 으로 들어옴 — "매트릭스 진입": 즉시 dispose + state.data 에
452
+ * record 로 환원. 결과: InstancedMesh 가 다시 그 자리에 instance 표시, rack 의 자식
453
+ * 컴포넌트 트리는 깨끗.
454
+ */
455
+ async receiveAt(cellId: string, carrier: Component, _options?: any): Promise<void> {
456
+ // R18 guard — disposed carrier 의 재처리 차단.
457
+ if ((carrier as any)?._disposed) {
458
+ throw new Error(
459
+ `Rack.receiveAt("${cellId}"): carrier is already disposed. ` +
460
+ 'After a successful pickAndPlace the carrier becomes a state.data record — ' +
461
+ 'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.'
462
+ )
463
+ }
464
+ if (!this.canReceiveAt(cellId, carrier)) {
465
+ ;(this as any).trigger?.('transfer-rejected', {
466
+ type: 'transfer-rejected',
467
+ component: carrier, container: this, reason: 'slot-occupied', cellId
468
+ })
151
469
  return
152
470
  }
153
471
 
154
- const context = this._app
155
- for (const cell of this.cellMap.cells) {
156
- const model = {
157
- type: 'storage-cell',
158
- cellId: cell.id,
159
- width: cell.size.width,
160
- height: cell.size.depth, // 2D height = 3D Z depth
161
- depth: cell.size.height // 3D Y = level height
162
- }
163
- const rackCell = new RackCellClass(model, context)
164
- this.addComponent(rackCell)
472
+ const record = this.recordFromCarrier(carrier, cellId)
473
+
474
+ // Carrier parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 rack 자신.
475
+ const carrierParent: any = (carrier as any).parent
476
+ if (carrierParent && typeof carrierParent.removeComponent === 'function') {
477
+ carrierParent.removeComponent(carrier)
165
478
  }
479
+
480
+ // 명시적 Three.js detach — RealObject.dispose() 의 clear() 는 *object3d 의 children*
481
+ // 만 정리하고 *object3d 자체* 는 scene graph 의 parent 에서 *떼지 않음*. 결과:
482
+ // crane fork 또는 slot anchor 에 attach 됐던 object3d 가 빈 상태로 남아 ghost.
483
+ // dispose 호출 *전* 에 명시적으로 parent.remove. (geometry/material dispose 는
484
+ // RealObject.dispose() 의 clear() 에 위임 — 공유 material 정책 framework 책임.)
485
+ const carrierObj3d: any = (carrier as any)._realObject?.object3d
486
+ if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
487
+ carrierObj3d.parent.remove(carrierObj3d)
488
+ }
489
+
490
+ ;(carrier as any).dispose?.()
491
+
492
+ // state.data 에 push — *silent* 갱신 (mapping cascade 회피, 위 _setDataSilently 주석 참조).
493
+ // 중복 방어: 동일 cellId 의 기존 record 가 있으면 제거 후 새 record 단일 추가.
494
+ const currentRecords = (this.records as any[]).filter(r => r?.cellId !== cellId)
495
+ this._setDataSilently([...currentRecords, record])
496
+
497
+ ;(this as any).trigger?.('transfer-received', {
498
+ type: 'transfer-received',
499
+ component: carrier, container: this, slotId: cellId, record
500
+ })
166
501
  }
167
502
 
168
- // ── Container gates ───────────────────────────────────────────────────────
503
+ /**
504
+ * Carrier 의 state 를 state.data record 로 추출. application 이 carrier subclass 별
505
+ * 추가 필드 인코딩 원하면 override. transform/position 관련은 record 와 무관해 skip.
506
+ */
507
+ recordFromCarrier(carrier: Component, cellId: string): { cellId: string; [key: string]: any } {
508
+ const state: any = (carrier as any).state ?? {}
509
+ const SKIP_KEYS = new Set([
510
+ 'left', 'top', 'zPos',
511
+ 'transform', 'rotation', 'scale',
512
+ '_transferSlotId',
513
+ 'cellId', // 새로 override
514
+ 'id', // 다음 materialize 시 충돌 방지 (id 는 component-instance 의 것, record 의 것 아님)
515
+ 'refid'
516
+ ])
517
+ const record: any = { cellId, type: state.type }
518
+ for (const key of Object.keys(state)) {
519
+ if (SKIP_KEYS.has(key)) continue
520
+ record[key] = state[key]
521
+ }
522
+ return record
523
+ }
169
524
 
170
525
  /**
171
- * Allow:
172
- * - Carriable components (pallets, boxes, parcels) — direct children in monitoring mode.
173
- * - RackCell created by _buildCells() in simulation mode.
526
+ * SlottedHolder 컨트랙 — slot 의 attach object3d 반환. SlotTarget 이 자기
527
+ * `_realObject.object3d` proxy 사용하고, Carriable.applyHolderAttachPoint
528
+ * 이걸 attach frame 으로 사용 (transit carrier 가 slot 위치에 정렬).
529
+ */
530
+ getSlotAttachObject3d(cellId: string): THREE.Object3D | undefined {
531
+ return this._ensureCellAttachObject3d(cellId)
532
+ }
533
+
534
+ /**
535
+ * SlottedHolder 컨트랙 — slot 의 *expected carrier* 의 3D 크기 (slot 자체의 기하 크기가
536
+ * 아님). Crane 의 `resolveCarrierBottomY = centerY - depth/2` 에서 *carrier 가 놓일 때
537
+ * 예상되는 carrier depth* 를 써야 fork 가 *carrier 바닥 = shelf* 에 정확히 진입.
174
538
  *
175
- * Block:
176
- * - Everything else (sensors, labels, etc. can be siblings of the rack, not children).
539
+ * 즉:
540
+ * depth = stockD (= levelHeight * 0.7) *carrier vertical extent*. 전체 높이
541
+ * (levelHeight) 가 아닌 *실제 stock 박스 깊이*. anchor 가 stock 시각 중심 (
542
+ * shelf + stockD/2) 에 위치하므로 depth = stockD 여야 bottom 계산이 shelf.
543
+ * width = bayWidth — 그대로
544
+ * height = rowDepth — 그대로 (2D 의 Z 축 폭)
177
545
  */
178
- containable(component: Component): boolean {
179
- if ((component as any).state?.type === 'storage-cell') return true
180
- const archetype = (component.constructor as any).placement
181
- if (archetype === 'operation') return true
182
- return component.isDescendible(this)
546
+ getSlotSize(cellId: string): { width: number; height: number; depth: number } | undefined {
547
+ const cell = this.cellMap?.findById(cellId)
548
+ if (!cell) return undefined
549
+ const stockD = cell.size.height * 0.7 // matches _ensureCellAttachObject3d + storage-rack-3d.rebuildStockMesh
550
+ return {
551
+ width: cell.size.width,
552
+ height: cell.size.depth, // 2D height = Z extent (front-back)
553
+ depth: stockD // 3D depth = carrier Y extent (NOT full level)
554
+ }
183
555
  }
184
556
 
185
- // ── CarrierHolder — attach frame for direct carrier children ─────────────
557
+ /**
558
+ * SlottedHolder 컨트랙 — cellId 에 대한 SlotTarget. Mover.pickAndPlace 의 dest 로 넘김.
559
+ *
560
+ * Signature overloads:
561
+ * slotTargetAt('0-0-5') — string cellId 직접
562
+ * slotTargetAt(1, 1, 6) — 1-based (bay, row, level)
563
+ */
564
+ slotTargetAt(idOrBay: string | number, row?: number, level?: number): SlotTarget {
565
+ const cellId = typeof idOrBay === 'string'
566
+ ? idOrBay
567
+ : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
568
+ return new SlotTarget(this, cellId)
569
+ }
186
570
 
187
571
  /**
188
- * Attach frame for carriers that are DIRECT children of the rack
189
- * (monitoring mode, where carriers go directly into the rack without
190
- * explicit RackCell components).
572
+ * SlotTarget 2D center 위임 Mover.moveTo 2D path 계산에 사용.
191
573
  *
192
- * In simulation mode, carriers become children of their RackCell,
193
- * and each RackCell provides its own attachPointFor(). So this method
194
- * is only invoked on direct-child carriers in monitoring mode — it
195
- * returns the rack's own object3d as the attach frame (default behavior).
574
+ * 반환값은 *rack 자체의 local frame* (rack left/top 미포함) — 즉 cell 의 위치를
575
+ * rack *내부 좌표계로* 표현. SlotTarget.toScene 이 rack.toScene 위임해 *rack 의
576
+ * rotation / 부모 chain 변환 포함* 절대 좌표로 변환.
577
+ *
578
+ * 이전 결함: rack.left/top 을 포함해 model-layer 프레임 좌표 반환 + toScene 미구현 →
579
+ * rack 이 rotated 또는 nested 일 때 X 가 어긋났음.
196
580
  */
197
- attachPointFor(_carrier: Component): AttachFrame | null {
198
- const root = this._realObject?.object3d
199
- if (!root) return null
200
- return { attach: root }
581
+ cellCenter2D(cellId: string): { x: number; y: number } | null {
582
+ const cell = this.cellMap?.findById(cellId)
583
+ if (!cell) return null
584
+ const rs: any = this.state
585
+ const rackWidth = rs?.width || 1000
586
+ const rackHeight = rs?.height || 100
587
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
588
+ const bayWidth = rackWidth / bays
589
+ const bayIdx = cell.bay - 1
590
+ // things-scene 컨벤션: 컴포넌트의 `center` 는 *parent 좌표계 의 center*
591
+ // (= bounds.left + width/2). 즉 *layer 좌표* — rack 의 left/top 포함, *rack 의
592
+ // rotation 미적용*. toScene 가 rotation 처리. parcel.center 등 모든 실제
593
+ // 컴포넌트가 이 컨벤션이므로 SlotTarget.center 도 동일해야 *pick 과 place 의
594
+ // 좌표 변환 체인 일관성* 유지.
595
+ return {
596
+ x: (rs?.left ?? 0) + bayIdx * bayWidth + bayWidth / 2,
597
+ y: (rs?.top ?? 0) + rackHeight / 2
598
+ }
599
+ }
600
+
601
+ /**
602
+ * SlotTarget 의 toScene 위임 — *rack-local* 좌표를 *scene-absolute* 로 변환.
603
+ * rack.toScene 이 rack 의 rotation / translation / parent chain 모두 처리.
604
+ */
605
+ cellToScene(localX: number, localY: number): { x: number; y: number } {
606
+ const f = (this as any).toScene
607
+ if (typeof f === 'function') return f.call(this, localX, localY)
608
+ return { x: localX, y: localY }
609
+ }
610
+
611
+ /** cellId 별 attach anchor object3d cache (rack.object3d 의 자식). */
612
+ private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
613
+
614
+ /**
615
+ * cellId 위치에 lightweight anchor object3d 를 *singleton 으로* 보장 + 갱신.
616
+ * 이 anchor 가:
617
+ * - Carriable.applyHolderAttachPoint 가 attach 하는 frame
618
+ * - SlotTarget._realObject.object3d 의 proxy
619
+ * - 두 용도가 *같은 object3d* 를 공유해 carrier 가 transient 동안 SlotTarget 의
620
+ * pose 와 정확히 동기화.
621
+ */
622
+ private _ensureCellAttachObject3d(cellId: string): THREE.Object3D | undefined {
623
+ const ro: any = (this as any)._realObject
624
+ if (!ro?.object3d) return undefined
625
+
626
+ let obj = this._attachAnchorByCell.get(cellId)
627
+ if (!obj) {
628
+ obj = new THREE.Object3D()
629
+ obj.name = `rack-slot-anchor:${cellId}`
630
+ ro.object3d.add(obj)
631
+ this._attachAnchorByCell.set(cellId, obj)
632
+ }
633
+
634
+ const cell = this.cellMap?.findById(cellId)
635
+ if (!cell) return undefined
636
+
637
+ const rs: any = this.state
638
+ const rackWidth = rs?.width || 1000
639
+ const rackDepth = rs?.depth || 3000
640
+ const rackHeight = rs?.height || 600
641
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
642
+ const levels = Math.max(1, Math.floor(rs?.levels || 4))
643
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9))
644
+ const shelfZone = rackDepth - shelfBase
645
+ const bayWidth = rackWidth / bays
646
+ const levelHeight = shelfZone / levels
647
+ const stockD = levelHeight * 0.7 // ← storage-rack-3d.rebuildStockMesh 의 stockD 와 동일
648
+ const rowDepth = rackHeight
649
+
650
+ // Anchor Y = *stock 시각 중심* (shelf + stockD/2). InstancedMesh 의 stock 박스 중심과
651
+ // 일치 → carrier (depth = stockD) 가 attach 시 정확히 stock 위치에 올라 앉음. Crane
652
+ // 의 resolveCarrierBottomY = centerY - depth/2 = shelf 라 fork 가 정확히 shelf 진입.
653
+ const x = cell.localPosition.x + bayWidth / 2 - rackWidth / 2
654
+ const y = cell.localPosition.y - rackDepth / 2 + stockD / 2
655
+ const z = cell.localPosition.z + rowDepth / 2 - rackHeight / 2
656
+ obj.position.set(x, y, z)
657
+ obj.updateMatrixWorld(true)
658
+ return obj
201
659
  }
202
660
 
203
661
  // ── 2D rendering ─────────────────────────────────────────────────────────
204
662
 
205
663
  /**
206
- * 2D — top-down rectangle showing the rack footprint, with subdivisions
207
- * suggesting the bay layout.
664
+ * 2D — top-down rectangle showing the rack footprint with bay subdivisions.
665
+ * 편집/배치 가능하도록 *명시 fill + stroke* — pipeline 분기 무관하게 항상
666
+ * 보임. fill 은 반투명 (carrier / cell 위 overlay).
208
667
  */
209
668
  render(ctx: CanvasRenderingContext2D) {
210
- const { width, height, left, top } = this.state
669
+ const left = (this.state.left as number) ?? 0
670
+ const top = (this.state.top as number) ?? 0
671
+ const width = (this.state.width as number) ?? 400
672
+ const height = (this.state.height as number) ?? 100
211
673
  const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
674
+ const fill = (this.state.fillStyle as string) || '#a0a0a8'
675
+ const stroke = (this.state.strokeStyle as string) || '#555'
676
+ const lineWidth = (this.state.lineWidth as number) || 1
212
677
 
678
+ // Fill (반투명)
679
+ ctx.save()
680
+ ctx.fillStyle = fill
681
+ ctx.globalAlpha = 0.2
682
+ ctx.fillRect(left, top, width, height)
683
+ ctx.restore()
684
+
685
+ // Stroke — outer + bay subdivisions
686
+ ctx.strokeStyle = stroke
687
+ ctx.lineWidth = lineWidth
688
+ ctx.strokeRect(left, top, width, height)
213
689
  ctx.beginPath()
214
- // Outer rectangle
215
- ctx.rect(left, top, width, height)
216
- // Bay subdivisions (vertical lines)
217
690
  for (let i = 1; i < bays; i++) {
218
691
  const x = left + (width * i) / bays
219
692
  ctx.moveTo(x, top)
220
693
  ctx.lineTo(x, top + height)
221
694
  }
695
+ ctx.stroke()
222
696
  }
223
697
 
224
698
  get fillStyle() {
225
699
  return '#a0a0a8'
226
700
  }
227
701
 
702
+ // ── Data binding — state.data 변경 시 InstancedMesh 재구성 ───────────────
703
+
704
+ onchangeData(): void {
705
+ ;(this._realObject as any)?.rebuildStockMesh?.()
706
+ }
707
+
708
+ // ── Legend — record 의 field 값 → 색상 매핑 ────────────────────────────────
709
+
710
+ private _legendTarget?: Component
711
+
712
+ /**
713
+ * Legend 컴포넌트 lookup. 우선순위:
714
+ * 1) state.legendTarget id 명시
715
+ * 2) scene 전체에서 `type='legend'` 첫 번째 컴포넌트 (자동 발견)
716
+ */
717
+ get legendTarget(): Component | undefined {
718
+ if (this._legendTarget) return this._legendTarget
719
+
720
+ const id = this.state.legendTarget
721
+ if (id) {
722
+ const found = (this.root as any)?.findById?.(id) as Component | undefined
723
+ if (found) {
724
+ this._legendTarget = found
725
+ ;(found as any).on?.('change', this._onLegendChanged, this)
726
+ return found
727
+ }
728
+ }
729
+
730
+ // scene-wide auto-discovery
731
+ const visit = (node: any): Component | undefined => {
732
+ if (!node) return undefined
733
+ if (node.state?.type === 'legend') return node as Component
734
+ const children = node.components as Component[] | undefined
735
+ if (children) {
736
+ for (const c of children) {
737
+ const r = visit(c)
738
+ if (r) return r
739
+ }
740
+ }
741
+ return undefined
742
+ }
743
+ const found = visit(this.root)
744
+ if (found) {
745
+ this._legendTarget = found
746
+ ;(found as any).on?.('change', this._onLegendChanged, this)
747
+ }
748
+ return found
749
+ }
750
+
751
+ private _onLegendChanged = (): void => {
752
+ ;(this._realObject as any)?.rebuildStockMesh?.()
753
+ }
754
+
755
+ /**
756
+ * record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
757
+ * - `range.value === recordValue` (카테고리 일치)
758
+ * - `range.min ≤ Number(v) < range.max` (수치 범위)
759
+ * - 매칭 없으면 `defaultColor` 또는 undefined
760
+ */
761
+ resolveLegendColor(record: any): string | undefined {
762
+ const legend = this.legendTarget
763
+ if (!legend) return undefined
764
+ const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
765
+ if (!status) return undefined
766
+
767
+ const field = status.field as string | undefined
768
+ const ranges = status.ranges as any[] | undefined
769
+ if (!field || !Array.isArray(ranges)) return undefined
770
+
771
+ const value = record?.[field]
772
+ if (value === undefined || value === null) return status.defaultColor
773
+
774
+ for (const range of ranges) {
775
+ if (!range) continue
776
+ if (range.value !== undefined) {
777
+ if (range.value === value) return range.color
778
+ continue
779
+ }
780
+ const num = Number(value)
781
+ if (!Number.isFinite(num)) continue
782
+ const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
783
+ const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
784
+ const minOk = min === undefined || num >= min
785
+ const maxOk = max === undefined || num < max
786
+ if (minOk && maxOk) return range.color
787
+ }
788
+ return status.defaultColor as string | undefined
789
+ }
790
+
791
+ // ── Click event — rack-cell-click 발사 ────────────────────────────────────
792
+ //
793
+ // 사용자가 *어떤 셀*을 클릭하든 (stock 가시화된 셀이든, 빈 셀이든) rack 이 다음
794
+ // payload 로 `rack-cell-click` 을 emit:
795
+ // { cellId, record?, hitPoint, instanceId?, isStock }
796
+ //
797
+ // 외부 consumer 등록:
798
+ // rack.on('rack-cell-click', ({ cellId, record, isStock }) => { ... })
799
+ //
800
+ // 분기:
801
+ // - InstancedMesh (stock) hit → cellId/record 는 records[instanceId] 에서 직접 추출, isStock=true
802
+ // - 그 외 rack mesh (shelf/frame) hit → hit.point 를 rack-local 좌표로 변환 후
803
+ // (bay, level) 역산, record 는 state.data 검색, isStock=false
804
+ // edit mode 클릭은 framework 의 선택 로직 우선이라 emit 하지 않음.
805
+
806
+ /**
807
+ * things-scene EventManager3D 가 raycast → object3d.userData.context.component 의
808
+ * `trigger("click", mouseEvent)` 을 호출 → eventMap 으로 receive.
809
+ * `(self).(self).click` 으로 등록해 *우리 rack 의 어떤 mesh 든 클릭됐을 때* 발사.
810
+ */
811
+ get eventMap() {
812
+ return {
813
+ '(self)': {
814
+ '(self)': {
815
+ click: this._onRackClick
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ private _onRackClick = (mouseEvent: MouseEvent) => {
822
+ // view mode 에서만 동작 (modeling 중 클릭은 framework 의 선택 로직 우선).
823
+ if (!(this as any).app?.isViewMode) return
824
+
825
+ const ro: any = (this as any)._realObject
826
+ if (!ro?.object3d) return
827
+
828
+ const hit = this._raycastRackHit(mouseEvent)
829
+ if (!hit) return
830
+
831
+ const stockMesh: THREE.InstancedMesh | undefined = ro.stockMesh
832
+ let cellId: string | undefined
833
+ let record: any
834
+ let isStock = false
835
+ let instanceId: number | undefined
836
+
837
+ if (hit.object === stockMesh) {
838
+ // (A) Stock instance 직접 hit — records 에서 정확한 record 추출.
839
+ const records: any[] | undefined = (stockMesh as any).userData?._records
840
+ record = records?.[hit.instanceId ?? -1]
841
+ cellId = record?.cellId
842
+ isStock = true
843
+ instanceId = hit.instanceId
844
+ } else {
845
+ // (B) 빈 cell / shelf / frame hit — world point → cellId 역산.
846
+ const cid = this._cellIdFromWorldPoint(hit.point)
847
+ if (!cid || cid.startsWith('out-of-bounds')) return
848
+ cellId = cid
849
+ const data = this.state.data as Array<any> | undefined
850
+ record = Array.isArray(data) ? data.find(r => r?.cellId === cid) : undefined
851
+ }
852
+
853
+ const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
854
+ this.trigger('rack-cell-click', payload)
855
+
856
+ // Popup 호출 — 일반 mechanism (Popup 컴포넌트) 활용, anchor 만 클릭된 cell 로 override.
857
+ this._invokePopup(cellId, record)
858
+ }
859
+
860
+ /**
861
+ * state.popupRef 가 가리키는 Popup 컴포넌트를 invoke. anchor 를 SlotTarget 으로
862
+ * 지정 — SlotTarget._realObject.object3d 가 cellId 위치의 anchor object3d 를
863
+ * 가리켜 tether / projectToScreen 정확.
864
+ *
865
+ * - popupRef 미설정 → no-op (event 만 발사된 상태로 남음)
866
+ * - 다른 cell 클릭 시 popup 이 새 anchor 로 "이동" (Popup 의 board 등 설정 유지)
867
+ * - frame/empty 영역 클릭 시 호출 안 됨 → popup 그대로 유지
868
+ * - 명시적 close 버튼은 popup 자체의 closable 옵션이 처리
869
+ */
870
+ private _invokePopup(cellId: string | undefined, record: any): void {
871
+ const popupRefId = this.state.popupRef
872
+ if (!popupRefId || !cellId) return
873
+ const popupComp: any = (this.root as any)?.findById?.(popupRefId)
874
+ if (!popupComp || typeof popupComp.openPopup !== 'function') {
875
+ console.warn(`[storage-rack] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
876
+ return
877
+ }
878
+ const anchor = this.slotTargetAt(cellId)
879
+ popupComp.openPopup(record ?? { cellId }, { anchor })
880
+ }
881
+
882
+ /**
883
+ * 클릭 시 framework 의 mouse NDC (이미 InteractionManager 가 set 한 상태) 를 재사용해
884
+ * raycast → *우리 rack* 의 어떤 mesh 가 closest hit 인지 반환. 다른 object 가 더 가까우면
885
+ * undefined (다른 rack 또는 무관 mesh 의 hit).
886
+ *
887
+ * 접근 경로:
888
+ * 1. ThreeCapability — ModelLayer 는 `_threeCapability`, ThreeContainer 는 `_capability`.
889
+ * capability 의 `getObjectsByRaycast()` 가 *동일한* mouse NDC 로 framework 가 click
890
+ * 처리 직전에 쓴 그 raycaster 를 재사용 (가장 정확).
891
+ * 2. capability 가 없는 컨테이너 — public scene3d / renderer3d / camera + mouseEvent
892
+ * 좌표로 자체 ndc 변환 후 fresh raycaster.
893
+ */
894
+ private _raycastRackHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
895
+ const ro: any = (this as any)._realObject
896
+ if (!ro?.object3d) return undefined
897
+
898
+ const tc: any = ro.threeContainer
899
+ if (!tc) return undefined
900
+
901
+ const cap: any = tc._threeCapability ?? tc._capability
902
+ let intersects: THREE.Intersection[] | undefined
903
+ if (cap?.getObjectsByRaycast) {
904
+ intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
905
+ }
906
+
907
+ if (!intersects || intersects.length === 0) {
908
+ const scene = tc.scene3d as THREE.Scene | undefined
909
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
910
+ const camera =
911
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
912
+ (cap?.activeCamera as THREE.Camera | undefined) ??
913
+ (cap?.camera as THREE.Camera | undefined)
914
+ const canvas = renderer?.domElement
915
+ if (!scene || !canvas || !camera) return undefined
916
+ const rect = canvas.getBoundingClientRect()
917
+ if (rect.width === 0 || rect.height === 0) return undefined
918
+ const ndc = new THREE.Vector2(
919
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
920
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
921
+ )
922
+ const raycaster = new THREE.Raycaster()
923
+ raycaster.setFromCamera(ndc, camera)
924
+ intersects = raycaster.intersectObjects(scene.children, true)
925
+ }
926
+
927
+ if (!intersects || intersects.length === 0) return undefined
928
+
929
+ // Three.js intersectObjects 는 distance 오름차순 정렬. 첫 hit 이 *우리 rack* 의 descendant
930
+ // 인지 확인 (userData.context walk-up 으로). 다른 object 가 더 가까우면 (다른 rack 또는
931
+ // 무관 mesh 가 사이에 있음) — 무시.
932
+ const closest = intersects[0]
933
+ let obj: THREE.Object3D | null = closest.object
934
+ while (obj) {
935
+ if (obj.userData?.context === ro) return closest
936
+ obj = obj.parent
937
+ }
938
+ return undefined
939
+ }
940
+
941
+ /**
942
+ * world point → cellId 역산.
943
+ *
944
+ * 1. rack 의 `matrixWorld.invert()` 로 world → rack-local 변환 (rack 의 회전·이동
945
+ * 반영)
946
+ * 2. rack-local point 의 `(x, y)` 를 (bay, level) 격자에 매핑
947
+ *
948
+ * 범위 밖이면 `"out-of-bounds(...)"` 문자열 반환 (caller 가 무시).
949
+ */
950
+ private _cellIdFromWorldPoint(worldPoint: THREE.Vector3): string | null {
951
+ const ro: any = (this as any)._realObject
952
+ if (!ro?.object3d) return null
953
+
954
+ const local = new THREE.Vector3().copy(worldPoint)
955
+ const mInv = new THREE.Matrix4().copy(ro.object3d.matrixWorld).invert()
956
+ local.applyMatrix4(mInv)
957
+
958
+ const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
959
+ const levels = Math.max(1, Math.floor((this.state.levels as number) || 4))
960
+ const width = (this.state.width as number) || 1000
961
+ const depth = (this.state.depth as number) || 3000
962
+ const shelfBase = (this.state.shelfBaseHeight as number) || 0
963
+ const shelfZone = depth - shelfBase
964
+
965
+ const bayWidth = width / bays
966
+ const levelHeight = shelfZone / levels
967
+
968
+ // rack 의 3D origin = rack center. X: [-width/2,+width/2], Y: [-depth/2,+depth/2]
969
+ const bayIdx = Math.floor((local.x + width / 2) / bayWidth)
970
+ const yFromBottom = local.y + depth / 2 - shelfBase
971
+ const levelIdx = Math.floor(yFromBottom / levelHeight)
972
+ const rowIdx = 0 // storage-rack 은 rows=1
973
+
974
+ if (bayIdx < 0 || bayIdx >= bays || levelIdx < 0 || levelIdx >= levels) {
975
+ return `out-of-bounds(bay=${bayIdx}, level=${levelIdx})`
976
+ }
977
+ return `${bayIdx}-${rowIdx}-${levelIdx}`
978
+ }
979
+
228
980
  // ── 3D ───────────────────────────────────────────────────────────────────
229
981
 
230
982
  buildRealObject(): RealObject | undefined {