@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,361 @@
1
+ /*
2
+ * Carrier Three.js lifecycle — pickAndPlace 단계별 *carrier.object3d 의 Three.js parent*
3
+ * 추적. ghost / teleport 같은 시각적 회귀의 근본 원인은 *어느 단계에서 parent 가 누락
4
+ * 또는 잘못 잡혀 있는가* 에 있음.
5
+ *
6
+ * 검증 4단계:
7
+ * 1. 갓 생성 (`new Carrier()`) — object3d.parent === null
8
+ * 2. obtainCarrier 직후 — object3d.parent === slot anchor (cellId 위치)
9
+ * 3. mover.pick 시뮬 (reparent) — object3d.parent === crane fork object3d
10
+ * 4. mover.place 의 receiveAt 시뮬 — object3d.parent === null (detach 완료)
11
+ *
12
+ * 4단계 누락 시 ghost 잔존. 1-3 단계 누락 시 carrier 가 잘못된 위치 (rack origin, world
13
+ * origin 등) 에 그려져 "랙 꼭대기 / 어딘가의 ghost" 증상.
14
+ *
15
+ * 실 things-scene Component 인스턴스화 비현실적 → minimal Component-like 로 contract
16
+ * 만 isolate.
17
+ */
18
+
19
+ import 'should'
20
+ import * as THREE from 'three'
21
+
22
+ // ── Minimal carrier (Plan A 의 transient carrier 대체) ──────────────────────────
23
+
24
+ class FakeCarrier {
25
+ state: any
26
+ parent: any = null
27
+ _realObject: { object3d: THREE.Object3D }
28
+ _disposed = false
29
+
30
+ constructor(state: any) {
31
+ this.state = state
32
+ this._realObject = { object3d: new THREE.Object3D() }
33
+ this._realObject.object3d.name = `carrier:${state.id ?? state.cellId ?? '?'}`
34
+ }
35
+
36
+ // Carriable.applyHolderAttachPoint 시뮬 — parent.attachPointFor 호출 후 Three.js attach
37
+ applyHolderAttachPoint(): void {
38
+ const p: any = this.parent
39
+ if (!p?.attachPointFor) return
40
+ const point = p.attachPointFor(this)
41
+ if (!point?.attach) return
42
+ point.attach.attach(this._realObject.object3d)
43
+ if (point.localPosition) {
44
+ this._realObject.object3d.position.set(
45
+ point.localPosition.x, point.localPosition.y, point.localPosition.z
46
+ )
47
+ }
48
+ }
49
+
50
+ dispose(): void {
51
+ this._disposed = true
52
+ // RealObject.dispose 가 children 만 정리, object3d 자체는 안 떼는 결함의 시뮬:
53
+ // children 만 제거.
54
+ this._realObject.object3d.clear()
55
+ // 만약 Plan A 의 receiveAt 명시 detach 가 정상이면 *전* 에 이미 parent.remove 됐어야 함.
56
+ }
57
+ }
58
+
59
+ // ── Minimal rack (SlottedHolder contract 대체) ──────────────────────────────────
60
+
61
+ class FakeRack {
62
+ state: any = { data: [] }
63
+ components: any[] = []
64
+ rootObject3d = new THREE.Group()
65
+ _slotAnchors = new Map<string, THREE.Object3D>()
66
+
67
+ constructor() { this.rootObject3d.name = 'rack' }
68
+
69
+ addComponent(c: any): void {
70
+ c.parent = this
71
+ this.components.push(c)
72
+ }
73
+ removeComponent(c: any): void {
74
+ const i = this.components.indexOf(c)
75
+ if (i >= 0) this.components.splice(i, 1)
76
+ c.parent = null
77
+ }
78
+
79
+ attachPointFor(carrier: any): { attach: THREE.Object3D; localPosition: { x: number; y: number; z: number } } {
80
+ const cellId = carrier.state.cellId
81
+ let anchor = this._slotAnchors.get(cellId)
82
+ if (!anchor) {
83
+ anchor = new THREE.Object3D()
84
+ anchor.name = `slot:${cellId}`
85
+ const bay = Number(cellId.split('-')[0] ?? 0)
86
+ anchor.position.set(bay * 100, 50, 0) // bay-별 X, level center Y
87
+ this.rootObject3d.add(anchor)
88
+ this._slotAnchors.set(cellId, anchor)
89
+ }
90
+ return { attach: anchor, localPosition: { x: 0, y: 0, z: 0 } }
91
+ }
92
+
93
+ obtainCarrier(cellId: string): FakeCarrier | null {
94
+ const records = this.state.data as any[]
95
+ const i = records.findIndex(r => r.cellId === cellId)
96
+ if (i === -1) return null
97
+ const record = records[i]
98
+ const carrier = new FakeCarrier({ ...record, cellId })
99
+ this.addComponent(carrier)
100
+ carrier.applyHolderAttachPoint()
101
+ this.state.data = records.filter((_, j) => j !== i)
102
+ return carrier
103
+ }
104
+
105
+ async receiveAt(cellId: string, carrier: any): Promise<void> {
106
+ // Plan A 의 receiveAt 흐름 그대로:
107
+ // 1. parent 에서 분리 (Component)
108
+ const p = carrier.parent
109
+ if (p?.removeComponent) p.removeComponent(carrier)
110
+
111
+ // 2. Three.js detach 명시 — 이게 RealObject.dispose 만으론 안 되는 부분
112
+ const obj = carrier._realObject?.object3d
113
+ if (obj?.parent?.remove) obj.parent.remove(obj)
114
+
115
+ // 3. dispose
116
+ carrier.dispose()
117
+
118
+ // 4. record push
119
+ const rec: any = { cellId, type: carrier.state.type ?? 'parcel' }
120
+ for (const k of Object.keys(carrier.state)) {
121
+ if (['id', 'left', 'top', 'zPos', 'cellId', '_transferSlotId'].includes(k)) continue
122
+ rec[k] = carrier.state[k]
123
+ }
124
+ this.state.data = [...this.state.data, rec]
125
+ }
126
+ }
127
+
128
+ // ── Minimal crane (Mover 시뮬) ──────────────────────────────────────────────────
129
+
130
+ class FakeCrane {
131
+ components: any[] = []
132
+ forkObject3d = new THREE.Object3D()
133
+
134
+ constructor() { this.forkObject3d.name = 'crane-fork' }
135
+
136
+ addComponent(c: any): void { c.parent = this; this.components.push(c) }
137
+ removeComponent(c: any): void {
138
+ const i = this.components.indexOf(c)
139
+ if (i >= 0) this.components.splice(i, 1)
140
+ c.parent = null
141
+ }
142
+
143
+ attachPointFor(_carrier: any): { attach: THREE.Object3D; localPosition: { x: number; y: number; z: number } } {
144
+ return { attach: this.forkObject3d, localPosition: { x: 0, y: 0, z: 0 } }
145
+ }
146
+
147
+ // pick 의 reparent 단계 시뮬 — Component 측 reparent + Three.js attach
148
+ simulatePick(carrier: any): void {
149
+ const oldParent = carrier.parent
150
+ if (oldParent?.removeComponent) oldParent.removeComponent(carrier)
151
+ this.addComponent(carrier)
152
+ // Three.js attach
153
+ const obj = carrier._realObject.object3d
154
+ this.forkObject3d.attach(obj)
155
+ obj.position.set(0, 0, 0)
156
+ }
157
+ }
158
+
159
+ // ── Group 1: 갓 생성 단계 ────────────────────────────────────────────────────
160
+
161
+ describe('Carrier lifecycle: 갓 생성', () => {
162
+ it('새 carrier 의 object3d.parent === null', () => {
163
+ const c = new FakeCarrier({ cellId: 'A' })
164
+ ;(c._realObject.object3d.parent === null).should.be.true()
165
+ })
166
+
167
+ it('Component-level parent 도 null', () => {
168
+ const c = new FakeCarrier({ cellId: 'A' })
169
+ ;(c.parent === null).should.be.true()
170
+ })
171
+ })
172
+
173
+ // ── Group 2: obtainCarrier 직후 ─────────────────────────────────────────────
174
+
175
+ describe('Carrier lifecycle: obtainCarrier 직후', () => {
176
+ it('Component-level parent === rack', () => {
177
+ const rack = new FakeRack()
178
+ rack.state.data = [{ cellId: 'A-0-1', type: 'parcel' }]
179
+ const c = rack.obtainCarrier('A-0-1')!
180
+ c.parent.should.equal(rack)
181
+ })
182
+
183
+ it('object3d.parent === slot anchor (rack 의 자식)', () => {
184
+ const rack = new FakeRack()
185
+ rack.state.data = [{ cellId: 'A-0-1' }]
186
+ const c = rack.obtainCarrier('A-0-1')!
187
+ const objParent = c._realObject.object3d.parent
188
+ objParent!.name.should.equal('slot:A-0-1')
189
+ objParent!.parent!.should.equal(rack.rootObject3d)
190
+ })
191
+
192
+ it('object3d.position === (0,0,0) — slot anchor origin 으로 snap', () => {
193
+ const rack = new FakeRack()
194
+ rack.state.data = [{ cellId: 'A-0-1' }]
195
+ const c = rack.obtainCarrier('A-0-1')!
196
+ const p = c._realObject.object3d.position
197
+ p.x.should.equal(0)
198
+ p.y.should.equal(0)
199
+ p.z.should.equal(0)
200
+ })
201
+
202
+ it('state.data 에서 그 record 제거됨', () => {
203
+ const rack = new FakeRack()
204
+ rack.state.data = [{ cellId: 'A', sku: 'X' }, { cellId: 'B', sku: 'Y' }]
205
+ rack.obtainCarrier('A')
206
+ rack.state.data.length.should.equal(1)
207
+ rack.state.data[0].cellId.should.equal('B')
208
+ })
209
+ })
210
+
211
+ // ── Group 3: simulated pick (rack → crane) ──────────────────────────────────
212
+
213
+ describe('Carrier lifecycle: pick (rack → crane) 후', () => {
214
+ it('Component-level parent === crane', () => {
215
+ const rack = new FakeRack()
216
+ rack.state.data = [{ cellId: 'A' }]
217
+ const crane = new FakeCrane()
218
+ const c = rack.obtainCarrier('A')!
219
+
220
+ crane.simulatePick(c)
221
+ c.parent.should.equal(crane)
222
+ })
223
+
224
+ it('object3d.parent === crane.forkObject3d', () => {
225
+ const rack = new FakeRack()
226
+ rack.state.data = [{ cellId: 'A' }]
227
+ const crane = new FakeCrane()
228
+ const c = rack.obtainCarrier('A')!
229
+
230
+ crane.simulatePick(c)
231
+ c._realObject.object3d.parent!.name.should.equal('crane-fork')
232
+ })
233
+
234
+ it('rack.components 에서 carrier 사라짐', () => {
235
+ const rack = new FakeRack()
236
+ rack.state.data = [{ cellId: 'A' }]
237
+ const crane = new FakeCrane()
238
+ const c = rack.obtainCarrier('A')!
239
+
240
+ crane.simulatePick(c)
241
+ rack.components.length.should.equal(0)
242
+ crane.components.length.should.equal(1)
243
+ crane.components[0].should.equal(c)
244
+ })
245
+
246
+ it('slot anchor object3d 는 rack 에 그대로 남음 (재사용 가능)', () => {
247
+ const rack = new FakeRack()
248
+ rack.state.data = [{ cellId: 'A' }]
249
+ const crane = new FakeCrane()
250
+ const c = rack.obtainCarrier('A')!
251
+
252
+ crane.simulatePick(c)
253
+ const anchor = rack._slotAnchors.get('A')
254
+ anchor!.parent!.should.equal(rack.rootObject3d)
255
+ anchor!.children.length.should.equal(0) // carrier 가 떠났으므로 비어있음
256
+ })
257
+ })
258
+
259
+ // ── Group 4: receiveAt — carrier 가 다시 rack 으로 (BUT 데이터로 환원) ────────
260
+
261
+ describe('Carrier lifecycle: receiveAt (crane → rack) 후', () => {
262
+ it('Component-level parent === null (어디에도 안 속함)', async () => {
263
+ const rack = new FakeRack()
264
+ rack.state.data = [{ cellId: 'A' }]
265
+ const crane = new FakeCrane()
266
+ const c = rack.obtainCarrier('A')!
267
+ crane.simulatePick(c)
268
+
269
+ await rack.receiveAt('B', c)
270
+ ;(c.parent === null).should.be.true()
271
+ })
272
+
273
+ it('object3d.parent === null — Three.js scene graph 에서 detach 완료', async () => {
274
+ const rack = new FakeRack()
275
+ rack.state.data = [{ cellId: 'A' }]
276
+ const crane = new FakeCrane()
277
+ const c = rack.obtainCarrier('A')!
278
+ crane.simulatePick(c)
279
+
280
+ await rack.receiveAt('B', c)
281
+ ;(c._realObject.object3d.parent === null).should.be.true()
282
+ })
283
+
284
+ it('crane.forkObject3d 의 자식에 carrier 없음 (ghost X)', async () => {
285
+ const rack = new FakeRack()
286
+ rack.state.data = [{ cellId: 'A' }]
287
+ const crane = new FakeCrane()
288
+ const c = rack.obtainCarrier('A')!
289
+ crane.simulatePick(c)
290
+
291
+ await rack.receiveAt('B', c)
292
+ crane.forkObject3d.children.length.should.equal(0)
293
+ crane.components.length.should.equal(0)
294
+ })
295
+
296
+ it('carrier 가 disposed 마킹됨', async () => {
297
+ const rack = new FakeRack()
298
+ rack.state.data = [{ cellId: 'A' }]
299
+ const crane = new FakeCrane()
300
+ const c = rack.obtainCarrier('A')!
301
+ crane.simulatePick(c)
302
+
303
+ await rack.receiveAt('B', c)
304
+ c._disposed.should.be.true()
305
+ })
306
+
307
+ it('state.data 에 record 추가됨 — dest slot 으로', async () => {
308
+ const rack = new FakeRack()
309
+ rack.state.data = [{ cellId: 'A', sku: 'X', qty: 5 }]
310
+ const crane = new FakeCrane()
311
+ const c = rack.obtainCarrier('A')!
312
+ crane.simulatePick(c)
313
+
314
+ await rack.receiveAt('B', c)
315
+ rack.state.data.length.should.equal(1)
316
+ rack.state.data[0].cellId.should.equal('B')
317
+ rack.state.data[0].sku.should.equal('X')
318
+ rack.state.data[0].qty.should.equal(5)
319
+ })
320
+ })
321
+
322
+ // ── Group 5: 전체 round-trip 의 *불변식* — 매 단계 carrier 가 정확히 한 곳에만 있음 ──
323
+
324
+ describe('Carrier lifecycle: 불변식 — 매 단계에서 carrier 가 정확히 한 holder 에만', () => {
325
+ it('obtain → pick → place 전체 흐름 — carrier 가 두 곳에 동시 존재 X', async () => {
326
+ const rack = new FakeRack()
327
+ rack.state.data = [{ cellId: 'A' }]
328
+ const crane = new FakeCrane()
329
+
330
+ // 1. obtain — carrier 는 rack 자식
331
+ const c = rack.obtainCarrier('A')!
332
+ rack.components.includes(c).should.be.true()
333
+ crane.components.includes(c).should.be.false()
334
+
335
+ // 2. pick — carrier 가 crane 자식, rack 에서 사라짐
336
+ crane.simulatePick(c)
337
+ rack.components.includes(c).should.be.false()
338
+ crane.components.includes(c).should.be.true()
339
+
340
+ // 3. receiveAt('B') — carrier 가 어디에도 없음 (data 로 환원)
341
+ await rack.receiveAt('B', c)
342
+ rack.components.includes(c).should.be.false()
343
+ crane.components.includes(c).should.be.false()
344
+ ;(c.parent === null).should.be.true()
345
+ })
346
+
347
+ it('state.data 의 record count 보존 — 1개 시작 → 1개 끝 (cellId 만 다름)', async () => {
348
+ const rack = new FakeRack()
349
+ rack.state.data = [{ cellId: 'A', sku: 'X' }]
350
+ const crane = new FakeCrane()
351
+
352
+ const c = rack.obtainCarrier('A')!
353
+ rack.state.data.length.should.equal(0) // obtain 후 record 빠짐
354
+ crane.simulatePick(c)
355
+ rack.state.data.length.should.equal(0) // pick 으로 변동 없음
356
+ await rack.receiveAt('B', c)
357
+ rack.state.data.length.should.equal(1) // place 후 record 추가
358
+ rack.state.data[0].cellId.should.equal('B') // 새 cellId 로
359
+ rack.state.data[0].sku.should.equal('X') // 데이터 보존
360
+ })
361
+ })
@@ -0,0 +1,201 @@
1
+ /*
2
+ * 좌표 정렬 검증 — anchor (Crane fork 가 target 하는 점) 가 InstancedMesh stock 의
3
+ * 시각 위치와 *수학적으로* 일치하는지.
4
+ *
5
+ * 두 공식이 *동일 cellId 에 대해 동일 결과* 를 반환해야 함:
6
+ * - storage-rack-3d.rebuildStockMesh: InstancedMesh instance 의 위치
7
+ * - storage-rack._ensureCellAttachObject3d: anchor (SlotTarget._realObject.object3d) 위치
8
+ *
9
+ * 불일치 = Crane fork 가 stock 시각 위치와 어긋남.
10
+ *
11
+ * 추가:
12
+ * - SlotTarget.state.depth 가 stockD 와 일치 (Crane.resolveCarrierBottomY 가 shelf 정확히)
13
+ * - cellCenter2D 가 rack-local 좌표 반환 (rack.left/top 미포함)
14
+ */
15
+
16
+ import 'should'
17
+
18
+ // ── 공식 재현 (storage-rack.ts / storage-rack-3d.ts 에서 정확히 그대로) ────────
19
+
20
+ function computeStockPosition(
21
+ cell: { localPosition: { x: number; y: number; z: number } },
22
+ rackParams: { width: number; depth: number; height: number; bays: number; levels: number; shelfBase: number }
23
+ ) {
24
+ const { width, depth, height, bays, levels, shelfBase } = rackParams
25
+ const shelfZone = depth - shelfBase
26
+ const bayWidth = width / bays
27
+ const levelHeight = shelfZone / levels
28
+ const stockD = levelHeight * 0.7
29
+ const rowDepth = height
30
+
31
+ // storage-rack-3d.ts rebuildStockMesh 공식
32
+ const x = cell.localPosition.x + bayWidth / 2 - width / 2
33
+ const cellCenterY = cell.localPosition.y + levelHeight / 2 - depth / 2
34
+ const y = cellCenterY - levelHeight / 2 + stockD / 2
35
+ const z = cell.localPosition.z + rowDepth / 2 - height / 2
36
+ return { x, y, z }
37
+ }
38
+
39
+ function computeAnchorPosition(
40
+ cell: { localPosition: { x: number; y: number; z: number } },
41
+ rackParams: { width: number; depth: number; height: number; bays: number; levels: number; shelfBase: number }
42
+ ) {
43
+ const { width, depth, height, bays, levels, shelfBase } = rackParams
44
+ const shelfZone = depth - shelfBase
45
+ const bayWidth = width / bays
46
+ const levelHeight = shelfZone / levels
47
+ const stockD = levelHeight * 0.7
48
+ const rowDepth = height
49
+
50
+ // storage-rack.ts _ensureCellAttachObject3d 공식
51
+ const x = cell.localPosition.x + bayWidth / 2 - width / 2
52
+ const y = cell.localPosition.y - depth / 2 + stockD / 2
53
+ const z = cell.localPosition.z + rowDepth / 2 - height / 2
54
+ return { x, y, z }
55
+ }
56
+
57
+ // CellMap.grid 시뮬 — 셀의 localPosition 생성 (rack 의 bottom-left-front 원점)
58
+ function makeCell(bay: number, row: number, level: number, p: { bayWidth: number; rowDepth: number; levelHeight: number; shelfBase: number }) {
59
+ return {
60
+ bay,
61
+ row,
62
+ level,
63
+ localPosition: {
64
+ x: (bay - 1) * p.bayWidth,
65
+ y: p.shelfBase + (level - 1) * p.levelHeight,
66
+ z: (row - 1) * p.rowDepth
67
+ }
68
+ }
69
+ }
70
+
71
+ // ── Group 1: anchor 와 stock 위치 일치 ──────────────────────────────────────
72
+
73
+ describe('Coord: anchor (Crane target) 가 stock visual 위치와 일치', () => {
74
+ const rackParams = { width: 1000, depth: 3000, height: 600, bays: 5, levels: 4, shelfBase: 0 }
75
+ const bayWidth = rackParams.width / rackParams.bays
76
+ const levelHeight = (rackParams.depth - rackParams.shelfBase) / rackParams.levels
77
+ const rowDepth = rackParams.height
78
+
79
+ function makeCellHere(b: number, r: number, l: number) {
80
+ return makeCell(b, r, l, { bayWidth, rowDepth, levelHeight, shelfBase: rackParams.shelfBase })
81
+ }
82
+
83
+ it('bay=1, row=1, level=1 — anchor === stock', () => {
84
+ const c = makeCellHere(1, 1, 1)
85
+ const s = computeStockPosition(c, rackParams)
86
+ const a = computeAnchorPosition(c, rackParams)
87
+ a.should.deepEqual(s)
88
+ })
89
+
90
+ it('bay=3, row=1, level=4 (마지막 level) — anchor === stock', () => {
91
+ const c = makeCellHere(3, 1, 4)
92
+ const s = computeStockPosition(c, rackParams)
93
+ const a = computeAnchorPosition(c, rackParams)
94
+ a.should.deepEqual(s)
95
+ })
96
+
97
+ it('bay=5, row=1, level=4 (rack 의 마지막 셀) — anchor === stock', () => {
98
+ const c = makeCellHere(5, 1, 4)
99
+ const s = computeStockPosition(c, rackParams)
100
+ const a = computeAnchorPosition(c, rackParams)
101
+ a.should.deepEqual(s)
102
+ })
103
+
104
+ it('shelfBase > 0 — anchor === stock (10개 cell sampling)', () => {
105
+ const rp = { ...rackParams, shelfBase: 200 }
106
+ const lh = (rp.depth - rp.shelfBase) / rp.levels
107
+ for (let b = 1; b <= rp.bays; b++) {
108
+ for (let l = 1; l <= rp.levels; l++) {
109
+ const c = makeCell(b, 1, l, { bayWidth, rowDepth, levelHeight: lh, shelfBase: rp.shelfBase })
110
+ const s = computeStockPosition(c, rp)
111
+ const a = computeAnchorPosition(c, rp)
112
+ a.should.deepEqual(s)
113
+ }
114
+ }
115
+ })
116
+ })
117
+
118
+ // ── Group 2: Crane fork bottom = shelf (resolveCarrierBottomY 공식) ────────
119
+
120
+ describe('Coord: Crane fork bottom = shelf level', () => {
121
+ // resolveCarrierBottomY = centerY - depth/2
122
+ // 여기서 centerY = anchor.y, depth = SlotTarget.state.depth = stockD
123
+ it('anchor.y - stockD/2 = cellBottom (= shelf level)', () => {
124
+ const p = { width: 1000, depth: 3000, height: 600, bays: 5, levels: 4, shelfBase: 0 }
125
+ const lh = (p.depth - p.shelfBase) / p.levels
126
+ const stockD = lh * 0.7
127
+ const cell = makeCell(2, 1, 2, { bayWidth: p.width / p.bays, rowDepth: p.height, levelHeight: lh, shelfBase: p.shelfBase })
128
+ const anchor = computeAnchorPosition(cell, p)
129
+
130
+ const carrierBottom = anchor.y - stockD / 2
131
+ const cellBottom = cell.localPosition.y - p.depth / 2 // rack-local 의 cell 바닥 = shelf
132
+ carrierBottom.should.be.approximately(cellBottom, 0.001)
133
+ })
134
+
135
+ it('잘못된 case — depth = levelHeight 면 fork 가 shelf 보다 아래', () => {
136
+ // 이전 결함: SlotTarget.state.depth = levelHeight (전체 셀) 였을 때
137
+ const p = { width: 1000, depth: 3000, height: 600, bays: 5, levels: 4, shelfBase: 0 }
138
+ const lh = (p.depth - p.shelfBase) / p.levels
139
+ const stockD = lh * 0.7
140
+ const cell = makeCell(2, 1, 2, { bayWidth: p.width / p.bays, rowDepth: p.height, levelHeight: lh, shelfBase: p.shelfBase })
141
+ const anchor = computeAnchorPosition(cell, p)
142
+ const cellBottom = cell.localPosition.y - p.depth / 2
143
+
144
+ // BAD: depth = levelHeight
145
+ const badBottom = anchor.y - lh / 2
146
+ badBottom.should.not.be.approximately(cellBottom, 0.001)
147
+ // 차이 = (levelHeight - stockD)/2 = 0.15·levelHeight
148
+ Math.abs(badBottom - cellBottom).should.be.approximately((lh - stockD) / 2, 0.001)
149
+ })
150
+ })
151
+
152
+ // ── Group 3: cellCenter2D — rack-local 좌표 반환 ─────────────────────────────
153
+
154
+ describe('Coord: cellCenter2D 가 rack-local 좌표 반환 (rack.left/top 미포함)', () => {
155
+ function computeCellCenter2D(
156
+ cellId: string,
157
+ rs: { width: number; height: number; bays: number; left?: number; top?: number }
158
+ ) {
159
+ const bayIdx = Number(cellId.split('-')[0] ?? 0)
160
+ const bayWidth = rs.width / rs.bays
161
+ // 새 공식 (Plan A fix): rack-local — left/top 미포함
162
+ return {
163
+ x: bayIdx * bayWidth + bayWidth / 2,
164
+ y: rs.height / 2
165
+ }
166
+ }
167
+
168
+ it('rack.left = 100 이어도 cellCenter2D 는 *rack-local* 좌표', () => {
169
+ const rs = { width: 1000, height: 100, bays: 5, left: 100, top: 50 }
170
+ const c = computeCellCenter2D('2-0-0', rs)
171
+ // bay 2, bayWidth=200, center = 2*200 + 100 = 500 (rack-local, 0-based)
172
+ c.x.should.equal(500)
173
+ c.y.should.equal(50) // rack-local Y center = rack.height/2 = 50
174
+ // rack.left/top 가 포함되지 *않아야* 함 (이전 결함은 포함했음)
175
+ ;(c.x === rs.left).should.be.false()
176
+ })
177
+
178
+ it('bay 인덱스별 X 위치', () => {
179
+ const rs = { width: 1000, height: 100, bays: 5 }
180
+ const bw = rs.width / rs.bays
181
+ for (let b = 0; b < 5; b++) {
182
+ const c = computeCellCenter2D(`${b}-0-0`, rs)
183
+ c.x.should.equal(b * bw + bw / 2)
184
+ }
185
+ })
186
+ })
187
+
188
+ // ── Group 4: SlotTarget.state.depth = stockD ────────────────────────────────
189
+
190
+ describe('Coord: SlotTarget.state.depth = stockD (carrier 의 실제 depth)', () => {
191
+ it('cell.size.height * 0.7 = stockD = state.depth', () => {
192
+ // CellMap.grid 에서 size.height = levelHeight
193
+ const levelHeight = 600
194
+ const cellSizeHeight = levelHeight
195
+ const stockD = cellSizeHeight * 0.7
196
+ // Plan A 의 getSlotSize 가 반환하는 depth
197
+ stockD.should.equal(420)
198
+ // *전체 levelHeight 가 아님*
199
+ stockD.should.not.equal(levelHeight)
200
+ })
201
+ })