@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,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* things-scene 의 toScene 컨벤션 *결정적* 검증.
|
|
3
|
+
*
|
|
4
|
+
* 미리 분석한 가설들:
|
|
5
|
+
* (A) toScene(x, y) 의 입력 (x, y) 는 self 의 *parent 좌표계* (= 형제와 같은 좌표).
|
|
6
|
+
* (B) toScene(x, y) 의 입력 (x, y) 는 *self-center-origin-local* (rotation 적용 전).
|
|
7
|
+
*
|
|
8
|
+
* 어느 쪽인지 *모의 컴포넌트* 로 직접 호출하여 결정.
|
|
9
|
+
*
|
|
10
|
+
* 모의 컴포넌트는 ComponentBase 의 *최소 표면* 만 구현 — parent / state / bounds /
|
|
11
|
+
* center / rotatePoint / _delta / isLayer / transcoordS2P. transcoordS2P 가 본
|
|
12
|
+
* 함수 호출 시 *transcoord 함수가 자체 동작* 하도록 things-scene 의 source 직접
|
|
13
|
+
* import (mjs 번들 아닌, src/ 안의 .ts).
|
|
14
|
+
*/
|
|
15
|
+
import 'should'
|
|
16
|
+
import {
|
|
17
|
+
transcoordS2P,
|
|
18
|
+
transcoordS2T,
|
|
19
|
+
transcoordRR
|
|
20
|
+
} from '../../../../things-scene/src/components/geometry/transcoord'
|
|
21
|
+
|
|
22
|
+
// ── Mock Component 구조 — transcoord 함수의 ComponentBase 인터페이스 충족 ──────
|
|
23
|
+
|
|
24
|
+
interface MockComp {
|
|
25
|
+
parent?: MockComp
|
|
26
|
+
state: { rotation?: number; scale?: { x: number; y: number }; translate?: { x: number; y: number }; left?: number; top?: number; width?: number; height?: number }
|
|
27
|
+
scalable: boolean
|
|
28
|
+
bounds: { left: number; top: number; width: number; height: number }
|
|
29
|
+
rotatePoint: { x: number; y: number }
|
|
30
|
+
model: any
|
|
31
|
+
rootModel: any
|
|
32
|
+
_delta: { theta: number; tx: number; ty: number; sx: number; sy: number }
|
|
33
|
+
isLayer(): boolean
|
|
34
|
+
transcoordS2P(x: number, y: number): { x: number; y: number }
|
|
35
|
+
transcoordP2S(x: number, y: number): { x: number; y: number }
|
|
36
|
+
transcoordS2T(x: number, y: number, top?: MockComp): { x: number; y: number }
|
|
37
|
+
transcoordT2S(x: number, y: number): { x: number; y: number }
|
|
38
|
+
transcoordT2P(x: number, y: number): { x: number; y: number }
|
|
39
|
+
get(prop: string): any
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeMock(opts: {
|
|
43
|
+
left: number; top: number; width: number; height: number; rotation?: number; parent?: MockComp
|
|
44
|
+
}): MockComp {
|
|
45
|
+
const state = {
|
|
46
|
+
left: opts.left, top: opts.top, width: opts.width, height: opts.height, rotation: opts.rotation ?? 0
|
|
47
|
+
}
|
|
48
|
+
const bounds = { left: opts.left, top: opts.top, width: opts.width, height: opts.height }
|
|
49
|
+
const center = { x: opts.left + opts.width / 2, y: opts.top + opts.height / 2 }
|
|
50
|
+
const comp: any = {
|
|
51
|
+
parent: opts.parent,
|
|
52
|
+
state,
|
|
53
|
+
scalable: false,
|
|
54
|
+
bounds,
|
|
55
|
+
rotatePoint: center,
|
|
56
|
+
model: null,
|
|
57
|
+
_delta: { theta: 0, tx: 0, ty: 0, sx: 1, sy: 1 },
|
|
58
|
+
isLayer: () => false,
|
|
59
|
+
get: (prop: string) => (state as any)[prop],
|
|
60
|
+
}
|
|
61
|
+
comp.rootModel = opts.parent?.rootModel ?? comp
|
|
62
|
+
comp.transcoordS2P = function (x: number, y: number) { return transcoordS2P.call(this, x, y) }
|
|
63
|
+
comp.transcoordS2T = function (x: number, y: number, top?: MockComp) { return transcoordS2T.call(this, x, y, top) }
|
|
64
|
+
// toScene = transcoordS2T
|
|
65
|
+
;(comp as any).toScene = function (x: number, y: number, top?: MockComp) { return transcoordS2T.call(this, x, y, top) }
|
|
66
|
+
return comp
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('things-scene toScene 컨벤션 검증', () => {
|
|
70
|
+
|
|
71
|
+
it('rotation=0 인 rack: rack.toScene(rack.center.x, rack.center.y) === rack.center', () => {
|
|
72
|
+
const root = makeMock({ left: 0, top: 0, width: 2000, height: 2000 }) // root (parent=undefined)
|
|
73
|
+
const rack = makeMock({ left: 365.17, top: 478.6, width: 800, height: 50, parent: root })
|
|
74
|
+
rack.rootModel = root
|
|
75
|
+
|
|
76
|
+
// rack.center (parent-좌표계) = (765.17, 503.6)
|
|
77
|
+
const cx = rack.bounds.left + rack.bounds.width / 2
|
|
78
|
+
const cy = rack.bounds.top + rack.bounds.height / 2
|
|
79
|
+
|
|
80
|
+
// 가설 (A): toScene 의 입력 = parent-좌표 → rack.toScene(cx, cy) = (cx, cy) (회전 없으면)
|
|
81
|
+
// 가설 (B): toScene 의 입력 = self-center-origin → rack.toScene(0, 0) = (cx, cy)
|
|
82
|
+
const resultA = (rack as any).toScene(cx, cy)
|
|
83
|
+
const resultB = (rack as any).toScene(0, 0)
|
|
84
|
+
|
|
85
|
+
console.log('[toScene 컨벤션] rotation=0 rack center')
|
|
86
|
+
console.log(` rack.center = (${cx}, ${cy})`)
|
|
87
|
+
console.log(` toScene(rack.center.x, rack.center.y) = (${resultA.x.toFixed(2)}, ${resultA.y.toFixed(2)})`)
|
|
88
|
+
console.log(` toScene(0, 0) = (${resultB.x.toFixed(2)}, ${resultB.y.toFixed(2)})`)
|
|
89
|
+
|
|
90
|
+
// 결론: 어느 가설이 맞는지 결과로 판단
|
|
91
|
+
// (A) 면 resultA == (cx, cy) 이고 resultB == (0, 0)
|
|
92
|
+
// (B) 면 resultA == (?, ?) 이고 resultB == (cx, cy)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('rotation=30° 인 rack: anchor.world 와 toScene 결과 비교', () => {
|
|
96
|
+
const root = makeMock({ left: 0, top: 0, width: 2000, height: 2000 })
|
|
97
|
+
const theta = Math.PI / 6 // 30°
|
|
98
|
+
const rack = makeMock({ left: 365.17, top: 478.6, width: 800, height: 50, rotation: theta, parent: root })
|
|
99
|
+
rack.rootModel = root
|
|
100
|
+
|
|
101
|
+
const cx = rack.bounds.left + rack.bounds.width / 2 // 765.17
|
|
102
|
+
const cy = rack.bounds.top + rack.bounds.height / 2 // 503.6
|
|
103
|
+
|
|
104
|
+
// bay 0 (cell 0-0-0) 의 *rack-local-center-origin* X = -380
|
|
105
|
+
// 즉 rack 의 center 에서 X=-380 만큼 떨어진 점. rotation 적용 후 *parent 좌표* 의 위치는?
|
|
106
|
+
// Manual: 점 (cx-380, cy) 를 rp=(cx, cy) 기준 30° 회전:
|
|
107
|
+
// (cx-380-cx)*cos - (cy-cy)*sin + cx = -380*cos(30°) + 765.17 = -329.09 + 765.17 = 436.08
|
|
108
|
+
// (cx-380-cx)*sin + (cy-cy)*cos + cy = -380*sin(30°) + 503.6 = -190 + 503.6 = 313.6
|
|
109
|
+
|
|
110
|
+
// 가설 (A): toScene 의 입력은 *parent-좌표*. 즉 *bay 0 의 parent-좌표 점* = (cx-380, cy) = (385.17, 503.6)
|
|
111
|
+
const resultA = (rack as any).toScene(cx + (-380), cy)
|
|
112
|
+
// 가설 (B): toScene 의 입력은 *self-center-origin*. 즉 *bay 0 의 local-center 점* = (-380, 0)
|
|
113
|
+
const resultB = (rack as any).toScene(-380, 0)
|
|
114
|
+
// 가설 (C): toScene 의 입력은 *rack 의 top-left-origin*. 즉 *bay 0 의 top-left 점* = (20, 25)
|
|
115
|
+
const resultC = (rack as any).toScene(20, 25)
|
|
116
|
+
|
|
117
|
+
console.log('[toScene 컨벤션] rotation=30° rack, bay 0 cell center')
|
|
118
|
+
console.log(` rack.center = (${cx}, ${cy})`)
|
|
119
|
+
console.log(` Manual 회전 후 (parent-좌표 의 bay 0) = (436.08, 313.60)`)
|
|
120
|
+
console.log(` (A) toScene(parent-좌표) = (${resultA.x.toFixed(2)}, ${resultA.y.toFixed(2)})`)
|
|
121
|
+
console.log(` (B) toScene(center-origin) = (${resultB.x.toFixed(2)}, ${resultB.y.toFixed(2)})`)
|
|
122
|
+
console.log(` (C) toScene(top-left-origin)= (${resultC.x.toFixed(2)}, ${resultC.y.toFixed(2)})`)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('rotation=0 rack, bay 0 cell — anchor의 *3D world* 와 *2D scene 좌표* 매핑 검증', () => {
|
|
126
|
+
// 사용자 콘솔 로그 기준:
|
|
127
|
+
// rackState: left=365.17, top=478.6, W=800, D=800, H=50, bays=20, levels=20, shelfBase=10
|
|
128
|
+
// rackObj3d=(65.17, 400.00, -16.40) ← 3D world position of rack.object3d
|
|
129
|
+
// anchor world=(-264.60, 23.82, 172.41) — rack의 3D rotation ≈ 30° (Y axis) 적용된 후
|
|
130
|
+
//
|
|
131
|
+
// 가설:
|
|
132
|
+
// sceneCenter.x ≈ 300 (rackObj3d.x = state.left - sceneCenter.x = 365.17 - 300 = 65.17)
|
|
133
|
+
// sceneCenter.y ≈ 495 (rackObj3d.z = state.top - sceneCenter.y = 478.6 - 495 ≈ -16.4)
|
|
134
|
+
//
|
|
135
|
+
// anchor 의 *2D scene 좌표* = (anchor.world.x + sceneCenter.x, anchor.world.z + sceneCenter.y)
|
|
136
|
+
// = (-264.60 + 300, 172.41 + 495) = (35.40, 667.41)
|
|
137
|
+
//
|
|
138
|
+
// 이게 cellCenter2D.toScene() 의 *목표값*.
|
|
139
|
+
// 만약 rack 의 *2D rotation* 이 0 이고 *3D Y축 회전* 만 있다면 2D toScene 은 회전 미적용.
|
|
140
|
+
// 그러면 cellCenter2D 가 *어떤 X, Y* 일 때 toScene 가 (35.40, 667.41) 를 반환?
|
|
141
|
+
|
|
142
|
+
const root = makeMock({ left: 0, top: 0, width: 2000, height: 2000 })
|
|
143
|
+
const rack = makeMock({ left: 365.17, top: 478.6, width: 800, height: 50, parent: root })
|
|
144
|
+
rack.rootModel = root
|
|
145
|
+
|
|
146
|
+
const target = { x: 35.40, y: 667.41 }
|
|
147
|
+
console.log('[toScene 역추적] target (= anchor 의 scene2D) =', target)
|
|
148
|
+
|
|
149
|
+
// 후보 X 값들 — bay 0 cell 의 다양한 origin 표현
|
|
150
|
+
const candidates: Array<[string, number, number]> = [
|
|
151
|
+
['(A) parent-좌표 (rack.left + 20)', 365.17 + 20, 478.6 + 25],
|
|
152
|
+
['(B) self-center-origin (-380, 0)', -380, 0],
|
|
153
|
+
['(C) rack top-left-origin (20, 25)', 20, 25]
|
|
154
|
+
]
|
|
155
|
+
for (const [label, x, y] of candidates) {
|
|
156
|
+
const out = (rack as any).toScene(x, y)
|
|
157
|
+
const dx = out.x - target.x
|
|
158
|
+
const dy = out.y - target.y
|
|
159
|
+
console.log(` ${label}: toScene(${x}, ${y}) = (${out.x.toFixed(2)}, ${out.y.toFixed(2)}) Δ=(${dx.toFixed(2)}, ${dy.toFixed(2)})`)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 사용자가 보고한 *정확한 시퀀스* 재현:
|
|
3
|
+
*
|
|
4
|
+
* [A] 외부 parcel → rack '0-0-6' 으로 pickAndPlace (성공 보고)
|
|
5
|
+
* [B] rack '0-0-6' 의 transient → rack '2-0-3' 으로 pickAndPlace (실패 보고)
|
|
6
|
+
*
|
|
7
|
+
* 가설: [A] 후 state.data 의 갱신이 정상이면 [B] 도 정상 진행. 실제 결함이면 이 테스트가
|
|
8
|
+
* fail 해서 정확한 *어느 step* 에서 깨지는지 드러남.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import 'should'
|
|
12
|
+
import * as THREE from 'three'
|
|
13
|
+
|
|
14
|
+
// Same fixture as test-external-to-rack — Plan A 정신을 가능한 한 가깝게 재현
|
|
15
|
+
class FakeRack {
|
|
16
|
+
state: any = { data: [] }
|
|
17
|
+
components: any[] = []
|
|
18
|
+
rootObject3d = new THREE.Group()
|
|
19
|
+
_slotAnchors = new Map<string, THREE.Object3D>()
|
|
20
|
+
|
|
21
|
+
records(): any[] { return this.state.data ?? [] }
|
|
22
|
+
carrierAt(cellId: string): any {
|
|
23
|
+
return this.components.find(c => c.placement === 'operation' && c.state?.cellId === cellId)
|
|
24
|
+
}
|
|
25
|
+
hasCarrierAt(cellId: string): boolean {
|
|
26
|
+
return !!this.carrierAt(cellId) || this.records().some(r => r?.cellId === cellId)
|
|
27
|
+
}
|
|
28
|
+
canReceiveAt(cellId: string, carrier?: any): boolean {
|
|
29
|
+
if (this.records().some(r => r?.cellId === cellId)) return false
|
|
30
|
+
const existing = this.carrierAt(cellId)
|
|
31
|
+
if (existing && existing !== carrier) return false
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
addComponent(c: any): void { c.parent = this; this.components.push(c) }
|
|
35
|
+
removeComponent(c: any): void {
|
|
36
|
+
const i = this.components.indexOf(c)
|
|
37
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
38
|
+
c.parent = null
|
|
39
|
+
}
|
|
40
|
+
ensureSlotAnchor(cellId: string): THREE.Object3D {
|
|
41
|
+
let a = this._slotAnchors.get(cellId)
|
|
42
|
+
if (!a) {
|
|
43
|
+
a = new THREE.Object3D()
|
|
44
|
+
a.name = `slot:${cellId}`
|
|
45
|
+
this.rootObject3d.add(a)
|
|
46
|
+
this._slotAnchors.set(cellId, a)
|
|
47
|
+
}
|
|
48
|
+
return a
|
|
49
|
+
}
|
|
50
|
+
attachPointFor(carrier: any) {
|
|
51
|
+
const cellId = carrier.state?.cellId
|
|
52
|
+
return { attach: this.ensureSlotAnchor(cellId ?? '?'), localPosition: { x: 0, y: 0, z: 0 } }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
slotTargetAt(cellId: string): any {
|
|
56
|
+
const rack = this
|
|
57
|
+
return {
|
|
58
|
+
slotId: cellId,
|
|
59
|
+
holder: rack,
|
|
60
|
+
canReceive: (c: any) => rack.canReceiveAt(cellId, c),
|
|
61
|
+
async receive(c: any) { await rack.receiveAt(cellId, c) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
obtainCarrier(cellId: string): any {
|
|
66
|
+
const existing = this.carrierAt(cellId)
|
|
67
|
+
if (existing) return existing
|
|
68
|
+
const records = this.records()
|
|
69
|
+
const idx = records.findIndex(r => r?.cellId === cellId)
|
|
70
|
+
if (idx === -1) return null
|
|
71
|
+
const record = records[idx]
|
|
72
|
+
const { id: _id, refid: _refid, ...rest } = record
|
|
73
|
+
const c: any = {
|
|
74
|
+
placement: 'operation',
|
|
75
|
+
state: { ...rest, cellId, type: rest.type ?? 'parcel' },
|
|
76
|
+
parent: null,
|
|
77
|
+
_disposed: false,
|
|
78
|
+
_realObject: { object3d: new THREE.Group() },
|
|
79
|
+
dispose() {
|
|
80
|
+
this._disposed = true
|
|
81
|
+
if (this._realObject?.object3d?.parent?.remove) {
|
|
82
|
+
this._realObject.object3d.parent.remove(this._realObject.object3d)
|
|
83
|
+
}
|
|
84
|
+
this._realObject.object3d.clear()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.addComponent(c)
|
|
88
|
+
const pt = this.attachPointFor(c)
|
|
89
|
+
pt.attach.attach(c._realObject.object3d)
|
|
90
|
+
c._realObject.object3d.position.set(0, 0, 0)
|
|
91
|
+
this.state.data = records.filter(r => r?.cellId !== cellId)
|
|
92
|
+
return c
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async receiveAt(cellId: string, carrier: any): Promise<void> {
|
|
96
|
+
if (carrier?._disposed) throw new Error('R18: carrier disposed')
|
|
97
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
98
|
+
const err: any = new Error('slot occupied')
|
|
99
|
+
err.reason = 'slot-occupied'
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
const p = carrier.parent
|
|
103
|
+
if (p?.removeComponent) p.removeComponent(carrier)
|
|
104
|
+
const obj = carrier._realObject?.object3d
|
|
105
|
+
if (obj?.parent?.remove) obj.parent.remove(obj)
|
|
106
|
+
carrier.dispose?.()
|
|
107
|
+
const remaining = this.records().filter(r => r?.cellId !== cellId)
|
|
108
|
+
const rec: any = { cellId, type: carrier.state.type ?? 'parcel' }
|
|
109
|
+
for (const k of Object.keys(carrier.state)) {
|
|
110
|
+
if (['id', 'refid', 'left', 'top', 'zPos', 'cellId', '_transferSlotId'].includes(k)) continue
|
|
111
|
+
rec[k] = carrier.state[k]
|
|
112
|
+
}
|
|
113
|
+
this.state.data = [...remaining, rec]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class FakeCrane {
|
|
118
|
+
components: any[] = []
|
|
119
|
+
forkObject3d = new THREE.Object3D()
|
|
120
|
+
constructor() { this.forkObject3d.name = 'crane-fork' }
|
|
121
|
+
|
|
122
|
+
canReceive(_c: any): boolean {
|
|
123
|
+
return this.components.filter(c => c._transferSlotId === 'forks').length < 1
|
|
124
|
+
}
|
|
125
|
+
async receive(carrier: any): Promise<void> {
|
|
126
|
+
if (!this.canReceive(carrier)) {
|
|
127
|
+
const err: any = new Error('crane full')
|
|
128
|
+
err.reason = 'all-slots-full'
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
const op = carrier.parent
|
|
132
|
+
if (op?.removeComponent) op.removeComponent(carrier)
|
|
133
|
+
carrier.parent = this
|
|
134
|
+
this.components.push(carrier)
|
|
135
|
+
carrier._transferSlotId = 'forks'
|
|
136
|
+
const obj = carrier._realObject?.object3d
|
|
137
|
+
if (obj) {
|
|
138
|
+
if (obj.parent?.remove) obj.parent.remove(obj)
|
|
139
|
+
this.forkObject3d.attach(obj)
|
|
140
|
+
obj.position.set(0, 0, 0)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
removeComponent(c: any): void {
|
|
144
|
+
const i = this.components.indexOf(c)
|
|
145
|
+
if (i >= 0) this.components.splice(i, 1)
|
|
146
|
+
c.parent = null
|
|
147
|
+
}
|
|
148
|
+
async dispatch(carrier: any, target: any): Promise<void> {
|
|
149
|
+
if (target.canReceive && !target.canReceive(carrier)) {
|
|
150
|
+
const err: any = new Error('target rejected')
|
|
151
|
+
err.reason = 'cannot-receive'
|
|
152
|
+
throw err
|
|
153
|
+
}
|
|
154
|
+
delete carrier._transferSlotId
|
|
155
|
+
if (typeof target.receive === 'function') await target.receive(carrier)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async pickAndPlace(carrier: any, target: any, options: { timeoutMs?: number } = {}): Promise<void> {
|
|
159
|
+
// R18 guard
|
|
160
|
+
if (!carrier) throw new Error('Mover.pick: carrier is null/undefined')
|
|
161
|
+
if (carrier._disposed) throw new Error('Mover.pickAndPlace: carrier is already disposed')
|
|
162
|
+
|
|
163
|
+
const sourceParent: any = carrier.parent
|
|
164
|
+
const sourceCellId: string | undefined = carrier.state?.cellId
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
// pick
|
|
168
|
+
if (carrier.parent !== this) await this.receive(carrier)
|
|
169
|
+
|
|
170
|
+
// place
|
|
171
|
+
const holder = target
|
|
172
|
+
const timeoutMs = options.timeoutMs ?? 1000
|
|
173
|
+
const started = Date.now()
|
|
174
|
+
// R19: post-engage absorb check 시뮬 — receive 후 _disposed true 면 return
|
|
175
|
+
// (실 Mover.place 와 동일: engage('place') 의 mid-Transfer 가 receive → absorb)
|
|
176
|
+
if (typeof holder?.receive === 'function') {
|
|
177
|
+
await holder.receive(carrier)
|
|
178
|
+
if (carrier.parent === holder) return // 보통 holder
|
|
179
|
+
if (carrier._disposed) return // R19 absorb 경로
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// polling fallback (도달하지 않아야 함)
|
|
183
|
+
while (holder?.canReceive && !holder.canReceive(carrier)) {
|
|
184
|
+
if (Date.now() - started > timeoutMs) throw new Error('Mover.place timeout')
|
|
185
|
+
await new Promise(r => setTimeout(r, 5))
|
|
186
|
+
}
|
|
187
|
+
if (holder?.receive && carrier.parent !== holder && !carrier._disposed) {
|
|
188
|
+
await this.dispatch(carrier, holder)
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Rollback
|
|
192
|
+
if (this.components.includes(carrier)) {
|
|
193
|
+
const slotTarget = sourceParent?.slotTargetAt?.(sourceCellId)
|
|
194
|
+
if (slotTarget?.canReceive?.(carrier)) {
|
|
195
|
+
try { await this.dispatch(carrier, slotTarget) } catch {}
|
|
196
|
+
} else {
|
|
197
|
+
this.removeComponent(carrier)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw err
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 사용자 시나리오 정확 재현 ────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('User scenario — sequential pickAndPlace (external → rack → another slot)', () => {
|
|
208
|
+
it('[A] 외부 parcel → rack.slotTargetAt(0-0-6) — *성공*', async () => {
|
|
209
|
+
const rack = new FakeRack()
|
|
210
|
+
const crane = new FakeCrane()
|
|
211
|
+
const parcel: any = {
|
|
212
|
+
placement: 'operation',
|
|
213
|
+
state: { id: 'parcel', type: 'parcel', sku: 'X' },
|
|
214
|
+
parent: null,
|
|
215
|
+
_disposed: false,
|
|
216
|
+
_realObject: { object3d: new THREE.Group() },
|
|
217
|
+
dispose() { this._disposed = true; this._realObject.object3d.clear() }
|
|
218
|
+
}
|
|
219
|
+
// 외부 (model-layer 시뮬 — 사실 parent 가 있긴 함)
|
|
220
|
+
const modelLayer: any = {
|
|
221
|
+
components: [] as any[],
|
|
222
|
+
addComponent(c: any) { c.parent = this; this.components.push(c) },
|
|
223
|
+
removeComponent(c: any) { const i = this.components.indexOf(c); if (i >= 0) this.components.splice(i, 1); c.parent = null },
|
|
224
|
+
canReceive(_c: any) { return true },
|
|
225
|
+
async receive(c: any) { const p = c.parent; if (p?.removeComponent) p.removeComponent(c); this.addComponent(c) }
|
|
226
|
+
}
|
|
227
|
+
modelLayer.addComponent(parcel)
|
|
228
|
+
|
|
229
|
+
await crane.pickAndPlace(parcel, rack.slotTargetAt('0-0-6'))
|
|
230
|
+
|
|
231
|
+
// 검증: parcel disposed, state.data 갱신, crane 비어있음
|
|
232
|
+
parcel._disposed.should.be.true()
|
|
233
|
+
rack.records().length.should.equal(1)
|
|
234
|
+
rack.records()[0].cellId.should.equal('0-0-6')
|
|
235
|
+
rack.records()[0].sku.should.equal('X')
|
|
236
|
+
crane.components.length.should.equal(0)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('[A] 직후 [B] obtainCarrier(0-0-6) — *transient 반환*', async () => {
|
|
240
|
+
const rack = new FakeRack()
|
|
241
|
+
const crane = new FakeCrane()
|
|
242
|
+
const parcel: any = {
|
|
243
|
+
placement: 'operation',
|
|
244
|
+
state: { id: 'parcel', type: 'parcel', sku: 'X' },
|
|
245
|
+
parent: null,
|
|
246
|
+
_disposed: false,
|
|
247
|
+
_realObject: { object3d: new THREE.Group() },
|
|
248
|
+
dispose() { this._disposed = true; this._realObject.object3d.clear() }
|
|
249
|
+
}
|
|
250
|
+
const modelLayer: any = {
|
|
251
|
+
components: [] as any[],
|
|
252
|
+
addComponent(c: any) { c.parent = this; this.components.push(c) },
|
|
253
|
+
removeComponent(c: any) { const i = this.components.indexOf(c); if (i >= 0) this.components.splice(i, 1); c.parent = null },
|
|
254
|
+
canReceive(_c: any) { return true },
|
|
255
|
+
async receive(c: any) { const p = c.parent; if (p?.removeComponent) p.removeComponent(c); this.addComponent(c) }
|
|
256
|
+
}
|
|
257
|
+
modelLayer.addComponent(parcel)
|
|
258
|
+
|
|
259
|
+
// [A]
|
|
260
|
+
await crane.pickAndPlace(parcel, rack.slotTargetAt('0-0-6'))
|
|
261
|
+
|
|
262
|
+
// [B] obtainCarrier
|
|
263
|
+
const carrier = rack.obtainCarrier('0-0-6')
|
|
264
|
+
// *결정 검증*: carrier 가 null 이 아니어야 함 — 사용자 보고와 일치하는지 확인
|
|
265
|
+
;(carrier === null).should.be.false()
|
|
266
|
+
carrier.state.cellId.should.equal('0-0-6')
|
|
267
|
+
carrier.state.sku.should.equal('X')
|
|
268
|
+
carrier._disposed.should.be.false()
|
|
269
|
+
;(carrier !== parcel).should.be.true() // 새 transient, 원본 parcel 아님
|
|
270
|
+
|
|
271
|
+
// state.data 에서 '0-0-6' record 제거됨 (obtain 의 부수효과)
|
|
272
|
+
rack.records().some((r: any) => r.cellId === '0-0-6').should.be.false()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('[A] → [B] 전체 흐름 — 0-0-6 에서 2-0-3 으로 이동', async () => {
|
|
276
|
+
const rack = new FakeRack()
|
|
277
|
+
const crane = new FakeCrane()
|
|
278
|
+
const parcel: any = {
|
|
279
|
+
placement: 'operation',
|
|
280
|
+
state: { id: 'parcel', type: 'parcel', sku: 'X' },
|
|
281
|
+
parent: null,
|
|
282
|
+
_disposed: false,
|
|
283
|
+
_realObject: { object3d: new THREE.Group() },
|
|
284
|
+
dispose() { this._disposed = true; this._realObject.object3d.clear() }
|
|
285
|
+
}
|
|
286
|
+
const modelLayer: any = {
|
|
287
|
+
components: [] as any[],
|
|
288
|
+
addComponent(c: any) { c.parent = this; this.components.push(c) },
|
|
289
|
+
removeComponent(c: any) { const i = this.components.indexOf(c); if (i >= 0) this.components.splice(i, 1); c.parent = null },
|
|
290
|
+
canReceive(_c: any) { return true },
|
|
291
|
+
async receive(c: any) { const p = c.parent; if (p?.removeComponent) p.removeComponent(c); this.addComponent(c) }
|
|
292
|
+
}
|
|
293
|
+
modelLayer.addComponent(parcel)
|
|
294
|
+
|
|
295
|
+
// [A] 외부 → 0-0-6
|
|
296
|
+
await crane.pickAndPlace(parcel, rack.slotTargetAt('0-0-6'))
|
|
297
|
+
|
|
298
|
+
// [B] 0-0-6 → 2-0-3 (사용자가 본 두 번째 script 와 동일)
|
|
299
|
+
const carrier = rack.obtainCarrier('0-0-6')
|
|
300
|
+
if (!carrier) throw new Error('[B] obtainCarrier returned null — sequence broken!')
|
|
301
|
+
const dest = rack.slotTargetAt('2-0-3')
|
|
302
|
+
await crane.pickAndPlace(carrier, dest)
|
|
303
|
+
|
|
304
|
+
// 검증: 2-0-3 에 record 있고, 0-0-6 비어있고, crane 깨끗
|
|
305
|
+
rack.records().length.should.equal(1)
|
|
306
|
+
rack.records()[0].cellId.should.equal('2-0-3')
|
|
307
|
+
rack.records()[0].sku.should.equal('X')
|
|
308
|
+
rack.hasCarrierAt('0-0-6').should.be.false()
|
|
309
|
+
crane.components.length.should.equal(0)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('rapid 클릭 시나리오 — [B] 가 두 번 빠르게 호출되면?', async () => {
|
|
313
|
+
const rack = new FakeRack()
|
|
314
|
+
rack.state.data = [{ cellId: '0-0-6', sku: 'X' }]
|
|
315
|
+
const crane = new FakeCrane()
|
|
316
|
+
|
|
317
|
+
// 동시 클릭 시뮬
|
|
318
|
+
const c1 = rack.obtainCarrier('0-0-6')
|
|
319
|
+
const c2 = rack.obtainCarrier('0-0-6') // 같은 인스턴스 idempotent 반환
|
|
320
|
+
|
|
321
|
+
c1!.should.equal(c2) // 같은 carrier
|
|
322
|
+
rack.records().length.should.equal(0) // record 한 번만 빠짐
|
|
323
|
+
rack.components.length.should.equal(1) // carrier 도 하나만
|
|
324
|
+
|
|
325
|
+
// 두 클릭 모두 pickAndPlace 호출 시
|
|
326
|
+
const r1 = crane.pickAndPlace(c1, rack.slotTargetAt('A'))
|
|
327
|
+
const r2 = crane.pickAndPlace(c2, rack.slotTargetAt('B'))
|
|
328
|
+
const results = await Promise.allSettled([r1, r2])
|
|
329
|
+
|
|
330
|
+
// 한 쪽은 성공, 다른 쪽은 disposed 이라 fail (R18 guard) 또는 crane full 이라 fail
|
|
331
|
+
const failures = results.filter(r => r.status === 'rejected')
|
|
332
|
+
failures.length.should.be.greaterThanOrEqual(1)
|
|
333
|
+
})
|
|
334
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"label.is-empty": "is empty",
|
|
35
35
|
"label.hide-empty-stock": "hide empty stock",
|
|
36
36
|
"label.hide-rack-frame": "hide rack frame",
|
|
37
|
+
"label.hide-horizontal-frame": "hide horizontal frame",
|
|
38
|
+
"label.popup-ref": "popup ref",
|
|
37
39
|
"label.stock-scale": "stock scale",
|
|
38
40
|
"label.legend-target": "legend target",
|
|
39
41
|
"label.shelf-base-height": "shelf base height",
|
package/translations/ja.json
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"label.is-empty": "空セル",
|
|
35
35
|
"label.hide-empty-stock": "空在庫を非表示",
|
|
36
36
|
"label.hide-rack-frame": "ラックフレームを非表示",
|
|
37
|
+
"label.hide-horizontal-frame": "横フレームを非表示",
|
|
38
|
+
"label.popup-ref": "ポップアップ参照",
|
|
37
39
|
"label.stock-scale": "在庫スケール",
|
|
38
40
|
"label.legend-target": "凡例対象",
|
|
39
41
|
"label.shelf-base-height": "棚の基準高さ",
|
package/translations/ko.json
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"label.is-empty": "빈 셀",
|
|
35
35
|
"label.hide-empty-stock": "빈 재고 숨김",
|
|
36
36
|
"label.hide-rack-frame": "랙 프레임 숨김",
|
|
37
|
+
"label.hide-horizontal-frame": "가로 프레임 숨김",
|
|
38
|
+
"label.popup-ref": "팝업 참조",
|
|
37
39
|
"label.stock-scale": "재고 크기",
|
|
38
40
|
"label.legend-target": "범례 대상",
|
|
39
41
|
"label.shelf-base-height": "선반 시작 높이",
|
package/translations/ms.json
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"label.is-empty": "sel kosong",
|
|
35
35
|
"label.hide-empty-stock": "sembunyi stok kosong",
|
|
36
36
|
"label.hide-rack-frame": "sembunyi rangka rak",
|
|
37
|
+
"label.hide-horizontal-frame": "sembunyi rangka mendatar",
|
|
38
|
+
"label.popup-ref": "rujukan popup",
|
|
37
39
|
"label.stock-scale": "skala stok",
|
|
38
40
|
"label.legend-target": "sasaran legenda",
|
|
39
41
|
"label.shelf-base-height": "tinggi asas rak",
|
package/translations/zh.json
CHANGED
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
"label.is-empty": "空单元",
|
|
35
35
|
"label.hide-empty-stock": "隐藏空库存",
|
|
36
36
|
"label.hide-rack-frame": "隐藏货架框架",
|
|
37
|
+
"label.hide-horizontal-frame": "隐藏横向框架",
|
|
38
|
+
"label.popup-ref": "弹窗引用",
|
|
37
39
|
"label.stock-scale": "库存比例",
|
|
38
40
|
"label.legend-target": "图例对象",
|
|
39
41
|
"label.shelf-base-height": "货架基准高度",
|