@operato/scene-storage 10.0.0-beta.41 → 10.0.0-beta.43
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 +20 -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/parcel-3d.js +42 -9
- package/dist/parcel-3d.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/parcel-3d.ts +41 -9
- 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
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
## [10.0.0-beta.43](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.42...v10.0.0-beta.43) (2026-05-21)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @operato/scene-storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [10.0.0-beta.42](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.41...v10.0.0-beta.42) (2026-05-21)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### :rocket: New Features
|
|
18
|
+
|
|
19
|
+
* **storage,scene-base:** Plan A slot API + pickAndPlace 좌표 컨벤션 수정 ([fab1970](https://github.com/things-scene/operato-scene/commit/fab1970f54863ddb7c0b17646a313aad648fd031))
|
|
20
|
+
* **storage:** batched 시각화 + cell-anchored popup + 클릭 라우팅 ([3acde61](https://github.com/things-scene/operato-scene/commit/3acde61be2647b41a3e550e3ee115736af0922d6))
|
|
21
|
+
* **storage:** RackGrid 3D frame 정밀화 + shared materials + hideHorizontalFrame ([8e65763](https://github.com/things-scene/operato-scene/commit/8e65763fedffea3b7350855ebb9b09be8289f597))
|
|
22
|
+
* **storage:** RackGrid 완성 — bay 그리드 + light cell-component + Plan A stock ([79ee9a6](https://github.com/things-scene/operato-scene/commit/79ee9a6da852af4834fe0ac58bcba2030aeb2462)), closes [#888](https://github.com/things-scene/operato-scene/issues/888)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
## [10.0.0-beta.41](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.40...v10.0.0-beta.41) (2026-05-17)
|
|
7
27
|
|
|
8
28
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# StorageRack Plan A — Slot API 마이그레이션 가이드
|
|
2
|
+
|
|
3
|
+
## TL;DR
|
|
4
|
+
|
|
5
|
+
Application 코드의 `rack.cellComponent(...).carrier` 류 패턴을 **`rack.obtainCarrier(cellId)` + `rack.slotTargetAt(cellId)`** 로 바꾸면 다음이 해결됨:
|
|
6
|
+
|
|
7
|
+
- 클릭/pickAndPlace 시 InstancedMesh stock 위에 carrier 가 겹쳐 그려지는 Z-fighting
|
|
8
|
+
- 같은 cell 에 반복 작업 시 carrier 가 누적되어 박스가 늘어나는 회귀
|
|
9
|
+
- pickAndPlace 후 state.data 와 시각화 사이의 부정합
|
|
10
|
+
- batched rack 의 메모리 낭비 (10000-cell rack 의 ~98% 절감)
|
|
11
|
+
|
|
12
|
+
기존 API (`cellComponent`, `_buildCells`) 는 유지 — 점진 마이그레이션 가능.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 모델 변화: "Matrix 안 = 데이터, 밖 = Component"
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
이전 모델:
|
|
20
|
+
Rack
|
|
21
|
+
└─ RackCell (10000 개 영구 Component)
|
|
22
|
+
└─ Carrier (재고가 있을 때마다 영구 Component)
|
|
23
|
+
|
|
24
|
+
새 모델 (Plan A):
|
|
25
|
+
Rack
|
|
26
|
+
└─ state.data: [{cellId, sku, qty, ...}, ...] ← "매트릭스 안"
|
|
27
|
+
└─ (transit 중 일시적으로 carrier child) ← "밖"
|
|
28
|
+
|
|
29
|
+
경계 통과 시:
|
|
30
|
+
obtainCarrier(cellId) → 데이터 → 컴포넌트 (transient materialize)
|
|
31
|
+
receiveAt(cellId, c) → 컴포넌트 → 데이터 (atomic absorb + dispose)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**불변식**: 동일 cellId 가 state.data 의 record 와 rack-child carrier 양쪽에 동시 존재하지 않음.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## API 매핑
|
|
39
|
+
|
|
40
|
+
### Pick (carrier 를 cell 에서 빼냄)
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// ── Before ─────────────────────────────────────────────
|
|
44
|
+
const cell = rack.cellComponent('A-0-0')
|
|
45
|
+
const carrier = cell.carrier // ← side-effect: state.data 의 record 로부터 자동 materialize
|
|
46
|
+
// (그러나 record 는 안 빠짐 → Z-fight, 누적)
|
|
47
|
+
await crane.pick(carrier)
|
|
48
|
+
|
|
49
|
+
// ── After ──────────────────────────────────────────────
|
|
50
|
+
const carrier = rack.obtainCarrier('A-0-0')
|
|
51
|
+
if (!carrier) {
|
|
52
|
+
// cell 에 record 도 child 도 없음 — 빈 cell.
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
await crane.pick(carrier)
|
|
56
|
+
// state.data 에서 그 record 가 atomic 하게 빠지고, InstancedMesh 가 그 자리 더 이상
|
|
57
|
+
// 안 그림. carrier 는 transient → 곧 crane child 가 됨.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Place (carrier 를 cell 에 넣음)
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// ── Before ─────────────────────────────────────────────
|
|
64
|
+
const destCell = rack.cellComponent('B-0-0')
|
|
65
|
+
await crane.place(carrier, destCell)
|
|
66
|
+
// carrier 는 destCell 의 child 로 영구 존재 → 다음 pickAndPlace 때 또 materialize
|
|
67
|
+
// 시도되어 누적 회귀.
|
|
68
|
+
|
|
69
|
+
// ── After ──────────────────────────────────────────────
|
|
70
|
+
await crane.place(carrier, rack.slotTargetAt('B-0-0'))
|
|
71
|
+
// SlotTarget.receive → rack.receiveAt → carrier dispose + state.data 에 record push.
|
|
72
|
+
// 결과: rack 의 자식 트리는 깨끗, state.data 에 record 만 추가. InstancedMesh 가
|
|
73
|
+
// 자동으로 그 자리에 instance 그림. 누적 없음.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### pickAndPlace (한 줄)
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// ── Before ─────────────────────────────────────────────
|
|
80
|
+
await crane.pickAndPlace(
|
|
81
|
+
rack.cellComponent('A-0-0').carrier,
|
|
82
|
+
rack.cellComponent('B-0-0')
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// ── After ──────────────────────────────────────────────
|
|
86
|
+
const carrier = rack.obtainCarrier('A-0-0')
|
|
87
|
+
if (!carrier) return
|
|
88
|
+
await crane.pickAndPlace(carrier, rack.slotTargetAt('B-0-0'))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 다른 rack 사이 이동
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// ── After ──────────────────────────────────────────────
|
|
95
|
+
const carrier = sourceRack.obtainCarrier('A-0-0')
|
|
96
|
+
if (!carrier) return
|
|
97
|
+
await crane.pickAndPlace(carrier, destRack.slotTargetAt('B-0-0'))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Rack → 자유공간 (model-layer 의 carrier holder)
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// ── After ──────────────────────────────────────────────
|
|
104
|
+
const carrier = rack.obtainCarrier('A-0-0')
|
|
105
|
+
if (!carrier) return
|
|
106
|
+
await crane.pickAndPlace(carrier, freeSpaceHolder)
|
|
107
|
+
// freeSpaceHolder 는 SlottedHolder 아님 → 흡수 안 일어남.
|
|
108
|
+
// carrier 가 freeSpaceHolder 의 child Component 로 영구 존재. ("매트릭스 밖")
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 자유공간 → Rack
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// ── After ──────────────────────────────────────────────
|
|
115
|
+
const carrier = root.findById('parcel-123') // model-layer 의 free carrier
|
|
116
|
+
await crane.pickAndPlace(carrier, rack.slotTargetAt('A-0-0'))
|
|
117
|
+
// rack.receiveAt → carrier dispose + record push. "매트릭스 진입".
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 조회 / 검사
|
|
123
|
+
|
|
124
|
+
### 점유 여부
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// ── Before ─────────────────────────────────────────────
|
|
128
|
+
const cell = rack.cellComponent('A-0-0')
|
|
129
|
+
const isOccupied = !!cell?.carrier // side-effect 동반 — 빈 cell 에 carrier 만들 가능성
|
|
130
|
+
|
|
131
|
+
// ── After ──────────────────────────────────────────────
|
|
132
|
+
const isOccupied = rack.hasCarrierAt('A-0-0') // 순수 검사, side-effect 없음
|
|
133
|
+
const canPlace = rack.canReceiveAt('A-0-0')
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 모든 점유 cell 의 record
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// ── Before ─────────────────────────────────────────────
|
|
140
|
+
// rack 의 모든 cell 컴포넌트 순회 + 각 cell.carrier 검사 (느림)
|
|
141
|
+
const occupied = rack.components
|
|
142
|
+
.filter(c => c.state?.type === 'storage-cell')
|
|
143
|
+
.map(c => c.carrier?.state)
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
|
|
146
|
+
// ── After ──────────────────────────────────────────────
|
|
147
|
+
const occupied = rack.records // state.data 의 readonly view
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 특정 record 의 데이터
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// ── Before ─────────────────────────────────────────────
|
|
154
|
+
const data = rack.cellComponent('A-0-0')?.carrier?.state // side-effect 위험
|
|
155
|
+
|
|
156
|
+
// ── After ──────────────────────────────────────────────
|
|
157
|
+
const record = rack.records.find(r => r.cellId === 'A-0-0')
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Type guard
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { isSlottedHolder } from '@operato/scene-base'
|
|
166
|
+
|
|
167
|
+
// 일반화된 target 처리 (rack, table, future slot holders)
|
|
168
|
+
function moveTo(target: any, carrier: Component, cellId?: string) {
|
|
169
|
+
if (cellId && isSlottedHolder(target)) {
|
|
170
|
+
return crane.place(carrier, target.slotTargetAt(cellId))
|
|
171
|
+
}
|
|
172
|
+
return crane.place(carrier, target)
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 메모리 최적화 — eagerCells
|
|
179
|
+
|
|
180
|
+
대용량 batched rack (예: 10000 cells) 에서는 RackCell Component 의 eager build 가 메모리 부담:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// state.data 가 있으면 자동으로 eagerCells = false (메모리 절약)
|
|
184
|
+
const rack = new Rack({ type: 'storage-rack', bays: 100, levels: 100, data: [...] })
|
|
185
|
+
|
|
186
|
+
// 명시 옵션:
|
|
187
|
+
rack.setState('eagerCells', false) // 강제 skip (Plan A 만 사용)
|
|
188
|
+
rack.setState('eagerCells', true) // 강제 eager (legacy 호환)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
기본 정책:
|
|
192
|
+
- `state.data` 있음 → `eagerCells = false` (auto, batched)
|
|
193
|
+
- `state.data` 없음 → `eagerCells = true` (auto, legacy non-batched)
|
|
194
|
+
- `state.eagerCells` 명시 시 그대로 따름
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 마이그레이션 단계별 권장
|
|
199
|
+
|
|
200
|
+
### Phase 1 — 신규 코드는 Plan A
|
|
201
|
+
- 새로 작성하는 application 로직은 `obtainCarrier` / `slotTargetAt` 사용
|
|
202
|
+
- `cellComponent` 호출 새로 추가하지 않음
|
|
203
|
+
|
|
204
|
+
### Phase 2 — 기존 호출처 점검
|
|
205
|
+
```bash
|
|
206
|
+
# application 코드에서 cellComponent 사용처 찾기
|
|
207
|
+
grep -rn "cellComponent\b\|\.carrier\b" src/ apps/
|
|
208
|
+
```
|
|
209
|
+
각 호출처:
|
|
210
|
+
- 단순 lookup (점유 검사 등) → `hasCarrierAt` / `canReceiveAt` 로 교체
|
|
211
|
+
- carrier 추출 → `obtainCarrier` 로 교체
|
|
212
|
+
- destination 으로 사용 → `slotTargetAt` 로 교체
|
|
213
|
+
|
|
214
|
+
### Phase 3 — 회귀 검증
|
|
215
|
+
- batched rack 의 *반복 pickAndPlace* 시 Z-fight / 누적 없음 확인
|
|
216
|
+
- 동일 cell 클릭으로 popup 띄우기 반복 → 박스 시각화 정상 유지 확인
|
|
217
|
+
- pickAndPlace 후 state.data 가 source 에서 빠지고 dest 로 갱신 확인
|
|
218
|
+
|
|
219
|
+
### Phase 4 — 메모리 검증 (대용량 rack)
|
|
220
|
+
- 1만+ cell rack 에서 eagerCells 미설정 시 자동 batched 모드 진입 확인
|
|
221
|
+
- JS heap 점유량이 기대치 (instance + record 만) 안 인지 측정
|
|
222
|
+
|
|
223
|
+
### Phase 5 — Legacy API 제거 (선택)
|
|
224
|
+
- application 전체가 Plan A 로 마이그레이션 완료되면, 향후 storage-rack 의
|
|
225
|
+
`cellComponent` / `_addCarrierToCell` / `_materializeCell` 메소드 제거 가능
|
|
226
|
+
- breaking change 라 별도 major 버전에서
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 자주 묻는 질문
|
|
231
|
+
|
|
232
|
+
### Q: 기존 cellComponent 호출이 그대로 동작하나?
|
|
233
|
+
A: 네. 후방 호환 유지. 단 *batched 모드 (state.data 있음) 에서* `cellComponent('A').carrier` 가 record 로부터 carrier 를 materialize 하는 부수효과는 *제거되지 않았음* — Z-fight 회귀의 원인. Plan A 로 마이그레이션 필요.
|
|
234
|
+
|
|
235
|
+
### Q: 시뮬레이션 (Crane 이 박스를 옮기는 데모) 도 batched 로 가능?
|
|
236
|
+
A: 거의 모두 가능. Plan A 의 obtainCarrier → transient materialize → crane 동행 → receiveAt → absorb 흐름이 *눈에 안 보이게* 처리. 박스 식별성 (어느 박스가 어디로 갔는지) 은 state.data record 의 데이터 자체가 보존.
|
|
237
|
+
|
|
238
|
+
### Q: 박스가 자체 행위 (애니메이션, 이벤트 핸들러, child component) 를 가지면?
|
|
239
|
+
A: 그땐 batched 모드 부적합. record 로 표현 불가능한 정보가 있는 경우 — `state.data` 안 쓰고 carrier 를 *명시적으로 추가* 해 영구 child 로 둠 (= 매트릭스 밖). 이 carrier 는 transient 가 아닌 영구.
|
|
240
|
+
|
|
241
|
+
### Q: 두 모드 혼합 가능?
|
|
242
|
+
A: 가능. 같은 rack 안에 state.data 의 record 9000개 + 영구 carrier child 10개 공존 OK. 불변식 ("같은 cellId 가 양쪽에 동시 존재 X") 만 application 이 지키면 됨.
|
|
243
|
+
|
|
244
|
+
### Q: SlotTarget 의 lifecycle 은? 매 클릭마다 새로 만들면 메모리 누수?
|
|
245
|
+
A: SlotTarget 자체는 가벼운 wrapper — 자체 Object3D 없음. `_realObject.object3d` 는 holder 의 *singleton anchor object3d* (lazy 생성, cellId 별 캐시) 를 참조. 즉 SlotTarget 객체는 GC 자유, 실제 3D 자원은 holder 가 관리.
|
|
246
|
+
|
|
247
|
+
### Q: record 에 어떤 필드를 포함시킬 수 있나?
|
|
248
|
+
A: `cellId` (필수) + 자유 application 필드 (sku, qty, status, color, ...). carrier 가 materialize 될 때 record 의 모든 필드가 carrier.state 로 spread. carrier 가 absorb 될 때 `recordFromCarrier(carrier, cellId)` 가 *transform 관련 필드 제외* 한 모든 state 를 record 로 환원 — application 이 override 로 추가 인코딩 가능.
|
|
249
|
+
|
|
250
|
+
### Q: 양방향 sync — 외부 (WMS) 에서 state.data 를 갱신하면?
|
|
251
|
+
A: `setState('data', newData)` → `onchangeData` → `rebuildStockMesh` 자동. InstancedMesh 가 새 데이터 반영. 즉 외부 데이터 → 시각화 push 그대로 작동.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 회귀 케이스 체크리스트
|
|
256
|
+
|
|
257
|
+
마이그레이션 완료 후 다음을 확인:
|
|
258
|
+
|
|
259
|
+
- [ ] Stock instance 클릭 → popup 정확한 cell anchor 위치
|
|
260
|
+
- [ ] 다른 cell 클릭 시 popup *이동* (이전 cell 의 stock 표면 정상 유지, Z-fight 없음)
|
|
261
|
+
- [ ] pickAndPlace cell → cell 후 source cell 에 박스 없음 + dest cell 에 instance 표시
|
|
262
|
+
- [ ] 같은 cell 에 반복 pickAndPlace 실행 → 박스 누적 없음
|
|
263
|
+
- [ ] pickAndPlace 도중 텔레포트 회귀 없음 (carrier 가 moveTo 단계 동안 source 에 머묾)
|
|
264
|
+
- [ ] WMS state.data push → InstancedMesh 즉시 반영
|
|
265
|
+
- [ ] 다른 rack 사이 이동 — source state.data 빠짐, dest state.data 추가
|
|
266
|
+
- [ ] 자유공간 ↔ rack 양방향 이동 정상
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Plan A — Rack as Slot Holder (LoopSorter-style)
|
|
2
|
+
|
|
3
|
+
## 동기
|
|
4
|
+
|
|
5
|
+
현재 StorageRack 은 *각 cell 을 RackCell Component 로 eager build* (10000 cell rack 이면 10000 Component + 10000 object3d). 이 비대한 구조가:
|
|
6
|
+
|
|
7
|
+
- batched 시각화 (InstancedMesh) 의 메모리 이점 무력화
|
|
8
|
+
- pickAndPlace 시 `cellComponent.carrier` 의 lazy materialize 가 *진짜 carrier Component* 를 만들어 InstancedMesh stock 과 Z-fight
|
|
9
|
+
- materialize 후 carrier 가 영구 존재하면서 (1) 누적 (2) state.data 와 불일치
|
|
10
|
+
|
|
11
|
+
LoopSorter 는 이미 *slot-as-index* 패턴으로 같은 종류 문제를 해결했음 — sorter 가 holder, slot 은 index, carrier 는 sorter 의 직접 자식 (proxy group attach). **Rack 도 그 패턴 채용**.
|
|
12
|
+
|
|
13
|
+
## 변경 후 모델
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Rack (CarrierHolder)
|
|
17
|
+
├── carrier (state.cellId = 'A-0-0') ← real Component, 직접 child
|
|
18
|
+
├── carrier (state.cellId = 'B-0-0') ← real Component, 직접 child
|
|
19
|
+
├── state.data: [{cellId: 'C-0-0', ...}, ...] ← 나머지는 record only (InstancedMesh)
|
|
20
|
+
└── 3D: InstancedMesh (state.data) + 각 carrier 의 mesh (직접 child)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- *RackCell Component 제거* (또는 debug-only)
|
|
24
|
+
- Carrier 는 Rack 의 직접 자식. `state.cellId` 이 slot 위치.
|
|
25
|
+
- state.data 와 carrier-children 은 *상호 배타* — 같은 cellId 가 양쪽에 있을 수 없음.
|
|
26
|
+
- InstancedMesh 는 state.data 의 record 만 렌더 — 자동으로 carrier 있는 cell 은 제외 (record 가 거기 없음).
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
### Rack 의 새 메소드 (LoopSorter 의 reserveAtJoinPoint 와 대응)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
class Rack {
|
|
34
|
+
// ── Slot-aware Pickable source ────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** state.data 에 record 가 있는 점유된 slot 인지 또는 child carrier 가 있는지 */
|
|
37
|
+
hasCarrierAt(cellId: string): boolean
|
|
38
|
+
|
|
39
|
+
/** record/carrier 를 carrier Component 로 보장. 이미 child 면 그대로 반환. */
|
|
40
|
+
obtainCarrier(cellId: string): Component | null
|
|
41
|
+
// 동작:
|
|
42
|
+
// 1. carrier-children 에서 state.cellId === cellId 찾기 → 있으면 return
|
|
43
|
+
// 2. state.data 에서 record 찾기 → 있으면 transient carrier materialize
|
|
44
|
+
// (Rack 의 직접 자식으로 addComponent, state.data 에서 record 제거)
|
|
45
|
+
// 3. 둘 다 없으면 null
|
|
46
|
+
|
|
47
|
+
// ── Slot-aware CarrierHolder destination ──────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** 특정 cell 로의 placement 가 가능한가 (slot 비어있나) */
|
|
50
|
+
canReceiveAt(cellId: string, carrier?: Component): boolean
|
|
51
|
+
|
|
52
|
+
/** Mover.place 가 호출. carrier 를 slot 에 흡수 */
|
|
53
|
+
async receiveAt(cellId: string, carrier: Component, options?): Promise<void>
|
|
54
|
+
// 동작:
|
|
55
|
+
// 1. carrier 를 rack 의 자식으로 reparent (carrier.state.cellId = cellId 로 set)
|
|
56
|
+
// 2. 이후 absorb 정책 적용:
|
|
57
|
+
// - 'auto' (default, batched-rack): carrier 를 dispose + state.data 에 record push
|
|
58
|
+
// → 다시 record-only 상태로 환원 (InstancedMesh 가 렌더)
|
|
59
|
+
// - 'keep': carrier 를 그대로 child 로 유지 (non-batched 모드)
|
|
60
|
+
// 정책 결정: state.absorbPolicy 또는 carrier 의 transient flag
|
|
61
|
+
|
|
62
|
+
// ── 3D 위치 정보 (Mover/Crane 의 target 위치 계산용) ───────────────────
|
|
63
|
+
|
|
64
|
+
/** cell 의 world-space pose — Mover.moveTo / Crane.engage 가 사용 */
|
|
65
|
+
getCellPose(cellId: string): { position: Vector3, rotation: Quaternion }
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 호출 패턴
|
|
70
|
+
|
|
71
|
+
**Pick from rack slot**:
|
|
72
|
+
```ts
|
|
73
|
+
const carrier = rack.obtainCarrier('A-0-0') // batched 면 materialize, 아니면 기존 child
|
|
74
|
+
if (!carrier) throw new Error('no stock at A-0-0')
|
|
75
|
+
|
|
76
|
+
await crane.pickAndPlace(carrier, rack.slotTargetAt('B-0-0'))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**`rack.slotTargetAt(cellId)`** — 가벼운 SlotTarget wrapper:
|
|
80
|
+
```ts
|
|
81
|
+
class SlotTarget {
|
|
82
|
+
constructor(public rack: Rack, public cellId: string) {}
|
|
83
|
+
|
|
84
|
+
// Mover/Crane 가 다음 인터페이스 사용:
|
|
85
|
+
get center(): {x, y} // → rack.getCellPose(cellId) 의 2D 투영
|
|
86
|
+
get state(): { cellId, ... } // Crane.engage 의 target pose 추출용
|
|
87
|
+
get _realObject() { ... } // proxy object3d 반환 (transient)
|
|
88
|
+
get parent() { return this.rack.parent }
|
|
89
|
+
|
|
90
|
+
// CarrierHolder 컨트랙
|
|
91
|
+
canReceive(carrier) { return this.rack.canReceiveAt(this.cellId, carrier) }
|
|
92
|
+
async receive(carrier, options) { return this.rack.receiveAt(this.cellId, carrier, options) }
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
SlotTarget 은 *호출 시점에만 생성되는 가벼운 wrapper* — 영구 Component 아님. 의미: "rack 위에 위치한 slot 의 carriable contract 진입점". RackCell 의 역할을 transient 로 대체.
|
|
97
|
+
|
|
98
|
+
### Mover/Crane 의 변경
|
|
99
|
+
|
|
100
|
+
이상적으로 *최소 변경*. SlotTarget 이 기존 Mover/Crane 가 부르는 인터페이스 (center / state / _realObject / canReceive / receive) 를 만족하면 Mover/Crane 코드 자체는 안 건드려도 됨.
|
|
101
|
+
|
|
102
|
+
확인 필요:
|
|
103
|
+
- `Crane.engage(target, kind)` 의 `getWorldPose(target)` — target 의 `_realObject.object3d` 의 matrixWorld 사용. SlotTarget 의 proxy object3d 가 올바른 위치/회전을 가지면 OK.
|
|
104
|
+
- `Mover.computeTarget(carrier, 'pick')` / `computeTarget(holder, 'place', carrier)` — target 의 center/bounds 사용.
|
|
105
|
+
|
|
106
|
+
→ SlotTarget 의 proxy object3d 를 *rack.object3d 의 자식* 으로 두고 cellId 위치에 배치. matrixWorld 자동 계산.
|
|
107
|
+
|
|
108
|
+
## state.data 와 carrier child 의 관계
|
|
109
|
+
|
|
110
|
+
| 상황 | state.data | carrier child | InstancedMesh |
|
|
111
|
+
|---------------------------|---------------|---------------|--------------|
|
|
112
|
+
| 빈 cell | record 없음 | child 없음 | 없음 |
|
|
113
|
+
| 점유 (batched 표시중) | record 있음 | child 없음 | instance 표시 |
|
|
114
|
+
| Pick 진행 중 | record 없음 | crane child | 없음 |
|
|
115
|
+
| Place 직후 (absorb 전) | record 없음 | rack child | 없음 |
|
|
116
|
+
| Absorb 완료 | record 있음 | child 없음 | instance 표시 |
|
|
117
|
+
|
|
118
|
+
핵심: *동일 cellId 가 record + child 양쪽에 동시 존재하지 않음*. 변환 (materialize / absorb) 시 atomic 하게 한쪽만.
|
|
119
|
+
|
|
120
|
+
## 단계별 작업
|
|
121
|
+
|
|
122
|
+
### Step 1: Rack 에 새 슬롯 API 추가 (RackCell 제거 *없이*)
|
|
123
|
+
- `obtainCarrier`, `canReceiveAt`, `receiveAt`, `getCellPose` 추가
|
|
124
|
+
- `slotTargetAt(cellId)` 추가
|
|
125
|
+
- 기존 `cellComponent` / `_buildCells` 는 그대로 (호환)
|
|
126
|
+
- 변경 영향: 새 API 만 사용하는 application 코드는 새 idiom 으로 작동
|
|
127
|
+
|
|
128
|
+
### Step 2: Materialize/Absorb 흐름 검증
|
|
129
|
+
- 단위 테스트:
|
|
130
|
+
- `obtainCarrier('A')` → state.data 에서 record 빠짐 + carrier 가 rack 의 자식
|
|
131
|
+
- `receiveAt('B', carrier)` → carrier dispose + state.data 에 record push
|
|
132
|
+
- InstancedMesh rebuild 가 정확히 반영
|
|
133
|
+
- pickAndPlace 통합 테스트:
|
|
134
|
+
- `crane.pickAndPlace(rack.obtainCarrier('A'), rack.slotTargetAt('B'))` → state.data 의 A 빠지고 B 추가, Z-fight 없음, 누적 없음
|
|
135
|
+
|
|
136
|
+
### Step 3: SlotTarget 의 Mover/Crane 호환성 검증
|
|
137
|
+
- Crane.engage 가 SlotTarget.targetPose 로 fork extension 정확히 계산
|
|
138
|
+
- Mover.moveTo 가 SlotTarget.center 로 정확히 이동
|
|
139
|
+
- 양 끝점 모두 SlotTarget 인 경우 (rack→rack) 양방향 정상
|
|
140
|
+
|
|
141
|
+
### Step 4: RackCell 제거 (또는 debug-only flag 화)
|
|
142
|
+
- `_buildCells()` 호출 제거 (added() 에서)
|
|
143
|
+
- `cellComponent()` 도 새 obtainCarrier 로 위임 (호환 wrapper)
|
|
144
|
+
- 10000-cell rack 메모리 검증
|
|
145
|
+
|
|
146
|
+
### Step 5: 응용 코드 마이그레이션
|
|
147
|
+
- things-factory / operato-mms 의 기존 호출 코드 (`rack.cellComponent('A').carrier` 등) 새 API 로 갱신
|
|
148
|
+
- 마이그레이션 가이드 작성
|
|
149
|
+
|
|
150
|
+
## 위험 / 미해결 질문
|
|
151
|
+
|
|
152
|
+
1. **SlotTarget 의 `_realObject` 가 충분한가** — Crane.engage 의 getWorldPose / solveForkExtensionForLocalZ 가 SlotTarget 의 proxy object3d 로 정상 동작하는지 실측 필요. RackCell 의 StorageCell3D 가 추가로 제공하던 게 있나 검증.
|
|
153
|
+
2. **non-batched (real-time WMS 모니터링) 모드 호환성** — `state.absorbPolicy = 'keep'` 으로 처리 가능하나 application 측 결정 필요.
|
|
154
|
+
3. **Mover.computeTarget 인터페이스** — SlotTarget 의 `state`/`center`/`bounds` 가 기존과 호환되는지.
|
|
155
|
+
4. **테스트 회귀** — 현재 114 통과 테스트 중 RackCell 의존 테스트 다수. 단계별 마이그레이션 필요.
|
|
156
|
+
|
|
157
|
+
## 일정 추정
|
|
158
|
+
|
|
159
|
+
- Step 1-2: 0.5 day (작성 + 단위 테스트)
|
|
160
|
+
- Step 3: 0.5 day (Crane 호환 검증, 필요 시 SlotTarget proxy object3d 정렬 보완)
|
|
161
|
+
- Step 4: 0.5 day (eager build 제거, 회귀 fix)
|
|
162
|
+
- Step 5: 별도 (application 코드 위치에 따라 0.5-1 day)
|
|
163
|
+
|
|
164
|
+
총 ~2 day 추정. Step 1-3 마무리되면 batched pickAndPlace UX 정상화 완료. Step 4-5 는 효율/일관성 마무리.
|
package/dist/crane.js
CHANGED
|
@@ -388,7 +388,7 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
|
|
|
388
388
|
// 3. dispatch — carrier world 위치 = cell 내 attach 위치, jump 없음. Transfer 추적.
|
|
389
389
|
const comps = this.components;
|
|
390
390
|
const carrier = comps?.find?.((c) => c?._transferSlotId === 'forks')
|
|
391
|
-
?? comps?.
|
|
391
|
+
?? comps?.[0];
|
|
392
392
|
if (carrier) {
|
|
393
393
|
try {
|
|
394
394
|
new Transfer({
|