@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
@@ -0,0 +1,167 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Crane 3D 의 기하학 공식 정합 검증 — *pure* unit test.
5
+ *
6
+ * 시뮬 시각 결함 진단 위한 *수학적 정합 sanity check*. carrier 위치/자세
7
+ * 결함이 *기하학 공식 mismatch* 인지 *3D scene-graph 의 다른 결함* 인지
8
+ * 좁힘.
9
+ *
10
+ * Forward 공식 (Crane3D.build):
11
+ * carriageH = S * 0.12 (S = min(width, height))
12
+ * bladeH = carriageH * 0.35
13
+ * liftGroup.y crane-local = baseTrolleyY + baseH/2 + carriageHeight + forkLift + carriageH/2
14
+ * _carrierBaseY (liftGroup-local) = -bladeH/2 (fork blade bottom)
15
+ * carrier 외부 bottom crane-local = liftGroup.y + _carrierBaseY
16
+ * carrier 외부 bottom world Y = craneCenterWorldY + crane-local
17
+ *
18
+ * Inverse (Crane3D.solveCarriageHeightForCarrierBaseWorldY):
19
+ * carriageHeight = worldY − craneCenterY − (baseTrolleyY + baseH/2 + carriageH/2 − bladeH/2) − forkLift
20
+ */
21
+
22
+ import 'should'
23
+
24
+ interface CraneParams {
25
+ S: number // min(width, height)
26
+ D: number // depth
27
+ }
28
+
29
+ interface DerivedParams {
30
+ carriageH: number
31
+ bladeH: number
32
+ baseH: number
33
+ railH: number
34
+ baseTrolleyY: number // crane-local (= -D/2 + railH + baseH/2)
35
+ }
36
+
37
+ function derive(p: CraneParams): DerivedParams {
38
+ const carriageH = p.S * 0.12
39
+ const bladeH = carriageH * 0.35
40
+ const baseH = p.S * 0.18
41
+ const railH = p.S * 0.04
42
+ const baseTrolleyY = -p.D / 2 + railH + baseH / 2
43
+ return { carriageH, bladeH, baseH, railH, baseTrolleyY }
44
+ }
45
+
46
+ // Forward: build 공식
47
+ function carrierBaseWorldY(
48
+ d: DerivedParams,
49
+ carriageHeight: number,
50
+ forkLift: number,
51
+ craneCenterY: number
52
+ ): number {
53
+ const liftGroupY = d.baseTrolleyY + d.baseH / 2 + carriageHeight + forkLift + d.carriageH / 2
54
+ const carrierBaseLocal = liftGroupY + (-d.bladeH / 2)
55
+ return craneCenterY + carrierBaseLocal
56
+ }
57
+
58
+ // Inverse: solveCarriageHeightForCarrierBaseWorldY
59
+ function solveCarriageHeight(
60
+ d: DerivedParams,
61
+ carrierBaseWorld: number,
62
+ forkLift: number,
63
+ craneCenterY: number
64
+ ): number {
65
+ return carrierBaseWorld - craneCenterY - (d.baseTrolleyY + d.baseH / 2 + d.carriageH / 2 - d.bladeH / 2) - forkLift
66
+ }
67
+
68
+ describe('Crane3D — geometry roundtrip', () => {
69
+ const params: CraneParams = { S: 400, D: 1000 }
70
+ const d = derive(params)
71
+
72
+ it('derived parameter values', () => {
73
+ d.carriageH.should.be.approximately(48, 0.001)
74
+ d.bladeH.should.be.approximately(16.8, 0.001)
75
+ d.baseH.should.be.approximately(72, 0.001)
76
+ d.railH.should.be.approximately(16, 0.001)
77
+ // baseTrolleyY = -500 + 16 + 36 = -448
78
+ d.baseTrolleyY.should.be.approximately(-448, 0.001)
79
+ })
80
+
81
+ it('forward → inverse roundtrip — forkLift=0', () => {
82
+ const craneCenterY = 0
83
+ const forkLift = 0
84
+ for (const ch of [0, 100, 200, 500, 800]) {
85
+ const baseWorld = carrierBaseWorldY(d, ch, forkLift, craneCenterY)
86
+ const recovered = solveCarriageHeight(d, baseWorld, forkLift, craneCenterY)
87
+ recovered.should.be.approximately(ch, 0.001)
88
+ }
89
+ })
90
+
91
+ it('forward → inverse roundtrip — forkLift>0 (들린 상태)', () => {
92
+ const craneCenterY = 0
93
+ const forkLift = 30
94
+ for (const ch of [0, 100, 500]) {
95
+ const baseWorld = carrierBaseWorldY(d, ch, forkLift, craneCenterY)
96
+ const recovered = solveCarriageHeight(d, baseWorld, forkLift, craneCenterY)
97
+ recovered.should.be.approximately(ch, 0.001)
98
+ }
99
+ })
100
+
101
+ it('forward → inverse — craneCenterY 보정 (crane.zPos != 0)', () => {
102
+ const craneCenterY = 250
103
+ const forkLift = 0
104
+ const ch = 100
105
+ const baseWorld = carrierBaseWorldY(d, ch, forkLift, craneCenterY)
106
+ const recovered = solveCarriageHeight(d, baseWorld, forkLift, craneCenterY)
107
+ recovered.should.be.approximately(ch, 0.001)
108
+ })
109
+ })
110
+
111
+ describe('Crane3D — pick 시퀀스 기하학', () => {
112
+ const params: CraneParams = { S: 400, D: 1000 }
113
+ const d = derive(params)
114
+ const craneCenterY = 0
115
+
116
+ it('pick 진입: carriageHeight 산출 → carrier 외부 bottom = cellBottom', () => {
117
+ const cellBottom = 100 // cell shelf 면 world Y
118
+ const forkLift = 0 // pick 진입 시 forkLift 0
119
+
120
+ const carriageHeight = solveCarriageHeight(d, cellBottom, forkLift, craneCenterY)
121
+ const baseWorld = carrierBaseWorldY(d, carriageHeight, forkLift, craneCenterY)
122
+
123
+ baseWorld.should.be.approximately(cellBottom, 0.001)
124
+ })
125
+
126
+ it('pick lift 후: forkLift = liftH → carrier 외부 bottom = cellBottom + liftH', () => {
127
+ const cellBottom = 100
128
+ const liftH = 30
129
+ // 진입 carriageHeight (forkLift=0)
130
+ const ch = solveCarriageHeight(d, cellBottom, 0, craneCenterY)
131
+ // lift 후 forkLift = liftH, carriageHeight 그대로
132
+ const liftedBaseWorld = carrierBaseWorldY(d, ch, liftH, craneCenterY)
133
+ liftedBaseWorld.should.be.approximately(cellBottom + liftH, 0.001)
134
+ })
135
+ })
136
+
137
+ describe('Crane3D — place 시퀀스 기하학', () => {
138
+ const params: CraneParams = { S: 400, D: 1000 }
139
+ const d = derive(params)
140
+ const craneCenterY = 0
141
+
142
+ it('place 진입 (holding): approachWorldY = cellBottom + liftH', () => {
143
+ const cellBottom = 200 // 다른 cell
144
+ const liftH = 30
145
+ const forkLiftRT = liftH // 들린 상태로 도착
146
+
147
+ // approachWorldY = cellBottom + liftH (holding 보정)
148
+ const approachWorldY = cellBottom + liftH
149
+
150
+ const carriageHeight = solveCarriageHeight(d, approachWorldY, forkLiftRT, craneCenterY)
151
+ const baseWorld = carrierBaseWorldY(d, carriageHeight, forkLiftRT, craneCenterY)
152
+
153
+ baseWorld.should.be.approximately(approachWorldY, 0.001)
154
+ })
155
+
156
+ it('place lower 후: forkLift = 0 → carrier 외부 bottom = cellBottom', () => {
157
+ const cellBottom = 200
158
+ const liftH = 30
159
+ const approachWorldY = cellBottom + liftH
160
+
161
+ const ch = solveCarriageHeight(d, approachWorldY, liftH, craneCenterY)
162
+ // lower 후 forkLift = 0
163
+ const loweredBaseWorld = carrierBaseWorldY(d, ch, 0, craneCenterY)
164
+
165
+ loweredBaseWorld.should.be.approximately(cellBottom, 0.001)
166
+ })
167
+ })
@@ -0,0 +1,461 @@
1
+ /*
2
+ * 외부 (model-layer) 패키지 → Rack 내부로의 pickAndPlace 검증.
3
+ *
4
+ * 사용자 질문:
5
+ * 1. 외부 → 내부 프로세스가 *완전히* 검증됐나?
6
+ * 2. 한 번 옮긴 후 *같은 외부 위치에서 또 가져오려 시도* 하는 결함 — dispose 가 정상인가?
7
+ *
8
+ * 검증:
9
+ * - 외부 parcel 의 lifecycle: model-layer 자식 → pick → crane 자식 → place → dispose
10
+ * - dispose 후 *id 인덱스 / findById / parent reference 모두 정리되어야*
11
+ * - 두 번째 pickAndPlace 시도 → 같은 parcel reference 가 *유효하지 않아야*
12
+ * - state.data 에 record 로 환원되어 다시 obtain 가능해야 함
13
+ */
14
+
15
+ import 'should'
16
+ import * as THREE from 'three'
17
+
18
+ // ── Mini scene: root + model-layer + rack + crane ──────────────────────────
19
+
20
+ class MiniRoot {
21
+ components: any[] = []
22
+ _idIndex = new Map<string, any>()
23
+
24
+ addComponent(c: any): void {
25
+ c.parent = this
26
+ this.components.push(c)
27
+ if (c.state?.id) this._idIndex.set(c.state.id, c)
28
+ // children recursive add
29
+ for (const child of c.components ?? []) {
30
+ this.addComponent(child)
31
+ }
32
+ }
33
+
34
+ removeComponent(c: any): void {
35
+ const i = this.components.indexOf(c)
36
+ if (i >= 0) this.components.splice(i, 1)
37
+ if (c.state?.id) this._idIndex.delete(c.state.id)
38
+ c.parent = null
39
+ }
40
+
41
+ // Recursive — descendant 도 id 등록
42
+ _registerDescendants(c: any): void {
43
+ if (c.state?.id) this._idIndex.set(c.state.id, c)
44
+ for (const child of c.components ?? []) this._registerDescendants(child)
45
+ }
46
+ _unregisterDescendants(c: any): void {
47
+ if (c.state?.id) this._idIndex.delete(c.state.id)
48
+ for (const child of c.components ?? []) this._unregisterDescendants(child)
49
+ }
50
+
51
+ findById(id: string): any { return this._idIndex.get(id) ?? null }
52
+ }
53
+
54
+ class MiniModelLayer {
55
+ parent: any = null
56
+ components: any[] = []
57
+ state = { id: 'model-layer' }
58
+ object3d = new THREE.Group()
59
+
60
+ constructor() { this.object3d.name = 'model-layer' }
61
+
62
+ addComponent(c: any): void {
63
+ c.parent = this
64
+ this.components.push(c)
65
+ if (c._realObject?.object3d) this.object3d.attach(c._realObject.object3d)
66
+ // root index sync
67
+ const root = this._findRoot()
68
+ if (root) root._registerDescendants(c)
69
+ }
70
+
71
+ removeComponent(c: any): void {
72
+ const i = this.components.indexOf(c)
73
+ if (i >= 0) this.components.splice(i, 1)
74
+ c.parent = null
75
+ const root = this._findRoot()
76
+ if (root) root._unregisterDescendants(c)
77
+ }
78
+
79
+ _findRoot(): MiniRoot | null {
80
+ let p: any = this.parent
81
+ while (p && !(p instanceof MiniRoot)) p = p.parent
82
+ return p as MiniRoot ?? null
83
+ }
84
+
85
+ canReceive(_c: any): boolean { return true }
86
+ async receive(c: any): Promise<void> {
87
+ const p = c.parent
88
+ if (p?.removeComponent) p.removeComponent(c)
89
+ this.addComponent(c)
90
+ }
91
+ }
92
+
93
+ class MiniRack {
94
+ parent: any = null
95
+ state: any = { id: 'rack', data: [] }
96
+ components: any[] = []
97
+ object3d = new THREE.Group()
98
+ _slotAnchors = new Map<string, THREE.Object3D>()
99
+
100
+ constructor() { this.object3d.name = 'rack' }
101
+
102
+ records(): any[] { return this.state.data }
103
+ carrierAt(cellId: string): any {
104
+ return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
105
+ }
106
+ canReceiveAt(cellId: string, carrier?: any): boolean {
107
+ if (this.records().some(r => r?.cellId === cellId)) return false
108
+ const existing = this.carrierAt(cellId)
109
+ if (existing && existing !== carrier) return false
110
+ return true
111
+ }
112
+
113
+ addComponent(c: any): void {
114
+ c.parent = this
115
+ this.components.push(c)
116
+ const root = (this.parent as MiniModelLayer)?._findRoot?.()
117
+ if (root) root._registerDescendants(c)
118
+ }
119
+ removeComponent(c: any): void {
120
+ const i = this.components.indexOf(c)
121
+ if (i >= 0) this.components.splice(i, 1)
122
+ c.parent = null
123
+ const root = (this.parent as MiniModelLayer)?._findRoot?.()
124
+ if (root) root._unregisterDescendants(c)
125
+ }
126
+
127
+ ensureSlotAnchor(cellId: string): THREE.Object3D {
128
+ let a = this._slotAnchors.get(cellId)
129
+ if (!a) {
130
+ a = new THREE.Object3D()
131
+ a.name = `slot:${cellId}`
132
+ this.object3d.add(a)
133
+ this._slotAnchors.set(cellId, a)
134
+ }
135
+ return a
136
+ }
137
+
138
+ slotTargetAt(cellId: string): any {
139
+ const rack = this
140
+ return {
141
+ slotId: cellId,
142
+ holder: rack,
143
+ canReceive: (c: any) => rack.canReceiveAt(cellId, c),
144
+ async receive(c: any) { await rack.receiveAt(cellId, c) }
145
+ }
146
+ }
147
+
148
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
149
+ if (!this.canReceiveAt(cellId, carrier)) {
150
+ const err: any = new Error('slot occupied')
151
+ err.reason = 'slot-occupied'
152
+ throw err
153
+ }
154
+ const p = carrier.parent
155
+ if (p?.removeComponent) p.removeComponent(carrier)
156
+ const obj = carrier._realObject?.object3d
157
+ if (obj?.parent?.remove) obj.parent.remove(obj)
158
+ carrier.dispose?.()
159
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel', sku: carrier.state.sku }
160
+ const remaining = this.records().filter(r => r?.cellId !== cellId)
161
+ this.state.data = [...remaining, rec]
162
+ }
163
+
164
+ obtainCarrier(cellId: string): any {
165
+ const existing = this.carrierAt(cellId)
166
+ if (existing) return existing
167
+ const records = this.records()
168
+ const idx = records.findIndex(r => r?.cellId === cellId)
169
+ if (idx === -1) return null
170
+ const record = records[idx]
171
+ const c: any = {
172
+ placement: 'operation',
173
+ state: { ...record, cellId }, // id 명시 안 함 → 새 transient (외부 parcel 과 다른 객체)
174
+ parent: null,
175
+ _disposed: false,
176
+ _realObject: { object3d: new THREE.Group() },
177
+ dispose() {
178
+ this._disposed = true
179
+ if (this._realObject?.object3d?.parent?.remove) {
180
+ this._realObject.object3d.parent.remove(this._realObject.object3d)
181
+ }
182
+ this._realObject.object3d.clear()
183
+ }
184
+ }
185
+ this.addComponent(c)
186
+ const pt = this.ensureSlotAnchor(cellId)
187
+ pt.attach(c._realObject.object3d)
188
+ c._realObject.object3d.position.set(0, 0, 0)
189
+ this.state.data = records.filter(r => r?.cellId !== cellId)
190
+ return c
191
+ }
192
+ }
193
+
194
+ class MiniCrane {
195
+ parent: any = null
196
+ state = { id: 'crane' }
197
+ components: any[] = []
198
+ object3d = new THREE.Group()
199
+ forkObject3d: THREE.Object3D
200
+ constructor() {
201
+ this.object3d.name = 'crane'
202
+ this.forkObject3d = new THREE.Object3D()
203
+ this.forkObject3d.name = 'crane-fork'
204
+ this.object3d.add(this.forkObject3d)
205
+ }
206
+ canReceive(_c: any): boolean {
207
+ return this.components.filter(c => c._transferSlotId === 'forks').length < 1
208
+ }
209
+ async receive(c: any): Promise<void> {
210
+ if (!this.canReceive(c)) {
211
+ const err: any = new Error('crane full')
212
+ err.reason = 'all-slots-full'
213
+ throw err
214
+ }
215
+ const op = c.parent
216
+ if (op?.removeComponent) op.removeComponent(c)
217
+ c.parent = this
218
+ this.components.push(c)
219
+ c._transferSlotId = 'forks'
220
+ const obj = c._realObject?.object3d
221
+ if (obj) {
222
+ if (obj.parent?.remove) obj.parent.remove(obj)
223
+ this.forkObject3d.attach(obj)
224
+ obj.position.set(0, 0, 0)
225
+ }
226
+ }
227
+ removeComponent(c: any): void {
228
+ const i = this.components.indexOf(c)
229
+ if (i >= 0) this.components.splice(i, 1)
230
+ c.parent = null
231
+ }
232
+ async dispatch(c: any, target: any): Promise<void> {
233
+ if (target.canReceive && !target.canReceive(c)) {
234
+ const err: any = new Error('target rejected')
235
+ err.reason = 'cannot-receive'
236
+ throw err
237
+ }
238
+ delete c._transferSlotId
239
+ if (typeof target.receive === 'function') await target.receive(c)
240
+ }
241
+ async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
242
+ // R18 guard — disposed carrier 재사용 차단
243
+ if (carrier?._disposed) {
244
+ throw new Error('Mover.pickAndPlace: carrier is already disposed')
245
+ }
246
+ const sourceParent: any = carrier.parent
247
+ try {
248
+ if (carrier.parent !== this) await this.receive(carrier)
249
+ const timeoutMs = options.timeoutMs ?? 1000
250
+ const started = Date.now()
251
+ while (target.canReceive && !target.canReceive(carrier)) {
252
+ if (Date.now() - started > timeoutMs) throw new Error(`place timeout`)
253
+ await new Promise(r => setTimeout(r, 5))
254
+ }
255
+ await this.dispatch(carrier, target)
256
+ } catch (err) {
257
+ if (this.components.includes(carrier)) {
258
+ if (sourceParent?.canReceive?.(carrier)) {
259
+ try { await this.dispatch(carrier, sourceParent) } catch {}
260
+ } else {
261
+ this.removeComponent(carrier)
262
+ }
263
+ }
264
+ throw err
265
+ }
266
+ }
267
+ }
268
+
269
+ function buildScene() {
270
+ const root = new MiniRoot()
271
+ const modelLayer = new MiniModelLayer()
272
+ const rack = new MiniRack()
273
+ const crane = new MiniCrane()
274
+ root.addComponent(modelLayer)
275
+ modelLayer.parent = root
276
+ rack.parent = modelLayer
277
+ modelLayer.components.push(rack)
278
+ if (rack.state?.id) root._idIndex.set(rack.state.id, rack)
279
+ crane.parent = modelLayer
280
+ modelLayer.components.push(crane)
281
+ if (crane.state?.id) root._idIndex.set(crane.state.id, crane)
282
+ return { root, modelLayer, rack, crane }
283
+ }
284
+
285
+ // ── Group 1: 외부 → 내부 pickAndPlace 기본 흐름 ─────────────────────────────
286
+
287
+ describe('External → Rack: 기본 흐름 검증', () => {
288
+ it('parcel (model-layer 자식) → rack slot — state.data 에 record 추가', async () => {
289
+ const { root, modelLayer, rack, crane } = buildScene()
290
+ const parcel: any = {
291
+ placement: 'operation',
292
+ state: { id: 'parcel', type: 'parcel', sku: 'X' },
293
+ parent: null,
294
+ _disposed: false,
295
+ _realObject: { object3d: new THREE.Group() },
296
+ dispose() {
297
+ this._disposed = true
298
+ if (this._realObject?.object3d?.parent?.remove) this._realObject.object3d.parent.remove(this._realObject.object3d)
299
+ this._realObject.object3d.clear()
300
+ }
301
+ }
302
+ modelLayer.addComponent(parcel)
303
+
304
+ root.findById('parcel').should.equal(parcel)
305
+ modelLayer.components.includes(parcel).should.be.true()
306
+
307
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
308
+
309
+ // 검증: rack 의 state.data 에 record 가 있어야 함
310
+ rack.records().length.should.equal(1)
311
+ rack.records()[0].cellId.should.equal('A')
312
+ rack.records()[0].sku.should.equal('X')
313
+
314
+ // parcel 은 dispose 됐어야 함
315
+ parcel._disposed.should.be.true()
316
+ ;(parcel.parent === null).should.be.true()
317
+ })
318
+
319
+ it('FIX 검증 — dispose 후 findById 가 *parcel 을 못 찾아야*', async () => {
320
+ const { root, modelLayer, rack, crane } = buildScene()
321
+ const parcel: any = {
322
+ placement: 'operation',
323
+ state: { id: 'parcel', type: 'parcel' },
324
+ parent: null,
325
+ _disposed: false,
326
+ _realObject: { object3d: new THREE.Group() },
327
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
328
+ }
329
+ modelLayer.addComponent(parcel)
330
+
331
+ root.findById('parcel').should.equal(parcel) // 시작 — 찾을 수 있음
332
+
333
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
334
+
335
+ // *결정적 검증* — dispose 후엔 못 찾아야 함
336
+ ;(root.findById('parcel') === null).should.be.true()
337
+ })
338
+
339
+ it('parcel.object3d 가 *완전히 detach* — model-layer / crane / rack 어디에도 없음', async () => {
340
+ const { root, modelLayer, rack, crane } = buildScene()
341
+ const parcel: any = {
342
+ placement: 'operation',
343
+ state: { id: 'parcel', type: 'parcel' },
344
+ parent: null,
345
+ _realObject: { object3d: new THREE.Group() },
346
+ dispose() {
347
+ if (this._realObject?.object3d?.parent?.remove) this._realObject.object3d.parent.remove(this._realObject.object3d)
348
+ this._realObject.object3d.clear()
349
+ }
350
+ }
351
+ modelLayer.addComponent(parcel)
352
+ parcel._realObject.object3d.parent!.name.should.equal('model-layer')
353
+
354
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
355
+
356
+ ;(parcel._realObject.object3d.parent === null).should.be.true()
357
+ crane.forkObject3d.children.length.should.equal(0)
358
+ modelLayer.object3d.children.includes(parcel._realObject.object3d).should.be.false()
359
+ })
360
+ })
361
+
362
+ // ── Group 2: dispose 후 *다시* pickAndPlace 시도 ────────────────────────────
363
+
364
+ describe('External → Rack: dispose 후 reference 재사용', () => {
365
+ it('R18 FIX VERIFY — disposed parcel 로 또 pickAndPlace 시도 → *즉시 throw*', async () => {
366
+ const { root, modelLayer, rack, crane } = buildScene()
367
+ const parcel: any = {
368
+ placement: 'operation',
369
+ state: { id: 'parcel', type: 'parcel' },
370
+ parent: null,
371
+ _disposed: false,
372
+ _realObject: { object3d: new THREE.Group() },
373
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
374
+ }
375
+ modelLayer.addComponent(parcel)
376
+
377
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
378
+ parcel._disposed.should.be.true()
379
+ rack.records().length.should.equal(1)
380
+ rack.records()[0].cellId.should.equal('A')
381
+
382
+ // 두 번째 시도 — disposed reference 로 → R18 fix 가 *즉시 throw*
383
+ let thrown: any
384
+ try {
385
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('B'), { timeoutMs: 200 })
386
+ } catch (e) { thrown = e }
387
+
388
+ thrown.should.not.be.null()
389
+ thrown.message.should.match(/disposed/i)
390
+ // 좀비 작업 안 일어남 — state.data 그대로
391
+ rack.records().length.should.equal(1)
392
+ rack.records()[0].cellId.should.equal('A')
393
+ // crane 안 움직임
394
+ crane.components.length.should.equal(0)
395
+ })
396
+
397
+ it('FIX REQUIRED — dispose 된 carrier 는 *재사용 reject 되어야*', async () => {
398
+ const { root, modelLayer, rack, crane } = buildScene()
399
+ const parcel: any = {
400
+ placement: 'operation',
401
+ state: { id: 'parcel', type: 'parcel' },
402
+ parent: null,
403
+ _disposed: false,
404
+ _realObject: { object3d: new THREE.Group() },
405
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
406
+ }
407
+ modelLayer.addComponent(parcel)
408
+
409
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
410
+
411
+ // *원하는 동작* — disposed carrier 로 호출 시 명시 reject
412
+ // 현재 구현엔 _disposed 검사가 없음. 사용자 script 가 disposed reference 를 갖고
413
+ // 또 호출하면 *조용히 새 carrier 처럼 작동* 함 — 사용자가 "왜 같은 외부에서 또
414
+ // 가져오려 하나" 라고 느끼는 정체.
415
+ parcel._disposed.should.be.true()
416
+
417
+ // 사용자가 *해야 할 일* — 두 번째에선 obtainCarrier 로 *새 transient* 를 가져와야 함
418
+ const newCarrier = rack.obtainCarrier('A')
419
+ newCarrier.should.not.be.null()
420
+ newCarrier!.state.cellId.should.equal('A')
421
+ ;(newCarrier!._disposed !== true).should.be.true()
422
+ })
423
+ })
424
+
425
+ // ── Group 3: pickAndPlace 후 *rack 내부 record 로 다시 obtain* ──────────────
426
+
427
+ describe('External → Rack → 다시 외부로 (round trip)', () => {
428
+ it('외부 parcel → rack 진입 → 다시 obtainCarrier → 새 transient — 원본 X', async () => {
429
+ const { root, modelLayer, rack, crane } = buildScene()
430
+ const parcel: any = {
431
+ placement: 'operation',
432
+ state: { id: 'parcel', type: 'parcel', sku: 'X' },
433
+ parent: null,
434
+ _disposed: false,
435
+ _realObject: { object3d: new THREE.Group() },
436
+ dispose() { this._disposed = true; this._realObject.object3d.clear() }
437
+ }
438
+ modelLayer.addComponent(parcel)
439
+
440
+ // 1. 외부 → 내부
441
+ await crane.pickAndPlace(parcel, rack.slotTargetAt('A'), { timeoutMs: 200 })
442
+ parcel._disposed.should.be.true()
443
+ rack.records().length.should.equal(1)
444
+
445
+ // 2. 내부 → (다시) 새 외부 위치로 — obtainCarrier 로 새 transient
446
+ const newCarrier = rack.obtainCarrier('A')!
447
+ newCarrier.should.not.equal(parcel) // 원본과 다른 객체
448
+ newCarrier._disposed.should.equal(false)
449
+ newCarrier.state.sku.should.equal('X') // 데이터 보존
450
+
451
+ // 3. 새 transient 를 model-layer 등 외부로 보냄
452
+ await crane.pickAndPlace(newCarrier, modelLayer, { timeoutMs: 200 })
453
+
454
+ // 사용자가 보는 결과:
455
+ // - rack.records 비어있음 (carrier 가 외부로 떠났음)
456
+ // - newCarrier 는 model-layer 의 자식
457
+ rack.records().length.should.equal(0)
458
+ modelLayer.components.includes(newCarrier).should.be.true()
459
+ newCarrier.state.sku.should.equal('X')
460
+ })
461
+ })