@operato/scene-storage 10.0.0-beta.41 → 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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/crane.js +1 -1
- package/dist/crane.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/rack-grid-3d.d.ts +18 -7
- package/dist/rack-grid-3d.js +372 -69
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid-cell.d.ts +21 -72
- package/dist/rack-grid-cell.js +147 -243
- package/dist/rack-grid-cell.js.map +1 -1
- package/dist/rack-grid.d.ts +277 -56
- package/dist/rack-grid.js +1230 -695
- package/dist/rack-grid.js.map +1 -1
- package/dist/rack-materials.d.ts +9 -0
- package/dist/rack-materials.js +55 -0
- package/dist/rack-materials.js.map +1 -0
- package/dist/storage-rack-3d.d.ts +15 -0
- package/dist/storage-rack-3d.js +131 -30
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +242 -45
- package/dist/storage-rack.js +684 -106
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/crane.ts +1 -1
- package/src/index.ts +3 -4
- package/src/rack-grid-3d.ts +383 -80
- package/src/rack-grid-cell.ts +161 -305
- package/src/rack-grid.ts +1263 -762
- package/src/rack-materials.ts +61 -0
- package/src/storage-rack-3d.ts +144 -30
- package/src/storage-rack.ts +763 -111
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-external-to-rack.ts +461 -0
- package/test/test-mover-concurrent-bug.ts +304 -0
- package/test/test-mover-rollback.ts +290 -0
- package/test/test-r19-place-absorb.ts +174 -0
- package/test/test-rack-3d-attach-real.ts +301 -0
- package/test/test-rack-concurrent.ts +254 -0
- package/test/test-rack-edge-cases.ts +323 -0
- package/test/test-rack-grid-cell.ts +318 -0
- package/test/test-rack-grid-location.ts +657 -0
- package/test/test-real-3d-positioning.ts +158 -0
- package/test/test-slot-center-convention.ts +116 -0
- package/test/test-slot-target.ts +189 -0
- package/test/test-storage-rack-batched.ts +606 -0
- package/test/test-storage-rack-click.ts +329 -0
- package/test/test-storage-rack-slot-api.ts +357 -0
- package/test/test-toscene-convention.ts +162 -0
- package/test/test-user-scenario-sequential.ts +334 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/rack-column.d.ts +0 -35
- package/dist/rack-column.js +0 -258
- package/dist/rack-column.js.map +0 -1
- package/dist/rack-grid-helpers.d.ts +0 -28
- package/dist/rack-grid-helpers.js +0 -71
- package/dist/rack-grid-helpers.js.map +0 -1
- package/dist/rack-grid-location.d.ts +0 -37
- package/dist/rack-grid-location.js +0 -227
- package/dist/rack-grid-location.js.map +0 -1
- package/dist/storage-cell-3d.d.ts +0 -25
- package/dist/storage-cell-3d.js +0 -88
- package/dist/storage-cell-3d.js.map +0 -1
- package/dist/storage-cell.d.ts +0 -73
- package/dist/storage-cell.js +0 -215
- package/dist/storage-cell.js.map +0 -1
- package/src/rack-column.ts +0 -340
- package/src/rack-grid-helpers.ts +0 -77
- package/src/rack-grid-location.ts +0 -286
- package/src/storage-cell-3d.ts +0 -101
- package/src/storage-cell.ts +0 -267
- package/test/test-cell-position.ts +0 -105
- 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
|
+
})
|