@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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.42](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.41...v10.0.0-beta.42) (2026-05-21)
7
+
8
+
9
+ ### :rocket: New Features
10
+
11
+ * **storage,scene-base:** Plan A slot API + pickAndPlace 좌표 컨벤션 수정 ([fab1970](https://github.com/things-scene/operato-scene/commit/fab1970f54863ddb7c0b17646a313aad648fd031))
12
+ * **storage:** batched 시각화 + cell-anchored popup + 클릭 라우팅 ([3acde61](https://github.com/things-scene/operato-scene/commit/3acde61be2647b41a3e550e3ee115736af0922d6))
13
+ * **storage:** RackGrid 3D frame 정밀화 + shared materials + hideHorizontalFrame ([8e65763](https://github.com/things-scene/operato-scene/commit/8e65763fedffea3b7350855ebb9b09be8289f597))
14
+ * **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)
15
+
16
+
17
+
18
+ ## [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)
19
+
20
+
21
+ ### :rocket: New Features
22
+
23
+ * **storage,mover:** Rack 자동 cell build + cellComponent helper + carrier 호환성 확장 + Mover null guard ([ed906fd](https://github.com/things-scene/operato-scene/commit/ed906fdbcc24715158aeb14cee133f6183a5dd56))
24
+ * **storage:** Box, Pallet 에도 agv-deck pickupFrame 추가 ([fc74a1a](https://github.com/things-scene/operato-scene/commit/fc74a1a44fc9d10cff9b140180974fca93884b2a))
25
+ * **storage:** Crane fork pnp 시각 정밀화 + 기하학 단순화 ([73cff2d](https://github.com/things-scene/operato-scene/commit/73cff2d4fbd02e310cdaa6144889a1ad8ebda2f4))
26
+ * **storage:** Parcel.pickupFrames 에 agv-deck frame 추가 ([be20388](https://github.com/things-scene/operato-scene/commit/be203886231523653bee94a6f3ae6953a82dd794))
27
+
28
+
29
+ ### :bug: Bug Fix
30
+
31
+ * **storage:** cell 2D 좌표 결함 + crane.simulate default + rack 시각 + parcel material hoist ([c6aa929](https://github.com/things-scene/operato-scene/commit/c6aa929255327e40859e638448ff6ae9719e8715))
32
+
33
+
34
+
6
35
  ## [10.0.0-beta.40](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.39...v10.0.0-beta.40) (2026-05-16)
7
36
 
8
37
  **Note:** Version bump only for package @operato/scene-storage
@@ -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/box.js CHANGED
@@ -88,6 +88,24 @@ let Box = class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {
88
88
  tolerance: { positionMm: 5, angleDeg: 1 },
89
89
  priority: 0,
90
90
  id: 'top-gripper'
91
+ }),
92
+ topApproachFrame({
93
+ carrierWorld: me,
94
+ topY: boxDepth,
95
+ approachDistance: 40,
96
+ toolType: 'agv-deck',
97
+ tolerance: { positionMm: 15, angleDeg: 3 },
98
+ priority: 1,
99
+ id: 'top-deck'
100
+ }),
101
+ topApproachFrame({
102
+ carrierWorld: me,
103
+ topY: boxDepth,
104
+ approachDistance: 80, // crane fork hover
105
+ toolType: 'forklift-fork',
106
+ tolerance: { positionMm: 25, angleDeg: 4 },
107
+ priority: 2,
108
+ id: 'top-fork'
91
109
  })
92
110
  ];
93
111
  }
package/dist/box.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"box.js","sourceRoot":"","sources":["../src/box.ts"],"names":[],"mappings":";AAAA;;GAEG;AACH,OAAO,EAGL,QAAQ,EACR,KAAK,EACL,gBAAgB,EAChB,YAAY,EACZ,cAAc,EACf,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EACL,SAAS,EACT,UAAU,EACV,SAAS,EAIV,MAAM,qBAAqB,CAAA;AAE5B,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAwBnC,MAAM,WAAW,GAAG;IAClB,IAAI,EAAE,SAAS;IACf,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;CACnB,CAAA;AAED,MAAM,MAAM,GAAoB;IAC9B,OAAO,EAAE,KAAK;IACd,SAAS,EAAE,IAAI;IACf,SAAS,EAAE,IAAI;IACf,UAAU,EAAE;QACV;YACE,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,UAAU;YACjB,IAAI,EAAE,UAAU;YAChB,QAAQ,EAAE;gBACR,OAAO,EAAE;oBACP,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE;oBAClC,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE;iBACzC;aACF;SACF;KACF;IACD,IAAI,EAAE,qBAAqB;CAC5B,CAAA;AAED,6EAA6E;AAC7E,kDAAkD;AAClD;;;;;;;GAOG;AAEY,IAAM,GAAG,GAAT,MAAM,GAAI,SAAQ,SAAS,CAAC,UAAU,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAGhF,MAAM,CAAC,OAAO,GAAkC;QAC9C,SAAS,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE;KACrD,CAAA;IAED,MAAM,CAAC,SAAS,GAAuB,WAAW,CAAA;IAClD,MAAM,CAAC,KAAK,GAAc,QAAQ,CAAA;IAClC,MAAM,CAAC,YAAY,GAAG,GAAG,CAAA;IAEzB,IAAI,MAAM;QACR,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,OAAO;QACT,OAAO,EAAE,CAAA;IACX,CAAC;IAED,+BAA+B;IAC/B,MAAM,CAAC,GAA6B;QAClC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAA;QAC/C,GAAG,CAAC,SAAS,EAAE,CAAA;QACf,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,CAAA;IACpC,CAAC;IAED,IAAI,SAAS;QACX,OAAQ,IAAI,CAAC,KAAK,CAAC,SAAoB,IAAI,SAAS,CAAA;IACtD,CAAC;IAED,eAAe;QACb,OAAO,IAAI,KAAK,CAAC,IAAI,CAAC,CAAA;IACxB,CAAC;IAED;;;;;;;OAOG;IACH,YAAY;QACV,MAAM,EAAE,GAAG,YAAY,CAAC,IAAI,CAAC,CAAA;QAC7B,MAAM,EAAE,GAAmB;YACzB,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE;YAClE,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE;SACrF,CAAA;QACD,MAAM,QAAQ,GAAI,IAAI,CAAC,WAAmB,CAAC,YAAY,IAAI,GAAG,CAAA;QAE9D,OAAO;YACL,gBAAgB,CAAC;gBACf,YAAY,EAAE,EAAE;gBAChB,IAAI,EAAE,QAAQ,EAAmB,iEAAiE;gBAClG,gBAAgB,EAAE,EAAE,EAAa,wBAAwB;gBACzD,QAAQ,EAAE,SAAS;gBACnB,SAAS,EAAE,EAAE,UAAU,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE;gBACzC,QAAQ,EAAE,CAAC;gBACX,EAAE,EAAE,aAAa;aAClB,CAAC;SACH,CAAA;IACH,CAAC;;AA7DkB,GAAG;IADvB,cAAc,CAAC,KAAK,CAAC;GACD,GAAG,CA8DvB;eA9DoB,GAAG","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\nimport {\n ComponentNature,\n RealObject,\n RectPath,\n Shape,\n topApproachFrame,\n getWorldPose,\n sceneComponent\n} from '@hatiolab/things-scene'\nimport type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'\nimport {\n Carriable,\n Legendable,\n Placeable,\n type Alignment,\n type LegendBinding,\n type PlacementArchetype\n} from '@operato/scene-base'\n\nimport { Box3D } from './box-3d.js'\n\n/**\n * Box material — drives 3D structure and color.\n *\n * - `wood` — wood crate: visible vertical slats, gaps between, open or\n * semi-open top. Used for heavy / industrial parts.\n * - `plastic` — plastic tote / bin: solid molded walls with stackable lip\n * at top. Used for fulfillment, parts kitting.\n *\n * Cardboard parcels are a separate component (see `parcel.ts`) — they have\n * different proportions, taping, and labels that warrant a distinct class.\n */\nexport type BoxMaterial = 'wood' | 'plastic'\n\n/** Box 컴포넌트 state */\nexport interface BoxState extends State {\n // ── 외관 ──\n material?: BoxMaterial\n\n // ── 3D 재질 ──\n material3d?: Material3D\n}\n\nconst BODY_LEGEND = {\n wood: '#a87644',\n plastic: '#3a5078',\n default: '#a87644'\n}\n\nconst NATURE: ComponentNature = {\n mutable: false,\n resizable: true,\n rotatable: true,\n properties: [\n {\n type: 'select',\n label: 'material',\n name: 'material',\n property: {\n options: [\n { display: 'Wood', value: 'wood' },\n { display: 'Plastic', value: 'plastic' }\n ]\n }\n }\n ],\n help: 'scene/component/box'\n}\n\n// Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,\n// AGV deck, robot-arm gripper, Spot for staging).\n/**\n * Box — a generic stackable container for goods. Wood crate or plastic tote\n * variants distinguished by `material` prop.\n *\n * Shape-based (not Container) — boxes nesting other components is rare in\n * logistics visualization (a *case* of items inside a box is data, not\n * scene-tree). If a future use case needs nested boxes, extend Container.\n */\n@sceneComponent('box')\nexport default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {\n declare state: BoxState\n\n static legends: Record<string, LegendBinding> = {\n bodyColor: { from: 'material', legend: BODY_LEGEND }\n }\n\n static placement: PlacementArchetype = 'operation'\n static align: Alignment = 'bottom'\n static defaultDepth = 300\n\n get nature() {\n return NATURE\n }\n\n get anchors() {\n return []\n }\n\n /** 2D — top-down rectangle. */\n render(ctx: CanvasRenderingContext2D) {\n const { width, height, left, top } = this.state\n ctx.beginPath()\n ctx.rect(left, top, width, height)\n }\n\n get fillStyle() {\n return (this.state.bodyColor as string) || '#a87644'\n }\n\n buildRealObject(): RealObject | undefined {\n return new Box3D(this)\n }\n\n /**\n * Phase H — pickup contract. Box 는 위에서 gripper / vacuum cup 으로 집기 —\n * 단일 entry (top center). Box 의 dimensions 가 작아서 forklift fork 보다는\n * gripper 가 일반적. forklift 로 들어올릴 box 는 통상 pallet 위에 stacking\n * 후 pallet 째로 운반.\n *\n * tolerance 가 pallet 보다 빡빡 (gripper 정밀도 vs forklift pocket 폭).\n */\n pickupFrames(): PickupFrame[] {\n const wp = getWorldPose(this)\n const me: PoseSerialized = {\n position: { x: wp.position.x, y: wp.position.y, z: wp.position.z },\n rotation: { x: wp.rotation.x, y: wp.rotation.y, z: wp.rotation.z, w: wp.rotation.w }\n }\n const boxDepth = (this.constructor as any).defaultDepth ?? 300\n\n return [\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth, // Box top in carrier-local Y (depth = full height; top at depth)\n approachDistance: 50, // gripper 가 hover 하는 거리\n toolType: 'gripper',\n tolerance: { positionMm: 5, angleDeg: 1 },\n priority: 0,\n id: 'top-gripper'\n })\n ]\n }\n}\n"]}
1
+ {"version":3,"file":"box.js","sourceRoot":"","sources":["../src/box.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\nimport {\n ComponentNature,\n RealObject,\n RectPath,\n Shape,\n topApproachFrame,\n getWorldPose,\n sceneComponent\n} from '@hatiolab/things-scene'\nimport type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'\nimport {\n Carriable,\n Legendable,\n Placeable,\n type Alignment,\n type LegendBinding,\n type PlacementArchetype\n} from '@operato/scene-base'\n\nimport { Box3D } from './box-3d.js'\n\n/**\n * Box material — drives 3D structure and color.\n *\n * - `wood` — wood crate: visible vertical slats, gaps between, open or\n * semi-open top. Used for heavy / industrial parts.\n * - `plastic` — plastic tote / bin: solid molded walls with stackable lip\n * at top. Used for fulfillment, parts kitting.\n *\n * Cardboard parcels are a separate component (see `parcel.ts`) — they have\n * different proportions, taping, and labels that warrant a distinct class.\n */\nexport type BoxMaterial = 'wood' | 'plastic'\n\n/** Box 컴포넌트 state */\nexport interface BoxState extends State {\n // ── 외관 ──\n material?: BoxMaterial\n\n // ── 3D 재질 ──\n material3d?: Material3D\n}\n\nconst BODY_LEGEND = {\n wood: '#a87644',\n plastic: '#3a5078',\n default: '#a87644'\n}\n\nconst NATURE: ComponentNature = {\n mutable: false,\n resizable: true,\n rotatable: true,\n properties: [\n {\n type: 'select',\n label: 'material',\n name: 'material',\n property: {\n options: [\n { display: 'Wood', value: 'wood' },\n { display: 'Plastic', value: 'plastic' }\n ]\n }\n }\n ],\n help: 'scene/component/box'\n}\n\n// Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,\n// AGV deck, robot-arm gripper, Spot for staging).\n/**\n * Box — a generic stackable container for goods. Wood crate or plastic tote\n * variants distinguished by `material` prop.\n *\n * Shape-based (not Container) — boxes nesting other components is rare in\n * logistics visualization (a *case* of items inside a box is data, not\n * scene-tree). If a future use case needs nested boxes, extend Container.\n */\n@sceneComponent('box')\nexport default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {\n declare state: BoxState\n\n static legends: Record<string, LegendBinding> = {\n bodyColor: { from: 'material', legend: BODY_LEGEND }\n }\n\n static placement: PlacementArchetype = 'operation'\n static align: Alignment = 'bottom'\n static defaultDepth = 300\n\n get nature() {\n return NATURE\n }\n\n get anchors() {\n return []\n }\n\n /** 2D — top-down rectangle. */\n render(ctx: CanvasRenderingContext2D) {\n const { width, height, left, top } = this.state\n ctx.beginPath()\n ctx.rect(left, top, width, height)\n }\n\n get fillStyle() {\n return (this.state.bodyColor as string) || '#a87644'\n }\n\n buildRealObject(): RealObject | undefined {\n return new Box3D(this)\n }\n\n /**\n * Phase H — pickup contract. Box 는 위에서 gripper / vacuum cup 으로 집기 —\n * 단일 entry (top center). Box 의 dimensions 가 작아서 forklift fork 보다는\n * gripper 가 일반적. forklift 로 들어올릴 box 는 통상 pallet 위에 stacking\n * 후 pallet 째로 운반.\n *\n * tolerance 가 pallet 보다 빡빡 (gripper 정밀도 vs forklift pocket 폭).\n */\n pickupFrames(): PickupFrame[] {\n const wp = getWorldPose(this)\n const me: PoseSerialized = {\n position: { x: wp.position.x, y: wp.position.y, z: wp.position.z },\n rotation: { x: wp.rotation.x, y: wp.rotation.y, z: wp.rotation.z, w: wp.rotation.w }\n }\n const boxDepth = (this.constructor as any).defaultDepth ?? 300\n\n return [\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth, // Box top in carrier-local Y (depth = full height; top at depth)\n approachDistance: 50, // gripper 가 hover 하는 거리\n toolType: 'gripper',\n tolerance: { positionMm: 5, angleDeg: 1 },\n priority: 0,\n id: 'top-gripper'\n }),\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth,\n approachDistance: 40,\n toolType: 'agv-deck',\n tolerance: { positionMm: 15, angleDeg: 3 },\n priority: 1,\n id: 'top-deck'\n }),\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth,\n approachDistance: 80, // crane fork hover\n toolType: 'forklift-fork',\n tolerance: { positionMm: 25, angleDeg: 4 },\n priority: 2,\n id: 'top-fork'\n })\n ]\n }\n}\n"]}
@@ -2,13 +2,58 @@ import * as THREE from 'three';
2
2
  import { RealObjectGroup } from '@hatiolab/things-scene';
3
3
  export declare class Crane3D extends RealObjectGroup {
4
4
  private _forkGroup?;
5
- private _forkTopY;
5
+ private _carrierBaseY;
6
6
  private _bladeMidZ;
7
+ /** floor rail 만 제외한 나머지 (trolley + masts + carriage + fork) 의 movable parent. */
8
+ private _trolleyGroup?;
9
+ /** carriage + fork 의 lift parent (carriageHeight + forkLiftRT 변경 시 Y 만 update). */
10
+ private _carriageLiftGroup?;
11
+ /** Fork active extension mesh — scale.z 와 position.z 로 lerp (rebuild 없이). */
12
+ private _extLeftMesh?;
13
+ private _extRightMesh?;
14
+ private _extBaseParams?;
15
+ /** Fork mesh 의 group-local Y center (carriage 위 + bladeH/2). _applyForkExtensionMeshes 가 ext mesh Y 결정. */
16
+ private _forkOffsetY;
17
+ /** liftGroup.position.y 재계산용 base parameters. */
18
+ private _liftBaseParams?;
7
19
  build(): void;
8
20
  getCarriageFrame(): THREE.Object3D | undefined;
9
- get platformTopY(): number;
21
+ /**
22
+ * Fork blade *bottom* 의 liftGroup-local Y. *carrier 외부 bottom 정렬점*.
23
+ *
24
+ * 모델: carrier 의 외부 bottom 과 fork blade 의 bottom 이 *거의 일치*. fork 가
25
+ * carrier 의 bottom 부분을 *찔러 들어가* carrier 와 *겹친 자세* (pallet pocket
26
+ * 안 fork 진입). attachPointFor 가 `carrierBaseY + carrier.depth/2` 로 carrier
27
+ * center 를 정렬 → carrier bottom = fork blade bottom.
28
+ */
29
+ get carrierBaseY(): number;
10
30
  get bladeMidZ(): number;
11
31
  updateDimension(): void;
12
32
  onchange(after: Record<string, unknown>, before: Record<string, unknown>): void;
33
+ /** carriageHeight + forkLiftRT 를 liftGroup.position.y 로 변환. */
34
+ private _computeLiftGroupY;
35
+ /**
36
+ * Carrier 외부 bottom 의 world Y → carriageHeight state 값 inverse-solve.
37
+ *
38
+ * Forward 공식 (build):
39
+ * liftGroup.y crane-local = baseTrolleyY + baseH/2 + carriageHeight + forkLift + carriageH/2
40
+ * carrier 외부 bottom crane-local = liftGroup.y + carrierBaseY (= -bladeH/2)
41
+ * = baseTrolleyY + baseH/2 + carriageH/2 - bladeH/2 + carriageHeight + forkLift
42
+ *
43
+ * Inverse:
44
+ * carriageHeight = worldY − craneCenterY − (baseTrolleyY + baseH/2 + carriageH/2 − bladeH/2) − forkLift
45
+ */
46
+ solveCarriageHeightForCarrierBaseWorldY(worldY: number, forkLift?: number): number;
47
+ /**
48
+ * target 의 *crane-local Z* → fork extension 값 inverse-solve.
49
+ *
50
+ * Forward (_applyForkExtensionMeshes): `_bladeMidZ = sign * absExt`
51
+ * (carrier 가 ext 만큼 fork 따라 진출). Inverse: `ext = |localZ|, sign = sign(localZ)`.
52
+ *
53
+ * forkLength 로 clamp — localZ 가 forkLength 보다 멀면 carrier 가 fork tip 까지만.
54
+ */
55
+ solveForkExtensionForLocalZ(localZ: number): number;
56
+ /** Fork active extension mesh 의 scale.z + position.z + visibility update. */
57
+ private _applyForkExtensionMeshes;
13
58
  updateAlpha(): void;
14
59
  }