@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,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
+ })
@@ -34,15 +34,21 @@
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",
41
+ "label.shelf-base-height": "shelf base height",
39
42
 
40
43
  "label.status": "status",
41
- "label.target": "target",
44
+ "label.simulate": "simulate",
45
+ "label.carriage-position": "carriage position",
46
+ "label.carriage-width": "carriage width",
42
47
  "label.carriage-height": "carriage height",
43
48
  "label.fork-length": "fork length",
44
49
  "label.fork-extension": "fork extension",
45
50
  "label.fork-lift": "fork lift",
51
+ "label.pocket-depth": "pocket depth",
46
52
 
47
53
  "option.idle": "idle",
48
54
  "option.moving": "moving",
@@ -34,15 +34,21 @@
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": "凡例対象",
41
+ "label.shelf-base-height": "棚の基準高さ",
39
42
 
40
43
  "label.status": "状態",
41
- "label.target": "ターゲット",
44
+ "label.simulate": "シミュレーション",
45
+ "label.carriage-position": "キャリッジ位置",
46
+ "label.carriage-width": "キャリッジ幅",
42
47
  "label.carriage-height": "キャリッジ高さ",
43
48
  "label.fork-length": "フォーク長",
44
49
  "label.fork-extension": "フォーク伸縮",
45
50
  "label.fork-lift": "フォーク昇降",
51
+ "label.pocket-depth": "ポケットの深さ",
46
52
 
47
53
  "option.idle": "待機",
48
54
  "option.moving": "移動中",
@@ -34,15 +34,21 @@
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": "범례 대상",
41
+ "label.shelf-base-height": "선반 시작 높이",
39
42
 
40
43
  "label.status": "상태",
41
- "label.target": "타깃",
44
+ "label.simulate": "시뮬레이션",
45
+ "label.carriage-position": "캐리지 위치",
46
+ "label.carriage-width": "캐리지 폭",
42
47
  "label.carriage-height": "캐리지 높이",
43
48
  "label.fork-length": "포크 길이",
44
49
  "label.fork-extension": "포크 신축",
45
50
  "label.fork-lift": "포크 들기",
51
+ "label.pocket-depth": "포켓 깊이",
46
52
 
47
53
  "option.idle": "대기",
48
54
  "option.moving": "이동중",
@@ -34,15 +34,21 @@
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",
41
+ "label.shelf-base-height": "tinggi asas rak",
39
42
 
40
43
  "label.status": "status",
41
- "label.target": "sasaran",
44
+ "label.simulate": "simulasi",
45
+ "label.carriage-position": "kedudukan pembawa",
46
+ "label.carriage-width": "lebar pembawa",
42
47
  "label.carriage-height": "tinggi pembawa",
43
48
  "label.fork-length": "panjang garpu",
44
49
  "label.fork-extension": "lanjutan garpu",
45
50
  "label.fork-lift": "angkat garpu",
51
+ "label.pocket-depth": "kedalaman poket",
46
52
 
47
53
  "option.idle": "menunggu",
48
54
  "option.moving": "bergerak",
@@ -34,15 +34,21 @@
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": "图例对象",
41
+ "label.shelf-base-height": "货架基准高度",
39
42
 
40
43
  "label.status": "状态",
41
- "label.target": "目标",
44
+ "label.simulate": "模拟",
45
+ "label.carriage-position": "载具位置",
46
+ "label.carriage-width": "载具宽度",
42
47
  "label.carriage-height": "载具高度",
43
48
  "label.fork-length": "叉长",
44
49
  "label.fork-extension": "叉伸",
45
50
  "label.fork-lift": "叉升",
51
+ "label.pocket-depth": "口袋深度",
46
52
 
47
53
  "option.idle": "待机",
48
54
  "option.moving": "移动中",