@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.
- package/CHANGELOG.md +29 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/box.js +18 -0
- package/dist/box.js.map +1 -1
- package/dist/crane-3d.d.ts +47 -2
- package/dist/crane-3d.js +246 -89
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +96 -12
- package/dist/crane.js +395 -100
- 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/pallet.d.ts +15 -0
- package/dist/pallet.js +38 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel-3d.js +22 -18
- package/dist/parcel-3d.js.map +1 -1
- package/dist/parcel.d.ts +4 -3
- package/dist/parcel.js +24 -5
- package/dist/parcel.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 +165 -29
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +253 -32
- package/dist/storage-rack.js +726 -66
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box.ts +18 -0
- package/src/crane-3d.ts +258 -93
- package/src/crane.ts +445 -110
- package/src/index.ts +3 -4
- package/src/pallet.ts +50 -1
- package/src/parcel-3d.ts +23 -18
- package/src/parcel.ts +24 -5
- 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 +182 -29
- package/src/storage-rack.ts +819 -67
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-crane-geometry.ts +167 -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-phase-h-carrier-pickable.ts +4 -3
- 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 +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
- 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 -70
- package/dist/storage-cell.js +0 -197
- 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 -247
- 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
|
+
})
|