@operato/scene-storage 10.0.0-beta.44 → 10.0.0-beta.46

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 (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/crane-3d.d.ts +10 -0
  3. package/dist/crane-3d.js +34 -5
  4. package/dist/crane-3d.js.map +1 -1
  5. package/dist/crane.d.ts +136 -6
  6. package/dist/crane.js +567 -46
  7. package/dist/crane.js.map +1 -1
  8. package/dist/parcel-3d.d.ts +1 -0
  9. package/dist/parcel-3d.js +18 -1
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.js +26 -8
  12. package/dist/rack-grid-3d.js.map +1 -1
  13. package/dist/rack-grid.d.ts +94 -10
  14. package/dist/rack-grid.js +468 -86
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/storage-rack-3d.js +1 -1
  17. package/dist/storage-rack-3d.js.map +1 -1
  18. package/dist/storage-rack.d.ts +31 -6
  19. package/dist/storage-rack.js +96 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +615 -55
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +488 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +96 -14
  29. package/test/test-coord-alignment.ts +2 -2
  30. package/test/test-crane-bay-match.ts +130 -0
  31. package/test/test-crane-binding-resolve.ts +168 -0
  32. package/test/test-crane-duration.ts +90 -0
  33. package/test/test-crane-rotation-reach.ts +218 -0
  34. package/test/test-rack-grid-3d-alignment.ts +235 -0
  35. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  36. package/test/test-rack-grid-cell.ts +2 -2
  37. package/test/test-rack-grid-location.ts +2 -2
  38. package/test/test-rack-grid-occupied-slots.ts +165 -0
  39. package/test/test-rack-grid-picking-position.ts +154 -0
  40. package/test/test-rack-grid-slot-api.ts +483 -0
  41. package/test/test-slot-ids-enumeration.ts +137 -0
  42. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,218 @@
1
+ /*
2
+ * Crane.findAdjacentSlots 의 reach 검사 — rotation 적용 검증.
3
+ *
4
+ * 핵심 가설 (사용자 보고 narrow 의 source):
5
+ * - StorageRack 정상 = rotation 0 모델 → world X 차 검사 와 rail-local X 가 일치.
6
+ * - RackGrid narrow = rotation π/2 모델 → rail X axis = world Z. 그러나 기존
7
+ * findAdjacentSlots 는 world X 차로만 검사 → far cell 도 reach 안으로 매칭 →
8
+ * Crane.moveTo 의 carriagePosition clamp 끝점 박힘 → carriage narrow.
9
+ *
10
+ * 가설 확인 path — 두 식 비교:
11
+ * (legacy) xOk = |pos.x - cx| <= xHalf; zOk = |pos.z - cz| <= zHalf
12
+ * (fix) railLocalX = dx*cos + dz*sin; railLocalZ = -dx*sin + dz*cos
13
+ * xOk = |railLocalX| <= xHalf; zOk = |railLocalZ| <= zHalf
14
+ *
15
+ * rotation = 0 → 두 식 결과 동일 (StorageRack 정상 설명).
16
+ * rotation = π/2 → 두 식 결과 다름 (RackGrid narrow 설명).
17
+ */
18
+
19
+ import 'should'
20
+
21
+ function legacyReach(opts: {
22
+ pos: { x: number; z: number }
23
+ crane: { x: number; z: number }
24
+ xHalf: number; zHalf: number
25
+ }): { xOk: boolean; zOk: boolean; matched: boolean } {
26
+ const dx = Math.abs(opts.pos.x - opts.crane.x)
27
+ const dz = Math.abs(opts.pos.z - opts.crane.z)
28
+ const xOk = dx <= opts.xHalf
29
+ const zOk = dz <= opts.zHalf
30
+ return { xOk, zOk, matched: xOk && zOk }
31
+ }
32
+
33
+ function fixReach(opts: {
34
+ pos: { x: number; z: number }
35
+ crane: { x: number; z: number }
36
+ rotation: number
37
+ xHalf: number; zHalf: number
38
+ }): { railLocalX: number; railLocalZ: number; xOk: boolean; zOk: boolean; matched: boolean } {
39
+ const dx = opts.pos.x - opts.crane.x
40
+ const dz = opts.pos.z - opts.crane.z
41
+ const cos = Math.cos(opts.rotation)
42
+ const sin = Math.sin(opts.rotation)
43
+ const railLocalX = dx * cos + dz * sin
44
+ const railLocalZ = -dx * sin + dz * cos
45
+ const xOk = Math.abs(railLocalX) <= opts.xHalf
46
+ const zOk = Math.abs(railLocalZ) <= opts.zHalf
47
+ return { railLocalX, railLocalZ, xOk, zOk, matched: xOk && zOk }
48
+ }
49
+
50
+ describe('rotation = 0 (StorageRack 모델 가정) — legacy 와 fix 동일 동작', () => {
51
+ it('cell 이 X reach 안 + Z reach 안 → 둘 다 matched', () => {
52
+ const legacy = legacyReach({ pos: { x: 100, z: 50 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
53
+ const fix = fixReach({ pos: { x: 100, z: 50 }, crane: { x: 0, z: 0 }, rotation: 0, xHalf: 200, zHalf: 100 })
54
+ legacy.matched.should.be.true()
55
+ fix.matched.should.be.true()
56
+ })
57
+
58
+ it('cell 이 X reach 밖 → 둘 다 not matched', () => {
59
+ const legacy = legacyReach({ pos: { x: 500, z: 50 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
60
+ const fix = fixReach({ pos: { x: 500, z: 50 }, crane: { x: 0, z: 0 }, rotation: 0, xHalf: 200, zHalf: 100 })
61
+ legacy.matched.should.be.false()
62
+ fix.matched.should.be.false()
63
+ })
64
+
65
+ it('cell 이 Z reach 밖 → 둘 다 not matched', () => {
66
+ const legacy = legacyReach({ pos: { x: 100, z: 800 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
67
+ const fix = fixReach({ pos: { x: 100, z: 800 }, crane: { x: 0, z: 0 }, rotation: 0, xHalf: 200, zHalf: 100 })
68
+ legacy.matched.should.be.false()
69
+ fix.matched.should.be.false()
70
+ })
71
+ })
72
+
73
+ describe('rotation = π/2 (RackGrid 모델 — diag 의 확정 값) — legacy 와 fix 결과 다름', () => {
74
+ // 사용자 모델 의 *_rail X axis 가 world Z axis* (rotation π/2 회전).
75
+ // 즉 carriage 의 실 운동 = world Z 방향.
76
+
77
+ it('cell 이 rail axis (= world Z) 따라 reach 안 → fix 만 정확히 matched', () => {
78
+ // cell 의 world Z 가 crane Z 와 가까움 (= rail 안). world X 차 작음.
79
+ // 사용자 의도: matched. legacy 와 fix 둘 다 matched 일 것 (X reach 안).
80
+ const legacy = legacyReach({ pos: { x: 50, z: 100 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
81
+ const fix = fixReach({ pos: { x: 50, z: 100 }, crane: { x: 0, z: 0 }, rotation: Math.PI / 2, xHalf: 200, zHalf: 100 })
82
+ legacy.matched.should.be.true()
83
+ fix.matched.should.be.true()
84
+ })
85
+
86
+ it('cell 이 rail axis 따라 reach 밖 (= world Z 차 큼) → fix 만 not matched', () => {
87
+ // 사용자 의도: cell 의 world Z 가 crane Z 와 멀음 = rail 밖 → NOT matched.
88
+ // legacy: world Z 차 = 800 > zHalf(100) → not matched. fix 도 not matched. 둘 다 정확.
89
+ const legacy = legacyReach({ pos: { x: 50, z: 800 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
90
+ const fix = fixReach({ pos: { x: 50, z: 800 }, crane: { x: 0, z: 0 }, rotation: Math.PI / 2, xHalf: 200, zHalf: 100 })
91
+ legacy.matched.should.be.false()
92
+ fix.matched.should.be.false()
93
+ })
94
+
95
+ it('★ 결정적 시나리오 ★ cell 이 *world X 방향* 으로 매우 멀음 + world Z 가까움 — legacy 는 *not matched* (잘못), fix 는 *matched* (정확)', () => {
96
+ // rail X axis = world Z 인 모델. cell 의 world Z = 50 (rail 안), world X = 600 (멀음).
97
+ // 사용자 의도: rail X axis 의 perpendicular (= world X) 차이는 *_fork reach (zHalf=100)*
98
+ // 안 이어야 함. 600 > 100 → 사용자 의도 = NOT matched.
99
+ //
100
+ // legacy: |dx|=600 > xHalf(200) → not matched.
101
+ // fix: railLocalX = 0*0 + 50*1 = 50 (rail 안). railLocalZ = -600*1 + 50*0 = -600.
102
+ // |railLocalZ| = 600 > zHalf(100) → not matched.
103
+ // 둘 다 not matched — 일치하나 *_왜* 의 path 다름.
104
+ const legacy = legacyReach({ pos: { x: 600, z: 50 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 100 })
105
+ const fix = fixReach({ pos: { x: 600, z: 50 }, crane: { x: 0, z: 0 }, rotation: Math.PI / 2, xHalf: 200, zHalf: 100 })
106
+ legacy.matched.should.be.false()
107
+ fix.matched.should.be.false()
108
+ // 핵심 — fix 의 railLocalX 는 작은 값 (50, rail 안), railLocalZ 가 큰 값 (-600, fork 밖).
109
+ fix.railLocalX.should.equal(50)
110
+ fix.railLocalZ.should.equal(-600)
111
+ })
112
+
113
+ it('★ narrow source 의 결정적 증거 ★ cell 의 world Z 가 매우 멀음 + world X 안 — legacy 는 *X reach 안* 으로 매칭, fix 는 *rail axis 밖* 으로 reject', () => {
114
+ // 사용자 모델 의 *_rotation π/2* — rail X axis = world Z. cell 의 world Z = 700
115
+ // (rail 의 X axis 따라 매우 멀음 — 사용자 의도 NOT matched, *_rail 밖*).
116
+ // 그러나 world X 는 0 (X reach 안). legacy 는 X reach 통과 → matched (잘못).
117
+ // fix: railLocalX = 0*0 + 700*1 = 700. |railLocalX| = 700 > xHalf(200) → not matched. 정확.
118
+ const legacy = legacyReach({ pos: { x: 0, z: 700 }, crane: { x: 0, z: 0 }, xHalf: 200, zHalf: 1500 })
119
+ const fix = fixReach({ pos: { x: 0, z: 700 }, crane: { x: 0, z: 0 }, rotation: Math.PI / 2, xHalf: 200, zHalf: 1500 })
120
+ // legacy 의 zHalf 를 일부러 크게 — Z 검사 통과시키고 X 검사 통과 여부 만 보기 위함.
121
+ legacy.matched.should.be.true() // ← legacy 의 잘못된 매칭 (실 사용자 narrow 의 source)
122
+ fix.matched.should.be.false() // ← fix 의 정확한 reject
123
+ fix.railLocalX.should.equal(700)
124
+ })
125
+ })
126
+
127
+ describe('useBoundScope = true (X 무관) — 진짜 *narrow source* 검증', () => {
128
+ // 기존 코드: useBoundScope = true 시 xOk = true (X 무관). 의도 = "rail 끝까지 reach".
129
+ // 실 결과: rail 의 *_perpendicular axis (= fork 방향)* 만 검사 → rail axis 따라 *_무한 cell*
130
+ // 매칭 → Crane.moveTo 가 그 cell 의 railLocalX 값 (큰 음수/양수) 으로 clamp 끝점 박힘.
131
+
132
+ function boundScopeReach(opts: {
133
+ pos: { x: number; z: number }; crane: { x: number; z: number }
134
+ rotation: number; zHalf: number
135
+ }): { matched: boolean; railLocalX: number } {
136
+ const dx = opts.pos.x - opts.crane.x
137
+ const dz = opts.pos.z - opts.crane.z
138
+ const cos = Math.cos(opts.rotation)
139
+ const sin = Math.sin(opts.rotation)
140
+ const railLocalX = dx * cos + dz * sin
141
+ const railLocalZ = -dx * sin + dz * cos
142
+ const xOk = true // ← useBoundScope = true 시 X 무관
143
+ const zOk = Math.abs(opts.pos.z - opts.crane.z) <= opts.zHalf // ← 기존: world Z 차로만 검사
144
+ return { matched: xOk && zOk, railLocalX }
145
+ }
146
+
147
+ it('rotation π/2 + bound 모델 — 멀리 떨어진 cell 도 매칭됨 → Crane.moveTo 에서 railLocalX 큰 값', () => {
148
+ // cell 의 world Z = 50 (Z reach 안), world X = 1000 (매우 멀음).
149
+ const r = boundScopeReach({
150
+ pos: { x: 1000, z: 50 }, crane: { x: 0, z: 0 },
151
+ rotation: Math.PI / 2, zHalf: 100
152
+ })
153
+ r.matched.should.be.true()
154
+ // Crane.moveTo 의 railLocalX = dx*cos + dz*sin = 1000*0 + 50*1 = 50. 작은 값 정상.
155
+ r.railLocalX.should.be.approximately(50, 1e-9)
156
+ // 즉 이 시나리오 는 *_narrow source 아님*. legacy 의 bound scope 가 X 무관이라도
157
+ // railLocalX 가 작아서 clamp 안 됨.
158
+ })
159
+
160
+ it('★ 실 narrow source ★ rotation π/2 + cell 의 *world Z 가 멀음* (rail X 따라 멀음) + world Z 차 > zHalf 일 때', () => {
161
+ // 사용자 모델 의 *_여러 RackGrid 의 *_far row*. cell 의 world Z 가 *_특정 row 의 Y*.
162
+ // 같은 RackGrid 안 *_여러 row 의 cell* 의 Y variance > zHalf.
163
+ // 단, 이 시나리오 = Z 검사 통과 못 함 → not matched. narrow source 아님.
164
+ const r = boundScopeReach({
165
+ pos: { x: 50, z: 800 }, crane: { x: 0, z: 0 },
166
+ rotation: Math.PI / 2, zHalf: 100
167
+ })
168
+ r.matched.should.be.false()
169
+ })
170
+
171
+ it('rotation = π/2 + RackGrid 의 *aisle row* cell — world Z 가 crane.z 근처 (reach 안), world X 가 col 따라 다양', () => {
172
+ // RackGrid 의 aisle row 만 매칭 — 같은 row 의 여러 col cell.
173
+ // 각 col 의 world X 가 다양 → railLocalX = dx*cos + dz*sin = 0 + dz*1 = dz (= 0 근처) 동일!
174
+ // = 모든 col 의 cell 이 *_같은 railLocalX (≈ 0)* → carriage 가 *_같은 carriagePosition* visit → narrow.
175
+ //
176
+ // 사용자 의도: carriage 가 *_col 따라 다양한 X visit*. 그러려면 *_rail X axis = world X*
177
+ // (rotation = 0). 그러나 사용자 모델 = rotation π/2 → rail X = world Z → col variance 영향 없음.
178
+ const cells = [
179
+ { x: -200, z: 50 },
180
+ { x: 0, z: 50 },
181
+ { x: 200, z: 50 },
182
+ { x: 400, z: 50 }
183
+ ]
184
+ const railLocalXs = cells.map(pos =>
185
+ boundScopeReach({ pos, crane: { x: 0, z: 0 }, rotation: Math.PI / 2, zHalf: 100 }).railLocalX
186
+ )
187
+ // 모든 col 의 railLocalX 가 *_같음* (= 50). carriage variance 0!
188
+ for (const v of railLocalXs) v.should.be.approximately(50, 1e-9)
189
+ // variance check — 최대 ~floating noise 만, 의미있는 variance 0.
190
+ const variance = Math.max(...railLocalXs) - Math.min(...railLocalXs)
191
+ variance.should.be.lessThan(1e-9)
192
+ // ⇒ 사용자 보고 narrow ✓. 진짜 source = 모델의 crane.rotation 이 aisle 방향과 어긋남.
193
+ })
194
+ })
195
+
196
+ describe('★ 결론 ★ RackGrid 모델 narrow 의 진짜 source', () => {
197
+ it('rotation = π/2 + cell 분포 가 *col 방향 (world X)* — rail X axis (= world Z) 와 직각 → carriage variance 0', () => {
198
+ // 같은 row 의 여러 col cell 들은 *_world X 만 다양*, world Z 동일.
199
+ // rail X = world Z (rotation π/2). 즉 cell 의 carriage 도달 위치 = railLocalX = dz (동일).
200
+ // ⇒ carriage 가 한 점만 visit. = 사용자 보고 narrow.
201
+ const dzs = [50, 50, 50, 50] // 모든 cell 의 dz 동일 (= 같은 row)
202
+ new Set(dzs).size.should.equal(1) // variance 0
203
+ })
204
+
205
+ it('rotation = 0 + cell 분포 가 *col 방향* — rail X axis (= world X) 와 평행 → carriage variance 큼', () => {
206
+ // 같은 row 의 여러 col cell — *_world X 다양*. rail X = world X. railLocalX = dx (다양).
207
+ const cells = [
208
+ { x: -200, z: 50 },
209
+ { x: 0, z: 50 },
210
+ { x: 200, z: 50 },
211
+ { x: 400, z: 50 }
212
+ ]
213
+ const cos = 1, sin = 0 // rotation 0
214
+ const railLocalXs = cells.map(p => p.x * cos + p.z * sin)
215
+ new Set(railLocalXs).size.should.equal(4) // variance 큼
216
+ // ⇒ StorageRack (rotation = 0) 정상 동작 설명.
217
+ })
218
+ })
@@ -0,0 +1,235 @@
1
+ /*
2
+ * RackGrid — anchor / stock / carrier 의 *실제 3D world position 일치* 검증.
3
+ *
4
+ * 사용자 보고: pick 시 carrier 가 *anchor (= fork 가 가는 자리)* 와 다른 위치에
5
+ * 그려져 fork 가 빈 포크질. 식 자체가 어디서 어긋나는지를 *실 Three.js
6
+ * scene graph* 로 직접 검증.
7
+ *
8
+ * 1. rack.object3d (THREE.Group) 만들기
9
+ * 2. anchor / stock / carrier 를 각각 *그 자식* 으로 추가
10
+ * 3. 각 식 (코드 발췌) 으로 position set 후 matrixWorld 비교
11
+ *
12
+ * 세 값이 모든 (col, row, shelf) 에서 동일하면 식 자체는 정확. 어긋나면 *어느
13
+ * 식이 잘못된지* 즉시 식별.
14
+ */
15
+
16
+ import 'should'
17
+ import * as THREE from 'three'
18
+
19
+ // ── 식 발췌 — RackGrid 의 실제 코드와 동일 ─────────────────────────────────
20
+
21
+ interface RackParams {
22
+ width: number // 3D X
23
+ height: number // 3D Z (front-back) = state.height = 2D bounds height
24
+ depth: number // 3D Y (vertical) = state.depth
25
+ cols: number
26
+ rows: number
27
+ shelves: number
28
+ shelfBase: number
29
+ }
30
+
31
+ function derived(rp: RackParams) {
32
+ const bayW = rp.width / rp.cols
33
+ const bayD = rp.height / rp.rows
34
+ const shelfZone = rp.depth - rp.shelfBase
35
+ const cellY = shelfZone / rp.shelves
36
+ const baseY = -rp.depth / 2
37
+ const shelfBaseY = baseY + rp.shelfBase
38
+ const stockD = cellY * 0.7
39
+ return { bayW, bayD, shelfZone, cellY, baseY, shelfBaseY, stockD }
40
+ }
41
+
42
+ // anchor 식 — rack-grid.ts getSlotAttachObject3d (line 1346-1349)
43
+ function anchorPos(col: number, row: number, shelf: number, rp: RackParams) {
44
+ const { bayW, bayD, cellY, shelfBaseY, stockD } = derived(rp)
45
+ return {
46
+ x: (col - rp.cols / 2 + 0.5) * bayW,
47
+ y: shelfBaseY + shelf * cellY + stockD / 2,
48
+ z: (row - rp.rows / 2 + 0.5) * bayD
49
+ }
50
+ }
51
+
52
+ // stock 식 — rack-grid-3d.ts matrixFor (line 358-363, cy 식은 line 280 부근의 동일)
53
+ function stockPos(col: number, row: number, shelf: number, rp: RackParams) {
54
+ const { bayW, bayD, cellY, shelfBaseY, stockD } = derived(rp)
55
+ return {
56
+ x: (col - rp.cols / 2 + 0.5) * bayW,
57
+ y: shelfBaseY + shelf * cellY + stockD / 2,
58
+ z: (row - rp.rows / 2 + 0.5) * bayD
59
+ }
60
+ }
61
+
62
+ // carrier 식 — rack-grid.ts obtainCarrier 의 *2D state.left/top → 3D world* 변환.
63
+ // 내 가정: parent (RackGrid) 의 *2D bounds center* 가 *3D origin*. 즉
64
+ // 3D X = state.left + carrierW/2 - parent.width/2 = cellCenterInnerX - rackWidth/2
65
+ // 3D Z = state.top + carrierH/2 - parent.height/2 = cellCenterInnerY - rackHeight/2
66
+ // 단 *2D Y → 3D Z 부호* 가 +/- 어느 쪽인지 things-scene 컨벤션 따라.
67
+ function carrierPos_assumeY_to_PlusZ(col: number, row: number, shelf: number, rp: RackParams) {
68
+ const { bayW, bayD, cellY, shelfBaseY, stockD } = derived(rp)
69
+ const cellCenterInnerX = (col + 0.5) * bayW
70
+ const cellCenterInnerY = (row + 0.5) * bayD
71
+ return {
72
+ x: cellCenterInnerX - rp.width / 2,
73
+ y: shelfBaseY + shelf * cellY + stockD / 2, // anchor 와 동일 (carrier.depth 가 stockD 인 가정)
74
+ z: cellCenterInnerY - rp.height / 2
75
+ }
76
+ }
77
+
78
+ // 부호 반대 가설 — 2D Y → 3D -Z
79
+ function carrierPos_assumeY_to_MinusZ(col: number, row: number, shelf: number, rp: RackParams) {
80
+ const { cellY, shelfBaseY, stockD, bayW, bayD } = derived(rp)
81
+ const cellCenterInnerX = (col + 0.5) * bayW
82
+ const cellCenterInnerY = (row + 0.5) * bayD
83
+ return {
84
+ x: cellCenterInnerX - rp.width / 2,
85
+ y: shelfBaseY + shelf * cellY + stockD / 2,
86
+ z: rp.height / 2 - cellCenterInnerY // 부호 반전
87
+ }
88
+ }
89
+
90
+ // ── Helper — 실 Three.js Group 안에 3 객체 add 후 matrixWorld 비교 ────────────
91
+
92
+ function compareWorld(rp: RackParams, col: number, row: number, shelf: number) {
93
+ const rackObj = new THREE.Group()
94
+ rackObj.position.set(0, 0, 0)
95
+ rackObj.updateMatrixWorld(true)
96
+
97
+ const a = anchorPos(col, row, shelf, rp)
98
+ const s = stockPos(col, row, shelf, rp)
99
+ const c1 = carrierPos_assumeY_to_PlusZ(col, row, shelf, rp)
100
+ const c2 = carrierPos_assumeY_to_MinusZ(col, row, shelf, rp)
101
+
102
+ const anchorObj = new THREE.Object3D()
103
+ anchorObj.position.set(a.x, a.y, a.z)
104
+ rackObj.add(anchorObj)
105
+
106
+ const stockObj = new THREE.Object3D()
107
+ stockObj.position.set(s.x, s.y, s.z)
108
+ rackObj.add(stockObj)
109
+
110
+ const carrier1Obj = new THREE.Object3D()
111
+ carrier1Obj.position.set(c1.x, c1.y, c1.z)
112
+ rackObj.add(carrier1Obj)
113
+
114
+ const carrier2Obj = new THREE.Object3D()
115
+ carrier2Obj.position.set(c2.x, c2.y, c2.z)
116
+ rackObj.add(carrier2Obj)
117
+
118
+ rackObj.updateMatrixWorld(true)
119
+
120
+ const aw = new THREE.Vector3()
121
+ const sw = new THREE.Vector3()
122
+ const c1w = new THREE.Vector3()
123
+ const c2w = new THREE.Vector3()
124
+ anchorObj.getWorldPosition(aw)
125
+ stockObj.getWorldPosition(sw)
126
+ carrier1Obj.getWorldPosition(c1w)
127
+ carrier2Obj.getWorldPosition(c2w)
128
+
129
+ return { aw, sw, c1w, c2w }
130
+ }
131
+
132
+ // ── Tests ──────────────────────────────────────────────────────────────────
133
+
134
+ describe('RackGrid Real 3D: anchor / stock / carrier world position 일치', () => {
135
+ const rp: RackParams = {
136
+ width: 1000, height: 600, depth: 2000,
137
+ cols: 5, rows: 3, shelves: 4, shelfBase: 0
138
+ }
139
+
140
+ it('anchor 와 stock 은 *모든 (col,row,shelf)* 에서 동일 world position', () => {
141
+ for (let col = 0; col < rp.cols; col++) {
142
+ for (let row = 0; row < rp.rows; row++) {
143
+ for (let shelf = 0; shelf < rp.shelves; shelf++) {
144
+ const { aw, sw } = compareWorld(rp, col, row, shelf)
145
+ aw.x.should.be.approximately(sw.x, 1e-9, `col=${col} row=${row} shelf=${shelf}`)
146
+ aw.y.should.be.approximately(sw.y, 1e-9, `col=${col} row=${row} shelf=${shelf}`)
147
+ aw.z.should.be.approximately(sw.z, 1e-9, `col=${col} row=${row} shelf=${shelf}`)
148
+ }
149
+ }
150
+ }
151
+ })
152
+
153
+ it('carrier (2D Y → 3D +Z 가정) — anchor 와 동일 world position', () => {
154
+ // 사용자가 *현재 코드* 의 식. fork 가 가는 자리(anchor) 와 carrier 가 같은 곳에
155
+ // 있어야 정상. 이 테스트 통과 = 식 자체 정확. 그러면 사용자 보고의 어긋남은
156
+ // *things-scene 의 *2D → 3D 변환* 이 *우리 가정과 다름* 시사.
157
+ for (let col = 0; col < rp.cols; col++) {
158
+ for (let row = 0; row < rp.rows; row++) {
159
+ for (let shelf = 0; shelf < rp.shelves; shelf++) {
160
+ const { aw, c1w } = compareWorld(rp, col, row, shelf)
161
+ aw.x.should.be.approximately(c1w.x, 1e-9, `+Z col=${col} row=${row} shelf=${shelf}`)
162
+ aw.y.should.be.approximately(c1w.y, 1e-9, `+Z col=${col} row=${row} shelf=${shelf}`)
163
+ aw.z.should.be.approximately(c1w.z, 1e-9, `+Z col=${col} row=${row} shelf=${shelf}`)
164
+ }
165
+ }
166
+ }
167
+ })
168
+
169
+ it('carrier (2D Y → 3D -Z 가정) — Z 좌표가 anchor 와 *반대 부호*', () => {
170
+ // 만약 things-scene 의 변환 컨벤션이 이쪽이라면 *내 obtainCarrier 식의 Z* 가
171
+ // *anchor 와 반대* — fork 가 anchor 위치 (= stock 자리) 로 오는데 carrier 는
172
+ // *반대 측 Z* 에 그려짐. 사용자 보고와 일치하는 가설.
173
+ for (let col = 0; col < rp.cols; col++) {
174
+ for (let row = 0; row < rp.rows; row++) {
175
+ const { aw, c2w } = compareWorld(rp, col, row, 0)
176
+ if (row === rp.rows / 2 - 0.5 || row === Math.floor(rp.rows / 2)) {
177
+ // 중앙 row 는 Z=0 이라 부호 무관 — skip
178
+ continue
179
+ }
180
+ // Z 부호 반대 — anchor.z 와 c2w.z 의 *합이 0* (대칭)
181
+ ;(aw.z + c2w.z).should.be.approximately(0, 1e-9,
182
+ `row=${row}: anchor.z=${aw.z}, carrier(-Z).z=${c2w.z}`)
183
+ }
184
+ }
185
+ })
186
+
187
+ it('row 별 anchor.z 값 명시 — row=0 가 *-Z (앞)*, row=N-1 가 *+Z (뒤)*', () => {
188
+ const { aw: aw0 } = compareWorld(rp, 0, 0, 0)
189
+ const { aw: awLast } = compareWorld(rp, 0, rp.rows - 1, 0)
190
+ aw0.z.should.be.lessThan(0)
191
+ awLast.z.should.be.greaterThan(0)
192
+ // bayD = height / rows = 200
193
+ // row 0: (0 - 1.5 + 0.5) * 200 = -200
194
+ // row 2: (2 - 1.5 + 0.5) * 200 = +200
195
+ aw0.z.should.be.approximately(-200, 1e-9)
196
+ awLast.z.should.be.approximately(200, 1e-9)
197
+ })
198
+ })
199
+
200
+ // ── 사용자 보고 *fork 가 뒤로 나감* 가설 분리 ─────────────────────────────────
201
+
202
+ describe('RackGrid: fork 진입 방향 가설 분리', () => {
203
+ /*
204
+ * 위 테스트의 두 carrier 식 결과로 본 분리:
205
+ *
206
+ * (A) anchor / stock / carrier(+Z) 모두 동일 world → 모두 식 정확.
207
+ * 사용자 보고의 어긋남은 *식 외* 원인:
208
+ * - things-scene 의 *2D → 3D 변환 부호* 가 +Z 가 아닌 -Z (= carrier(-Z) 가
209
+ * 실제 carrier 위치). 그러면 *carrier 가 anchor 반대* — fork 빈 공간 + 뒤로.
210
+ * → fix: rack-grid.ts 의 cellCenterInnerY 부호 반전.
211
+ * - 또는 things-scene 변환이 +Z 정확 → *fork 의 *진입 face* 가 *crane 위치*
212
+ * 따라 자동 결정 = 시뮬레이션 *crane 의 RackGrid 측 위치* 가 *fork 진입
213
+ * 반대 방향*. *코드 fix 외 영역*.
214
+ *
215
+ * (B) anchor != stock 또는 anchor != carrier → 식 자체 잘못. 수정 대상 분명.
216
+ */
217
+ it('진단 출력 — row=0 ~ row=N-1 의 anchor.z / carrier(+Z).z / carrier(-Z).z', () => {
218
+ const rp: RackParams = {
219
+ width: 1000, height: 600, depth: 2000,
220
+ cols: 5, rows: 3, shelves: 4, shelfBase: 0
221
+ }
222
+ const rows: Array<{ row: number; anchorZ: number; carrierPlusZ: number; carrierMinusZ: number }> = []
223
+ for (let row = 0; row < rp.rows; row++) {
224
+ const { aw, c1w, c2w } = compareWorld(rp, 0, row, 0)
225
+ rows.push({
226
+ row,
227
+ anchorZ: Math.round(aw.z),
228
+ carrierPlusZ: Math.round(c1w.z),
229
+ carrierMinusZ: Math.round(c2w.z)
230
+ })
231
+ }
232
+ console.table(rows)
233
+ rows.length.should.equal(rp.rows)
234
+ })
235
+ })