@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.
- package/CHANGELOG.md +29 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/box.js +18 -0
- package/dist/box.js.map +1 -1
- package/dist/crane-3d.d.ts +47 -2
- package/dist/crane-3d.js +246 -89
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +96 -12
- package/dist/crane.js +395 -100
- 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/pallet.d.ts +15 -0
- package/dist/pallet.js +38 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel-3d.js +22 -18
- package/dist/parcel-3d.js.map +1 -1
- package/dist/parcel.d.ts +4 -3
- package/dist/parcel.js +24 -5
- package/dist/parcel.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 +165 -29
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +253 -32
- package/dist/storage-rack.js +726 -66
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box.ts +18 -0
- package/src/crane-3d.ts +258 -93
- package/src/crane.ts +445 -110
- package/src/index.ts +3 -4
- package/src/pallet.ts +50 -1
- package/src/parcel-3d.ts +23 -18
- package/src/parcel.ts +24 -5
- 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 +182 -29
- package/src/storage-rack.ts +819 -67
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-crane-geometry.ts +167 -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-phase-h-carrier-pickable.ts +4 -3
- 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 +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
- 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 -70
- package/dist/storage-cell.js +0 -197
- 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 -247
- package/test/test-rack-grid.ts +0 -77
package/dist/storage-rack.js
CHANGED
|
@@ -3,7 +3,8 @@ import { __decorate } from "tslib";
|
|
|
3
3
|
* Copyright © HatioLab Inc. All rights reserved.
|
|
4
4
|
*/
|
|
5
5
|
import { Component, ContainerAbstract, sceneComponent } from '@hatiolab/things-scene';
|
|
6
|
-
import
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
import { CellContainer, CellMap, CarrierHolder, Placeable, SlotTarget } from '@operato/scene-base';
|
|
7
8
|
import { StorageRack3D } from './storage-rack-3d.js';
|
|
8
9
|
const NATURE = {
|
|
9
10
|
mutable: false,
|
|
@@ -21,6 +22,31 @@ const NATURE = {
|
|
|
21
22
|
label: 'bays',
|
|
22
23
|
name: 'bays',
|
|
23
24
|
placeholder: '# of horizontal bays (default 5)'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: 'number',
|
|
28
|
+
label: 'shelf-base-height',
|
|
29
|
+
name: 'shelfBaseHeight',
|
|
30
|
+
placeholder: 'mm — level 1 시작 높이 (바닥부터). stocker port / conveyor 공간.'
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'id-input',
|
|
34
|
+
label: 'legend-target',
|
|
35
|
+
name: 'legendTarget',
|
|
36
|
+
property: { component: 'legend' },
|
|
37
|
+
placeholder: '미명시 시 scene 의 legend 자동 발견'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: 'id-input',
|
|
41
|
+
label: 'popup-ref',
|
|
42
|
+
name: 'popupRef',
|
|
43
|
+
property: { component: 'popup' },
|
|
44
|
+
placeholder: 'click 시 invoke 할 Popup 컴포넌트 id (anchor 는 클릭된 cell 로 override)'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'checkbox',
|
|
48
|
+
label: 'hide-horizontal-frame',
|
|
49
|
+
name: 'hideHorizontalFrame'
|
|
24
50
|
}
|
|
25
51
|
],
|
|
26
52
|
help: 'scene/component/rack'
|
|
@@ -34,22 +60,27 @@ const NATURE = {
|
|
|
34
60
|
// CarrierHolder: 3D attach-point protocol (attachPointFor, containable gates)
|
|
35
61
|
// Placeable: floor-archetype positioning
|
|
36
62
|
// ContainerAbstract: child component management
|
|
63
|
+
// Transient carrier refid generator — load 시 root._addTraverse 가 자식 부터 처리
|
|
64
|
+
// 하면서 cell.model.refid 가 비어있으면 root.getNewRefid() = 1,2,3... 작은 값 부여
|
|
65
|
+
// → *부모 Rack + 모델 안 다른 컴포넌트* refid 와 충돌 ("Refid Index replaced" 경고).
|
|
66
|
+
// transient materialize 시 *큰 시작값 + monotonic counter* 로 자체 부여 → 충돌 0.
|
|
67
|
+
// hierarchy override / state.data 만 저장 → 모델 파일엔 refid 안 새겨짐.
|
|
68
|
+
let _carrierRefidCounter = 200_000_000;
|
|
69
|
+
function _nextCarrierRefid() {
|
|
70
|
+
return _carrierRefidCounter++;
|
|
71
|
+
}
|
|
37
72
|
/**
|
|
38
73
|
* Rack — a multi-level storage shelf system. A *Storage* whose role is to hold
|
|
39
74
|
* carriers in a (bay × level) grid of cells.
|
|
40
75
|
*
|
|
41
76
|
* `levels` × `bays` cells form a vertical grid. Each cell holds one logistics
|
|
42
77
|
* package (Pallet / Box / Parcel). A picker (Crane / Forklift / robot arm)
|
|
43
|
-
* accesses individual cells via
|
|
44
|
-
*
|
|
78
|
+
* accesses individual cells via the Plan A slot API — the picker interacts
|
|
79
|
+
* with `SlotTarget` (no explicit cell-component required).
|
|
45
80
|
*
|
|
46
|
-
* **
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* **Simulation mode**: call `rack._buildCells()` after placing the rack on the
|
|
50
|
-
* scene. This creates RackCell children at the correct 3D positions. A picker
|
|
51
|
-
* (Crane / Forklift / ...) then navigates to individual RackCells for
|
|
52
|
-
* pick-and-place.
|
|
81
|
+
* **Plan A**: carriers are direct children of the rack, addressed by
|
|
82
|
+
* `state.cellId`. Stock visualization uses InstancedMesh (batched). Slot
|
|
83
|
+
* lookup / pick / place via `obtainCarrier` / `receiveAt` / `slotTargetAt`.
|
|
53
84
|
*
|
|
54
85
|
* **Placement**: `floor` archetype, full ceiling depth by default.
|
|
55
86
|
*
|
|
@@ -67,6 +98,25 @@ let Rack = class Rack extends CellContainer(CarrierHolder(Placeable(ContainerAbs
|
|
|
67
98
|
get anchors() {
|
|
68
99
|
return [];
|
|
69
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Runtime — bays / levels 변경 시 anchor 캐시 무효화. cell 위치가 바뀌므로 다음
|
|
103
|
+
* `_ensureCellAttachObject3d` 호출이 새 좌표로 갱신.
|
|
104
|
+
*/
|
|
105
|
+
onchange(after, _before) {
|
|
106
|
+
super.onchange?.(after, _before);
|
|
107
|
+
if ('bays' in after ||
|
|
108
|
+
'levels' in after ||
|
|
109
|
+
'shelfBaseHeight' in after ||
|
|
110
|
+
'width' in after ||
|
|
111
|
+
'height' in after ||
|
|
112
|
+
'depth' in after) {
|
|
113
|
+
this._attachAnchorByCell.clear();
|
|
114
|
+
}
|
|
115
|
+
if ('hideHorizontalFrame' in after) {
|
|
116
|
+
;
|
|
117
|
+
this._realObject?.applyFrameVisibility?.();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
70
120
|
// ── CellContainer ─────────────────────────────────────────────────────────
|
|
71
121
|
/**
|
|
72
122
|
* Derive the cell topology from the rack's current dimensions and bay/level
|
|
@@ -84,62 +134,28 @@ let Rack = class Rack extends CellContainer(CarrierHolder(Placeable(ContainerAbs
|
|
|
84
134
|
const width = this.state.width || 1000;
|
|
85
135
|
const rackDepth = this.state.depth || 3000; // Y: floor→ceiling
|
|
86
136
|
const rackHeight = this.state.height || 600; // Z: front→back
|
|
137
|
+
const shelfBase = Math.max(0, Math.min(this.state.shelfBaseHeight || 0, rackDepth * 0.9 // clamp ≤ 90% — 최소 shelf zone
|
|
138
|
+
));
|
|
139
|
+
const shelfZone = rackDepth - shelfBase; // 실제 shelf 가 차지하는 Y 영역
|
|
87
140
|
return CellMap.grid({
|
|
88
141
|
bays,
|
|
89
142
|
rows: 1,
|
|
90
143
|
levels,
|
|
91
144
|
bayWidth: width / bays,
|
|
92
145
|
rowDepth: rackHeight,
|
|
93
|
-
levelHeight:
|
|
146
|
+
levelHeight: shelfZone / levels,
|
|
147
|
+
origin: { x: 0, y: shelfBase, z: 0 } // 첫 cell 의 Y = shelfBase
|
|
94
148
|
});
|
|
95
149
|
}
|
|
96
|
-
/**
|
|
97
|
-
* Create RackCell child components for each cell in the CellMap.
|
|
98
|
-
*
|
|
99
|
-
* Called explicitly to enter simulation mode — monitoring-mode racks
|
|
100
|
-
* never call this (carriers are direct children, no explicit cells).
|
|
101
|
-
*
|
|
102
|
-
* Idempotent: removes existing rack-cell children first.
|
|
103
|
-
*/
|
|
104
|
-
_buildCells() {
|
|
105
|
-
// Remove existing rack-cell children
|
|
106
|
-
const existing = this.components ?? [];
|
|
107
|
-
for (const child of [...existing]) {
|
|
108
|
-
if (child.state?.type === 'storage-cell') {
|
|
109
|
-
this.removeComponent(child);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
// Create a RackCell for each cell in the map
|
|
113
|
-
const RackCellClass = Component.register('storage-cell');
|
|
114
|
-
if (!RackCellClass) {
|
|
115
|
-
console.warn('Rack._buildCells: rack-cell type not registered. Import rack-cell.ts first.');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const context = this._app;
|
|
119
|
-
for (const cell of this.cellMap.cells) {
|
|
120
|
-
const model = {
|
|
121
|
-
type: 'storage-cell',
|
|
122
|
-
cellId: cell.id,
|
|
123
|
-
width: cell.size.width,
|
|
124
|
-
height: cell.size.depth, // 2D height = 3D Z depth
|
|
125
|
-
depth: cell.size.height // 3D Y = level height
|
|
126
|
-
};
|
|
127
|
-
const rackCell = new RackCellClass(model, context);
|
|
128
|
-
this.addComponent(rackCell);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
150
|
// ── Container gates ───────────────────────────────────────────────────────
|
|
132
151
|
/**
|
|
133
152
|
* Allow:
|
|
134
|
-
* - Carriable components (pallets, boxes, parcels) — direct children
|
|
135
|
-
* - RackCell — created by _buildCells() in simulation mode.
|
|
153
|
+
* - Carriable components (pallets, boxes, parcels) — direct children, operation archetype.
|
|
136
154
|
*
|
|
137
155
|
* Block:
|
|
138
156
|
* - Everything else (sensors, labels, etc. can be siblings of the rack, not children).
|
|
139
157
|
*/
|
|
140
158
|
containable(component) {
|
|
141
|
-
if (component.state?.type === 'storage-cell')
|
|
142
|
-
return true;
|
|
143
159
|
const archetype = component.constructor.placement;
|
|
144
160
|
if (archetype === 'operation')
|
|
145
161
|
return true;
|
|
@@ -147,42 +163,686 @@ let Rack = class Rack extends CellContainer(CarrierHolder(Placeable(ContainerAbs
|
|
|
147
163
|
}
|
|
148
164
|
// ── CarrierHolder — attach frame for direct carrier children ─────────────
|
|
149
165
|
/**
|
|
150
|
-
* Attach frame for carriers
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* In simulation mode, carriers become children of their RackCell,
|
|
155
|
-
* and each RackCell provides its own attachPointFor(). So this method
|
|
156
|
-
* is only invoked on direct-child carriers in monitoring mode — it
|
|
157
|
-
* returns the rack's own object3d as the attach frame (default behavior).
|
|
166
|
+
* Attach frame for direct-child carriers — Plan A 의 모든 carrier 가 rack 의
|
|
167
|
+
* 직접 자식이므로 매번 호출됨. carrier 의 state.cellId 에 해당하는 *cell-local
|
|
168
|
+
* anchor object3d* 를 반환 → carrier 의 object3d 가 자동으로 셀 위치에 정렬됨.
|
|
158
169
|
*/
|
|
159
|
-
attachPointFor(
|
|
170
|
+
attachPointFor(carrier) {
|
|
171
|
+
// Plan A: rack 의 직접 자식 carrier 는 *state.cellId* 로 슬롯 위치를 표시 (매트릭스
|
|
172
|
+
// 내 주소). attachPointFor 는 그 cellId 의 *셀-로컬 anchor object3d* 를 반환 →
|
|
173
|
+
// carrier 의 object3d 가 자동으로 셀 위치에 정렬됨.
|
|
174
|
+
//
|
|
175
|
+
// localPosition: {0,0,0} 명시 — Carriable.applyHolderAttachPoint 가 Three.js
|
|
176
|
+
// attach() 후 *world pose 보존* 만 하고 local 을 reset 하지 않는 결함 우회.
|
|
177
|
+
// 명시 시 (Carriable + CarrierHolder.reparent 모두) 그대로 anchor origin 에 snap.
|
|
178
|
+
const cellId = carrier?.state?.cellId;
|
|
179
|
+
if (cellId) {
|
|
180
|
+
const obj = this._ensureCellAttachObject3d(cellId);
|
|
181
|
+
if (obj)
|
|
182
|
+
return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } };
|
|
183
|
+
}
|
|
184
|
+
// Fallback — cellId 없는 (legacy) 호출 시 rack root.
|
|
160
185
|
const root = this._realObject?.object3d;
|
|
161
186
|
if (!root)
|
|
162
187
|
return null;
|
|
163
|
-
return { attach: root };
|
|
188
|
+
return { attach: root, localPosition: { x: 0, y: 0, z: 0 } };
|
|
189
|
+
}
|
|
190
|
+
// ── Plan A — Slot API (LoopSorter-style; "rack 안 = 데이터, 밖 = 컴포넌트") ───
|
|
191
|
+
//
|
|
192
|
+
// Conceptual model:
|
|
193
|
+
// - rack 안의 carrier 는 *state.data 의 한 record* (데이터)
|
|
194
|
+
// - rack 밖의 carrier 는 *Component* (실재)
|
|
195
|
+
// - 경계 통과 시 transient materialize / atomic absorb
|
|
196
|
+
//
|
|
197
|
+
// 불변식: 동일 cellId 가 state.data 의 record 와 rack-child carrier 양쪽에 동시
|
|
198
|
+
// 존재하지 않음. obtainCarrier 와 receiveAt 가 atomic 하게 전환.
|
|
199
|
+
//
|
|
200
|
+
// 호출 흐름:
|
|
201
|
+
// - Pick: const c = rack.obtainCarrier('A-0-0') → c 는 rack child, record 빠짐
|
|
202
|
+
// await crane.pick(c) → c.parent = crane (rack child 에서도 빠짐)
|
|
203
|
+
// - Place: await crane.place(c, destRack.slotTargetAt('B-0-0'))
|
|
204
|
+
// SlotTarget.receive → destRack.receiveAt → c dispose + record push
|
|
205
|
+
/** state.data 의 record 목록 (읽기 전용 뷰). */
|
|
206
|
+
get records() {
|
|
207
|
+
return this.state.data ?? [];
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 1-based (bay, row, level) → 0-based cellId 문자열.
|
|
211
|
+
*
|
|
212
|
+
* rack.cellIdOf(1, 1, 6) → '0-0-5'
|
|
213
|
+
* rack.cellIdOf(3, 1, 4) → '2-0-3'
|
|
214
|
+
*/
|
|
215
|
+
cellIdOf(bay, row = 1, level = 1) {
|
|
216
|
+
return `${bay - 1}-${row - 1}-${level - 1}`;
|
|
217
|
+
}
|
|
218
|
+
/** cellId 에 carrier 가 있는가 — child carrier 또는 state.data record 어느 쪽이든. */
|
|
219
|
+
hasCarrierAt(cellId) {
|
|
220
|
+
if (this._carrierChildAt(cellId))
|
|
221
|
+
return true;
|
|
222
|
+
return this.records.some(r => r.cellId === cellId);
|
|
223
|
+
}
|
|
224
|
+
/** cellId 매칭되는 rack 의 직접 자식 carrier (operation archetype). */
|
|
225
|
+
_carrierChildAt(cellId) {
|
|
226
|
+
const children = this.components ?? [];
|
|
227
|
+
return children.find(c => {
|
|
228
|
+
const placement = c.constructor.placement;
|
|
229
|
+
return placement === 'operation' && c.state?.cellId === cellId;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
|
|
234
|
+
* materialize 후 rack 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
|
|
235
|
+
* record 도 child 도 없으면 null.
|
|
236
|
+
*
|
|
237
|
+
* Signature overloads:
|
|
238
|
+
* obtainCarrier('0-0-5') — string cellId 직접
|
|
239
|
+
* obtainCarrier(1, 1, 6) — 1-based (bay, row, level)
|
|
240
|
+
* obtainCarrier(1) ≡ obtainCarrier(1,1,1)
|
|
241
|
+
*/
|
|
242
|
+
obtainCarrier(idOrBay, row, level) {
|
|
243
|
+
const cellId = typeof idOrBay === 'string'
|
|
244
|
+
? idOrBay
|
|
245
|
+
: this.cellIdOf(idOrBay, row ?? 1, level ?? 1);
|
|
246
|
+
const existing = this._carrierChildAt(cellId);
|
|
247
|
+
if (existing)
|
|
248
|
+
return existing;
|
|
249
|
+
const records = this.records;
|
|
250
|
+
const idx = records.findIndex(r => r?.cellId === cellId);
|
|
251
|
+
if (idx === -1)
|
|
252
|
+
return null;
|
|
253
|
+
const record = records[idx];
|
|
254
|
+
const carrierType = record.type || 'parcel';
|
|
255
|
+
const CarrierClass = Component.register(carrierType);
|
|
256
|
+
if (!CarrierClass) {
|
|
257
|
+
console.warn(`[storage-rack] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// 크기 기본값 — rack 기하에서 derive (record 가 명시 안 한 경우)
|
|
261
|
+
const rs = this.state;
|
|
262
|
+
const rackWidth = rs?.width || 1000;
|
|
263
|
+
const rackDepth = rs?.depth || 3000;
|
|
264
|
+
const rackHeight = rs?.height || 600;
|
|
265
|
+
const bays = Math.max(1, Math.floor(rs?.bays || 5));
|
|
266
|
+
const levels = Math.max(1, Math.floor(rs?.levels || 4));
|
|
267
|
+
const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9));
|
|
268
|
+
const shelfZone = rackDepth - shelfBase;
|
|
269
|
+
const bayWidth = rackWidth / bays;
|
|
270
|
+
const levelHeight = shelfZone / levels;
|
|
271
|
+
// record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
|
|
272
|
+
// component 와 충돌해 parent 가 잘못 잡힘 (model-layer 의 원본을 재사용 등).
|
|
273
|
+
const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record;
|
|
274
|
+
const carrierW = record.width ?? bayWidth * 0.85;
|
|
275
|
+
const carrierH = record.height ?? rackHeight * 0.85;
|
|
276
|
+
// cell 의 *rack-inner 좌표* (= carrier 의 parent=rack 의 inner 좌표계 의 점).
|
|
277
|
+
// carrier.center = carrier.state.left + carrierW/2 가 *rack-inner 좌표 의 cell center*
|
|
278
|
+
// 이어야 Crane.moveTo (target.center → target.toScene) 가 정확한 layer 좌표로 변환.
|
|
279
|
+
// 이전엔 left=0, top=0 → center=(carrierW/2, carrierH/2) = bay 0 의 좌상단 근처 → fork 어긋남.
|
|
280
|
+
const cellInfo = this.cellMap?.findById(cellId);
|
|
281
|
+
const bayIdx = (cellInfo?.bay ?? 1) - 1;
|
|
282
|
+
const cellCenterInnerX = bayIdx * bayWidth + bayWidth / 2;
|
|
283
|
+
const cellCenterInnerY = rackHeight / 2;
|
|
284
|
+
const carrierState = {
|
|
285
|
+
...recordCopy,
|
|
286
|
+
type: carrierType,
|
|
287
|
+
cellId, // 슬롯 주소
|
|
288
|
+
refid: _nextCarrierRefid(), // refid 충돌 회피
|
|
289
|
+
width: carrierW,
|
|
290
|
+
height: carrierH,
|
|
291
|
+
depth: record.depth ?? levelHeight * 0.7,
|
|
292
|
+
left: cellCenterInnerX - carrierW / 2,
|
|
293
|
+
top: cellCenterInnerY - carrierH / 2
|
|
294
|
+
};
|
|
295
|
+
const carrier = new CarrierClass(carrierState, this._app);
|
|
296
|
+
this.addComponent(carrier, { silent: true });
|
|
297
|
+
// 3D 강제 빌드 + attach (pipeline tick 없이도 즉시 표시 — Mover 가 곧 pick).
|
|
298
|
+
void carrier.realObject;
|
|
299
|
+
carrier.applyHolderAttachPoint?.();
|
|
300
|
+
// record 제거 — atomic: child 추가 직후. 동일 cellId 의 *모든* record 정리.
|
|
301
|
+
// *silent* 갱신 — mapping cascade (onchangeData → executeMappings → script fire)
|
|
302
|
+
// 회피. external (WMS push) 호출은 setState 거치므로 그쪽 mapping 은 정상 발사.
|
|
303
|
+
this._setDataSilently(records.filter(r => r?.cellId !== cellId));
|
|
304
|
+
return carrier;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* State.data 의 *internal* 갱신 — Plan A 의 obtainCarrier / receiveAt 가 사용.
|
|
308
|
+
* `setState` 와 달리 *'change' 이벤트 / onchangeData / mapping cascade 를 우회*.
|
|
309
|
+
*
|
|
310
|
+
* 이유: mapping 시스템이 state.data 변경 시 *자동으로 script fire*. Plan A 의
|
|
311
|
+
* setState 가 그 cascade 를 트리거하면 사용자 script 가 *재귀적으로 자기 자신을 호출*
|
|
312
|
+
* 하는 회귀 (board 의 의도된 binding 일 수도, 우연일 수도) 발생.
|
|
313
|
+
*
|
|
314
|
+
* 대신 *직접 _state 갱신 + rebuildStockMesh 직접 호출* — 시각화는 갱신되지만 외부
|
|
315
|
+
* mapping 은 fire 안 됨. External (WMS / application setState) 호출은 그대로 setState
|
|
316
|
+
* 거치므로 그쪽 mapping 은 정상 동작.
|
|
317
|
+
*/
|
|
318
|
+
_setDataSilently(newData) {
|
|
319
|
+
const self = this;
|
|
320
|
+
if (!self._state)
|
|
321
|
+
self._state = {};
|
|
322
|
+
self._state.data = newData;
|
|
323
|
+
self._cachedState = null // state getter 가 다음 read 때 fresh build
|
|
324
|
+
;
|
|
325
|
+
this._realObject?.rebuildStockMesh?.();
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* cell 이 carrier 를 받을 수 있는가.
|
|
329
|
+
*
|
|
330
|
+
* 규칙:
|
|
331
|
+
* - state.data 에 record 가 있으면 점유 → false
|
|
332
|
+
* - carrier-child 가 있고 *그 child 가 들여오려는 carrier 자기 자신이 아니면* → false
|
|
333
|
+
* - 들여오려는 carrier 가 *바로 그 cell 의 child 자기 자신* 이면 → true (idempotent —
|
|
334
|
+
* obtain('A') 직후 receive('A', sameCarrier) 가 *자기 자리 복귀* 로 동작)
|
|
335
|
+
*/
|
|
336
|
+
canReceiveAt(cellId, carrier) {
|
|
337
|
+
const records = this.records;
|
|
338
|
+
if (records.some(r => r?.cellId === cellId))
|
|
339
|
+
return false;
|
|
340
|
+
const existingChild = this._carrierChildAt(cellId);
|
|
341
|
+
if (existingChild && existingChild !== carrier)
|
|
342
|
+
return false;
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Carrier 가 rack 의 slot 으로 들어옴 — "매트릭스 진입": 즉시 dispose + state.data 에
|
|
347
|
+
* record 로 환원. 결과: InstancedMesh 가 다시 그 자리에 instance 표시, rack 의 자식
|
|
348
|
+
* 컴포넌트 트리는 깨끗.
|
|
349
|
+
*/
|
|
350
|
+
async receiveAt(cellId, carrier, _options) {
|
|
351
|
+
// R18 guard — disposed carrier 의 재처리 차단.
|
|
352
|
+
if (carrier?._disposed) {
|
|
353
|
+
throw new Error(`Rack.receiveAt("${cellId}"): carrier is already disposed. ` +
|
|
354
|
+
'After a successful pickAndPlace the carrier becomes a state.data record — ' +
|
|
355
|
+
'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.');
|
|
356
|
+
}
|
|
357
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
358
|
+
;
|
|
359
|
+
this.trigger?.('transfer-rejected', {
|
|
360
|
+
type: 'transfer-rejected',
|
|
361
|
+
component: carrier, container: this, reason: 'slot-occupied', cellId
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const record = this.recordFromCarrier(carrier, cellId);
|
|
366
|
+
// Carrier 의 parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 rack 자신.
|
|
367
|
+
const carrierParent = carrier.parent;
|
|
368
|
+
if (carrierParent && typeof carrierParent.removeComponent === 'function') {
|
|
369
|
+
carrierParent.removeComponent(carrier);
|
|
370
|
+
}
|
|
371
|
+
// 명시적 Three.js detach — RealObject.dispose() 의 clear() 는 *object3d 의 children*
|
|
372
|
+
// 만 정리하고 *object3d 자체* 는 scene graph 의 parent 에서 *떼지 않음*. 결과:
|
|
373
|
+
// crane fork 또는 slot anchor 에 attach 됐던 object3d 가 빈 상태로 남아 ghost.
|
|
374
|
+
// dispose 호출 *전* 에 명시적으로 parent.remove. (geometry/material dispose 는
|
|
375
|
+
// RealObject.dispose() 의 clear() 에 위임 — 공유 material 정책 framework 책임.)
|
|
376
|
+
const carrierObj3d = carrier._realObject?.object3d;
|
|
377
|
+
if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
|
|
378
|
+
carrierObj3d.parent.remove(carrierObj3d);
|
|
379
|
+
}
|
|
380
|
+
;
|
|
381
|
+
carrier.dispose?.();
|
|
382
|
+
// state.data 에 push — *silent* 갱신 (mapping cascade 회피, 위 _setDataSilently 주석 참조).
|
|
383
|
+
// 중복 방어: 동일 cellId 의 기존 record 가 있으면 제거 후 새 record 단일 추가.
|
|
384
|
+
const currentRecords = this.records.filter(r => r?.cellId !== cellId);
|
|
385
|
+
this._setDataSilently([...currentRecords, record]);
|
|
386
|
+
this.trigger?.('transfer-received', {
|
|
387
|
+
type: 'transfer-received',
|
|
388
|
+
component: carrier, container: this, slotId: cellId, record
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Carrier 의 state 를 state.data record 로 추출. application 이 carrier subclass 별
|
|
393
|
+
* 추가 필드 인코딩 원하면 override. transform/position 관련은 record 와 무관해 skip.
|
|
394
|
+
*/
|
|
395
|
+
recordFromCarrier(carrier, cellId) {
|
|
396
|
+
const state = carrier.state ?? {};
|
|
397
|
+
const SKIP_KEYS = new Set([
|
|
398
|
+
'left', 'top', 'zPos',
|
|
399
|
+
'transform', 'rotation', 'scale',
|
|
400
|
+
'_transferSlotId',
|
|
401
|
+
'cellId', // 새로 override
|
|
402
|
+
'id', // 다음 materialize 시 충돌 방지 (id 는 component-instance 의 것, record 의 것 아님)
|
|
403
|
+
'refid'
|
|
404
|
+
]);
|
|
405
|
+
const record = { cellId, type: state.type };
|
|
406
|
+
for (const key of Object.keys(state)) {
|
|
407
|
+
if (SKIP_KEYS.has(key))
|
|
408
|
+
continue;
|
|
409
|
+
record[key] = state[key];
|
|
410
|
+
}
|
|
411
|
+
return record;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* SlottedHolder 컨트랙 — slot 의 attach object3d 반환. SlotTarget 이 자기
|
|
415
|
+
* `_realObject.object3d` proxy 로 사용하고, Carriable.applyHolderAttachPoint 도
|
|
416
|
+
* 이걸 attach frame 으로 사용 (transit 중 carrier 가 slot 위치에 정렬).
|
|
417
|
+
*/
|
|
418
|
+
getSlotAttachObject3d(cellId) {
|
|
419
|
+
return this._ensureCellAttachObject3d(cellId);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* SlottedHolder 컨트랙 — slot 의 *expected carrier* 의 3D 크기 (slot 자체의 기하 크기가
|
|
423
|
+
* 아님). Crane 의 `resolveCarrierBottomY = centerY - depth/2` 에서 *carrier 가 놓일 때
|
|
424
|
+
* 예상되는 carrier depth* 를 써야 fork 가 *carrier 바닥 = shelf* 에 정확히 진입.
|
|
425
|
+
*
|
|
426
|
+
* 즉:
|
|
427
|
+
* depth = stockD (= levelHeight * 0.7) — *carrier 의 vertical extent*. 전체 셀 높이
|
|
428
|
+
* (levelHeight) 가 아닌 *실제 stock 박스 깊이*. anchor 가 stock 시각 중심 (
|
|
429
|
+
* shelf + stockD/2) 에 위치하므로 depth = stockD 여야 bottom 계산이 shelf.
|
|
430
|
+
* width = bayWidth — 그대로
|
|
431
|
+
* height = rowDepth — 그대로 (2D 의 Z 축 폭)
|
|
432
|
+
*/
|
|
433
|
+
getSlotSize(cellId) {
|
|
434
|
+
const cell = this.cellMap?.findById(cellId);
|
|
435
|
+
if (!cell)
|
|
436
|
+
return undefined;
|
|
437
|
+
const stockD = cell.size.height * 0.7; // matches _ensureCellAttachObject3d + storage-rack-3d.rebuildStockMesh
|
|
438
|
+
return {
|
|
439
|
+
width: cell.size.width,
|
|
440
|
+
height: cell.size.depth, // 2D height = Z extent (front-back)
|
|
441
|
+
depth: stockD // 3D depth = carrier Y extent (NOT full level)
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* SlottedHolder 컨트랙 — cellId 에 대한 SlotTarget. Mover.pickAndPlace 의 dest 로 넘김.
|
|
446
|
+
*
|
|
447
|
+
* Signature overloads:
|
|
448
|
+
* slotTargetAt('0-0-5') — string cellId 직접
|
|
449
|
+
* slotTargetAt(1, 1, 6) — 1-based (bay, row, level)
|
|
450
|
+
*/
|
|
451
|
+
slotTargetAt(idOrBay, row, level) {
|
|
452
|
+
const cellId = typeof idOrBay === 'string'
|
|
453
|
+
? idOrBay
|
|
454
|
+
: this.cellIdOf(idOrBay, row ?? 1, level ?? 1);
|
|
455
|
+
return new SlotTarget(this, cellId);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* SlotTarget 의 2D center 위임 — Mover.moveTo 의 2D path 계산에 사용.
|
|
459
|
+
*
|
|
460
|
+
* 반환값은 *rack 자체의 local frame* (rack 의 left/top 미포함) — 즉 cell 의 위치를
|
|
461
|
+
* rack 의 *내부 좌표계로* 표현. SlotTarget.toScene 이 rack.toScene 을 위임해 *rack 의
|
|
462
|
+
* rotation / 부모 chain 변환 포함* 한 절대 좌표로 변환.
|
|
463
|
+
*
|
|
464
|
+
* 이전 결함: rack.left/top 을 포함해 model-layer 프레임 좌표 반환 + toScene 미구현 →
|
|
465
|
+
* rack 이 rotated 또는 nested 일 때 X 가 어긋났음.
|
|
466
|
+
*/
|
|
467
|
+
cellCenter2D(cellId) {
|
|
468
|
+
const cell = this.cellMap?.findById(cellId);
|
|
469
|
+
if (!cell)
|
|
470
|
+
return null;
|
|
471
|
+
const rs = this.state;
|
|
472
|
+
const rackWidth = rs?.width || 1000;
|
|
473
|
+
const rackHeight = rs?.height || 100;
|
|
474
|
+
const bays = Math.max(1, Math.floor(rs?.bays || 5));
|
|
475
|
+
const bayWidth = rackWidth / bays;
|
|
476
|
+
const bayIdx = cell.bay - 1;
|
|
477
|
+
// things-scene 컨벤션: 컴포넌트의 `center` 는 *parent 좌표계 의 center*
|
|
478
|
+
// (= bounds.left + width/2). 즉 *layer 좌표* — rack 의 left/top 포함, *rack 의
|
|
479
|
+
// rotation 미적용*. toScene 가 rotation 처리. parcel.center 등 모든 실제
|
|
480
|
+
// 컴포넌트가 이 컨벤션이므로 SlotTarget.center 도 동일해야 *pick 과 place 의
|
|
481
|
+
// 좌표 변환 체인 일관성* 유지.
|
|
482
|
+
return {
|
|
483
|
+
x: (rs?.left ?? 0) + bayIdx * bayWidth + bayWidth / 2,
|
|
484
|
+
y: (rs?.top ?? 0) + rackHeight / 2
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* SlotTarget 의 toScene 위임 — *rack-local* 좌표를 *scene-absolute* 로 변환.
|
|
489
|
+
* rack.toScene 이 rack 의 rotation / translation / parent chain 모두 처리.
|
|
490
|
+
*/
|
|
491
|
+
cellToScene(localX, localY) {
|
|
492
|
+
const f = this.toScene;
|
|
493
|
+
if (typeof f === 'function')
|
|
494
|
+
return f.call(this, localX, localY);
|
|
495
|
+
return { x: localX, y: localY };
|
|
496
|
+
}
|
|
497
|
+
/** cellId 별 attach anchor object3d cache (rack.object3d 의 자식). */
|
|
498
|
+
_attachAnchorByCell = new Map();
|
|
499
|
+
/**
|
|
500
|
+
* cellId 위치에 lightweight anchor object3d 를 *singleton 으로* 보장 + 갱신.
|
|
501
|
+
* 이 anchor 가:
|
|
502
|
+
* - Carriable.applyHolderAttachPoint 가 attach 하는 frame
|
|
503
|
+
* - SlotTarget._realObject.object3d 의 proxy
|
|
504
|
+
* - 두 용도가 *같은 object3d* 를 공유해 carrier 가 transient 동안 SlotTarget 의
|
|
505
|
+
* pose 와 정확히 동기화.
|
|
506
|
+
*/
|
|
507
|
+
_ensureCellAttachObject3d(cellId) {
|
|
508
|
+
const ro = this._realObject;
|
|
509
|
+
if (!ro?.object3d)
|
|
510
|
+
return undefined;
|
|
511
|
+
let obj = this._attachAnchorByCell.get(cellId);
|
|
512
|
+
if (!obj) {
|
|
513
|
+
obj = new THREE.Object3D();
|
|
514
|
+
obj.name = `rack-slot-anchor:${cellId}`;
|
|
515
|
+
ro.object3d.add(obj);
|
|
516
|
+
this._attachAnchorByCell.set(cellId, obj);
|
|
517
|
+
}
|
|
518
|
+
const cell = this.cellMap?.findById(cellId);
|
|
519
|
+
if (!cell)
|
|
520
|
+
return undefined;
|
|
521
|
+
const rs = this.state;
|
|
522
|
+
const rackWidth = rs?.width || 1000;
|
|
523
|
+
const rackDepth = rs?.depth || 3000;
|
|
524
|
+
const rackHeight = rs?.height || 600;
|
|
525
|
+
const bays = Math.max(1, Math.floor(rs?.bays || 5));
|
|
526
|
+
const levels = Math.max(1, Math.floor(rs?.levels || 4));
|
|
527
|
+
const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9));
|
|
528
|
+
const shelfZone = rackDepth - shelfBase;
|
|
529
|
+
const bayWidth = rackWidth / bays;
|
|
530
|
+
const levelHeight = shelfZone / levels;
|
|
531
|
+
const stockD = levelHeight * 0.7; // ← storage-rack-3d.rebuildStockMesh 의 stockD 와 동일
|
|
532
|
+
const rowDepth = rackHeight;
|
|
533
|
+
// Anchor Y = *stock 시각 중심* (shelf + stockD/2). InstancedMesh 의 stock 박스 중심과
|
|
534
|
+
// 일치 → carrier (depth = stockD) 가 attach 시 정확히 stock 위치에 올라 앉음. Crane
|
|
535
|
+
// 의 resolveCarrierBottomY = centerY - depth/2 = shelf 라 fork 가 정확히 shelf 진입.
|
|
536
|
+
const x = cell.localPosition.x + bayWidth / 2 - rackWidth / 2;
|
|
537
|
+
const y = cell.localPosition.y - rackDepth / 2 + stockD / 2;
|
|
538
|
+
const z = cell.localPosition.z + rowDepth / 2 - rackHeight / 2;
|
|
539
|
+
obj.position.set(x, y, z);
|
|
540
|
+
obj.updateMatrixWorld(true);
|
|
541
|
+
return obj;
|
|
164
542
|
}
|
|
165
543
|
// ── 2D rendering ─────────────────────────────────────────────────────────
|
|
166
544
|
/**
|
|
167
|
-
* 2D — top-down rectangle showing the rack footprint
|
|
168
|
-
*
|
|
545
|
+
* 2D — top-down rectangle showing the rack footprint with bay subdivisions.
|
|
546
|
+
* 편집/배치 가 가능하도록 *명시 fill + stroke* — pipeline 분기 무관하게 항상
|
|
547
|
+
* 보임. fill 은 반투명 (carrier / cell 위 overlay).
|
|
169
548
|
*/
|
|
170
549
|
render(ctx) {
|
|
171
|
-
const
|
|
550
|
+
const left = this.state.left ?? 0;
|
|
551
|
+
const top = this.state.top ?? 0;
|
|
552
|
+
const width = this.state.width ?? 400;
|
|
553
|
+
const height = this.state.height ?? 100;
|
|
172
554
|
const bays = Math.max(1, Math.floor(this.state.bays || 5));
|
|
555
|
+
const fill = this.state.fillStyle || '#a0a0a8';
|
|
556
|
+
const stroke = this.state.strokeStyle || '#555';
|
|
557
|
+
const lineWidth = this.state.lineWidth || 1;
|
|
558
|
+
// Fill (반투명)
|
|
559
|
+
ctx.save();
|
|
560
|
+
ctx.fillStyle = fill;
|
|
561
|
+
ctx.globalAlpha = 0.2;
|
|
562
|
+
ctx.fillRect(left, top, width, height);
|
|
563
|
+
ctx.restore();
|
|
564
|
+
// Stroke — outer + bay subdivisions
|
|
565
|
+
ctx.strokeStyle = stroke;
|
|
566
|
+
ctx.lineWidth = lineWidth;
|
|
567
|
+
ctx.strokeRect(left, top, width, height);
|
|
173
568
|
ctx.beginPath();
|
|
174
|
-
// Outer rectangle
|
|
175
|
-
ctx.rect(left, top, width, height);
|
|
176
|
-
// Bay subdivisions (vertical lines)
|
|
177
569
|
for (let i = 1; i < bays; i++) {
|
|
178
570
|
const x = left + (width * i) / bays;
|
|
179
571
|
ctx.moveTo(x, top);
|
|
180
572
|
ctx.lineTo(x, top + height);
|
|
181
573
|
}
|
|
574
|
+
ctx.stroke();
|
|
182
575
|
}
|
|
183
576
|
get fillStyle() {
|
|
184
577
|
return '#a0a0a8';
|
|
185
578
|
}
|
|
579
|
+
// ── Data binding — state.data 변경 시 InstancedMesh 재구성 ───────────────
|
|
580
|
+
onchangeData() {
|
|
581
|
+
;
|
|
582
|
+
this._realObject?.rebuildStockMesh?.();
|
|
583
|
+
}
|
|
584
|
+
// ── Legend — record 의 field 값 → 색상 매핑 ────────────────────────────────
|
|
585
|
+
_legendTarget;
|
|
586
|
+
/**
|
|
587
|
+
* Legend 컴포넌트 lookup. 우선순위:
|
|
588
|
+
* 1) state.legendTarget id 명시
|
|
589
|
+
* 2) scene 전체에서 `type='legend'` 첫 번째 컴포넌트 (자동 발견)
|
|
590
|
+
*/
|
|
591
|
+
get legendTarget() {
|
|
592
|
+
if (this._legendTarget)
|
|
593
|
+
return this._legendTarget;
|
|
594
|
+
const id = this.state.legendTarget;
|
|
595
|
+
if (id) {
|
|
596
|
+
const found = this.root?.findById?.(id);
|
|
597
|
+
if (found) {
|
|
598
|
+
this._legendTarget = found;
|
|
599
|
+
found.on?.('change', this._onLegendChanged, this);
|
|
600
|
+
return found;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// scene-wide auto-discovery
|
|
604
|
+
const visit = (node) => {
|
|
605
|
+
if (!node)
|
|
606
|
+
return undefined;
|
|
607
|
+
if (node.state?.type === 'legend')
|
|
608
|
+
return node;
|
|
609
|
+
const children = node.components;
|
|
610
|
+
if (children) {
|
|
611
|
+
for (const c of children) {
|
|
612
|
+
const r = visit(c);
|
|
613
|
+
if (r)
|
|
614
|
+
return r;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return undefined;
|
|
618
|
+
};
|
|
619
|
+
const found = visit(this.root);
|
|
620
|
+
if (found) {
|
|
621
|
+
this._legendTarget = found;
|
|
622
|
+
found.on?.('change', this._onLegendChanged, this);
|
|
623
|
+
}
|
|
624
|
+
return found;
|
|
625
|
+
}
|
|
626
|
+
_onLegendChanged = () => {
|
|
627
|
+
;
|
|
628
|
+
this._realObject?.rebuildStockMesh?.();
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
|
|
632
|
+
* - `range.value === recordValue` (카테고리 일치)
|
|
633
|
+
* - `range.min ≤ Number(v) < range.max` (수치 범위)
|
|
634
|
+
* - 매칭 없으면 `defaultColor` 또는 undefined
|
|
635
|
+
*/
|
|
636
|
+
resolveLegendColor(record) {
|
|
637
|
+
const legend = this.legendTarget;
|
|
638
|
+
if (!legend)
|
|
639
|
+
return undefined;
|
|
640
|
+
const status = legend.getState?.('status') ?? legend.state?.status;
|
|
641
|
+
if (!status)
|
|
642
|
+
return undefined;
|
|
643
|
+
const field = status.field;
|
|
644
|
+
const ranges = status.ranges;
|
|
645
|
+
if (!field || !Array.isArray(ranges))
|
|
646
|
+
return undefined;
|
|
647
|
+
const value = record?.[field];
|
|
648
|
+
if (value === undefined || value === null)
|
|
649
|
+
return status.defaultColor;
|
|
650
|
+
for (const range of ranges) {
|
|
651
|
+
if (!range)
|
|
652
|
+
continue;
|
|
653
|
+
if (range.value !== undefined) {
|
|
654
|
+
if (range.value === value)
|
|
655
|
+
return range.color;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const num = Number(value);
|
|
659
|
+
if (!Number.isFinite(num))
|
|
660
|
+
continue;
|
|
661
|
+
const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined;
|
|
662
|
+
const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined;
|
|
663
|
+
const minOk = min === undefined || num >= min;
|
|
664
|
+
const maxOk = max === undefined || num < max;
|
|
665
|
+
if (minOk && maxOk)
|
|
666
|
+
return range.color;
|
|
667
|
+
}
|
|
668
|
+
return status.defaultColor;
|
|
669
|
+
}
|
|
670
|
+
// ── Click event — rack-cell-click 발사 ────────────────────────────────────
|
|
671
|
+
//
|
|
672
|
+
// 사용자가 *어떤 셀*을 클릭하든 (stock 가시화된 셀이든, 빈 셀이든) rack 이 다음
|
|
673
|
+
// payload 로 `rack-cell-click` 을 emit:
|
|
674
|
+
// { cellId, record?, hitPoint, instanceId?, isStock }
|
|
675
|
+
//
|
|
676
|
+
// 외부 consumer 등록:
|
|
677
|
+
// rack.on('rack-cell-click', ({ cellId, record, isStock }) => { ... })
|
|
678
|
+
//
|
|
679
|
+
// 분기:
|
|
680
|
+
// - InstancedMesh (stock) hit → cellId/record 는 records[instanceId] 에서 직접 추출, isStock=true
|
|
681
|
+
// - 그 외 rack mesh (shelf/frame) hit → hit.point 를 rack-local 좌표로 변환 후
|
|
682
|
+
// (bay, level) 역산, record 는 state.data 검색, isStock=false
|
|
683
|
+
// edit mode 클릭은 framework 의 선택 로직 우선이라 emit 하지 않음.
|
|
684
|
+
/**
|
|
685
|
+
* things-scene EventManager3D 가 raycast → object3d.userData.context.component 의
|
|
686
|
+
* `trigger("click", mouseEvent)` 을 호출 → eventMap 으로 receive.
|
|
687
|
+
* `(self).(self).click` 으로 등록해 *우리 rack 의 어떤 mesh 든 클릭됐을 때* 발사.
|
|
688
|
+
*/
|
|
689
|
+
get eventMap() {
|
|
690
|
+
return {
|
|
691
|
+
'(self)': {
|
|
692
|
+
'(self)': {
|
|
693
|
+
click: this._onRackClick
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
_onRackClick = (mouseEvent) => {
|
|
699
|
+
// view mode 에서만 동작 (modeling 중 클릭은 framework 의 선택 로직 우선).
|
|
700
|
+
if (!this.app?.isViewMode)
|
|
701
|
+
return;
|
|
702
|
+
const ro = this._realObject;
|
|
703
|
+
if (!ro?.object3d)
|
|
704
|
+
return;
|
|
705
|
+
const hit = this._raycastRackHit(mouseEvent);
|
|
706
|
+
if (!hit)
|
|
707
|
+
return;
|
|
708
|
+
const stockMesh = ro.stockMesh;
|
|
709
|
+
let cellId;
|
|
710
|
+
let record;
|
|
711
|
+
let isStock = false;
|
|
712
|
+
let instanceId;
|
|
713
|
+
if (hit.object === stockMesh) {
|
|
714
|
+
// (A) Stock instance 직접 hit — records 에서 정확한 record 추출.
|
|
715
|
+
const records = stockMesh.userData?._records;
|
|
716
|
+
record = records?.[hit.instanceId ?? -1];
|
|
717
|
+
cellId = record?.cellId;
|
|
718
|
+
isStock = true;
|
|
719
|
+
instanceId = hit.instanceId;
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
// (B) 빈 cell / shelf / frame hit — world point → cellId 역산.
|
|
723
|
+
const cid = this._cellIdFromWorldPoint(hit.point);
|
|
724
|
+
if (!cid || cid.startsWith('out-of-bounds'))
|
|
725
|
+
return;
|
|
726
|
+
cellId = cid;
|
|
727
|
+
const data = this.state.data;
|
|
728
|
+
record = Array.isArray(data) ? data.find(r => r?.cellId === cid) : undefined;
|
|
729
|
+
}
|
|
730
|
+
const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock };
|
|
731
|
+
this.trigger('rack-cell-click', payload);
|
|
732
|
+
// Popup 호출 — 일반 mechanism (Popup 컴포넌트) 활용, anchor 만 클릭된 cell 로 override.
|
|
733
|
+
this._invokePopup(cellId, record);
|
|
734
|
+
};
|
|
735
|
+
/**
|
|
736
|
+
* state.popupRef 가 가리키는 Popup 컴포넌트를 invoke. anchor 를 SlotTarget 으로
|
|
737
|
+
* 지정 — SlotTarget._realObject.object3d 가 cellId 위치의 anchor object3d 를
|
|
738
|
+
* 가리켜 tether / projectToScreen 정확.
|
|
739
|
+
*
|
|
740
|
+
* - popupRef 미설정 → no-op (event 만 발사된 상태로 남음)
|
|
741
|
+
* - 다른 cell 클릭 시 popup 이 새 anchor 로 "이동" (Popup 의 board 등 설정 유지)
|
|
742
|
+
* - frame/empty 영역 클릭 시 호출 안 됨 → popup 그대로 유지
|
|
743
|
+
* - 명시적 close 버튼은 popup 자체의 closable 옵션이 처리
|
|
744
|
+
*/
|
|
745
|
+
_invokePopup(cellId, record) {
|
|
746
|
+
const popupRefId = this.state.popupRef;
|
|
747
|
+
if (!popupRefId || !cellId)
|
|
748
|
+
return;
|
|
749
|
+
const popupComp = this.root?.findById?.(popupRefId);
|
|
750
|
+
if (!popupComp || typeof popupComp.openPopup !== 'function') {
|
|
751
|
+
console.warn(`[storage-rack] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const anchor = this.slotTargetAt(cellId);
|
|
755
|
+
popupComp.openPopup(record ?? { cellId }, { anchor });
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* 클릭 시 framework 의 mouse NDC (이미 InteractionManager 가 set 한 상태) 를 재사용해
|
|
759
|
+
* raycast → *우리 rack* 의 어떤 mesh 가 closest hit 인지 반환. 다른 object 가 더 가까우면
|
|
760
|
+
* undefined (다른 rack 또는 무관 mesh 의 hit).
|
|
761
|
+
*
|
|
762
|
+
* 접근 경로:
|
|
763
|
+
* 1. ThreeCapability — ModelLayer 는 `_threeCapability`, ThreeContainer 는 `_capability`.
|
|
764
|
+
* capability 의 `getObjectsByRaycast()` 가 *동일한* mouse NDC 로 framework 가 click
|
|
765
|
+
* 처리 직전에 쓴 그 raycaster 를 재사용 (가장 정확).
|
|
766
|
+
* 2. capability 가 없는 컨테이너 — public scene3d / renderer3d / camera + mouseEvent
|
|
767
|
+
* 좌표로 자체 ndc 변환 후 fresh raycaster.
|
|
768
|
+
*/
|
|
769
|
+
_raycastRackHit(mouseEvent) {
|
|
770
|
+
const ro = this._realObject;
|
|
771
|
+
if (!ro?.object3d)
|
|
772
|
+
return undefined;
|
|
773
|
+
const tc = ro.threeContainer;
|
|
774
|
+
if (!tc)
|
|
775
|
+
return undefined;
|
|
776
|
+
const cap = tc._threeCapability ?? tc._capability;
|
|
777
|
+
let intersects;
|
|
778
|
+
if (cap?.getObjectsByRaycast) {
|
|
779
|
+
intersects = cap.getObjectsByRaycast();
|
|
780
|
+
}
|
|
781
|
+
if (!intersects || intersects.length === 0) {
|
|
782
|
+
const scene = tc.scene3d;
|
|
783
|
+
const renderer = tc.renderer3d;
|
|
784
|
+
const camera = tc.activeCamera3d ??
|
|
785
|
+
cap?.activeCamera ??
|
|
786
|
+
cap?.camera;
|
|
787
|
+
const canvas = renderer?.domElement;
|
|
788
|
+
if (!scene || !canvas || !camera)
|
|
789
|
+
return undefined;
|
|
790
|
+
const rect = canvas.getBoundingClientRect();
|
|
791
|
+
if (rect.width === 0 || rect.height === 0)
|
|
792
|
+
return undefined;
|
|
793
|
+
const ndc = new THREE.Vector2(((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1, -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1);
|
|
794
|
+
const raycaster = new THREE.Raycaster();
|
|
795
|
+
raycaster.setFromCamera(ndc, camera);
|
|
796
|
+
intersects = raycaster.intersectObjects(scene.children, true);
|
|
797
|
+
}
|
|
798
|
+
if (!intersects || intersects.length === 0)
|
|
799
|
+
return undefined;
|
|
800
|
+
// Three.js intersectObjects 는 distance 오름차순 정렬. 첫 hit 이 *우리 rack* 의 descendant
|
|
801
|
+
// 인지 확인 (userData.context walk-up 으로). 다른 object 가 더 가까우면 (다른 rack 또는
|
|
802
|
+
// 무관 mesh 가 사이에 있음) — 무시.
|
|
803
|
+
const closest = intersects[0];
|
|
804
|
+
let obj = closest.object;
|
|
805
|
+
while (obj) {
|
|
806
|
+
if (obj.userData?.context === ro)
|
|
807
|
+
return closest;
|
|
808
|
+
obj = obj.parent;
|
|
809
|
+
}
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* world point → cellId 역산.
|
|
814
|
+
*
|
|
815
|
+
* 1. rack 의 `matrixWorld.invert()` 로 world → rack-local 변환 (rack 의 회전·이동
|
|
816
|
+
* 반영)
|
|
817
|
+
* 2. rack-local point 의 `(x, y)` 를 (bay, level) 격자에 매핑
|
|
818
|
+
*
|
|
819
|
+
* 범위 밖이면 `"out-of-bounds(...)"` 문자열 반환 (caller 가 무시).
|
|
820
|
+
*/
|
|
821
|
+
_cellIdFromWorldPoint(worldPoint) {
|
|
822
|
+
const ro = this._realObject;
|
|
823
|
+
if (!ro?.object3d)
|
|
824
|
+
return null;
|
|
825
|
+
const local = new THREE.Vector3().copy(worldPoint);
|
|
826
|
+
const mInv = new THREE.Matrix4().copy(ro.object3d.matrixWorld).invert();
|
|
827
|
+
local.applyMatrix4(mInv);
|
|
828
|
+
const bays = Math.max(1, Math.floor(this.state.bays || 5));
|
|
829
|
+
const levels = Math.max(1, Math.floor(this.state.levels || 4));
|
|
830
|
+
const width = this.state.width || 1000;
|
|
831
|
+
const depth = this.state.depth || 3000;
|
|
832
|
+
const shelfBase = this.state.shelfBaseHeight || 0;
|
|
833
|
+
const shelfZone = depth - shelfBase;
|
|
834
|
+
const bayWidth = width / bays;
|
|
835
|
+
const levelHeight = shelfZone / levels;
|
|
836
|
+
// rack 의 3D origin = rack center. X: [-width/2,+width/2], Y: [-depth/2,+depth/2]
|
|
837
|
+
const bayIdx = Math.floor((local.x + width / 2) / bayWidth);
|
|
838
|
+
const yFromBottom = local.y + depth / 2 - shelfBase;
|
|
839
|
+
const levelIdx = Math.floor(yFromBottom / levelHeight);
|
|
840
|
+
const rowIdx = 0; // storage-rack 은 rows=1
|
|
841
|
+
if (bayIdx < 0 || bayIdx >= bays || levelIdx < 0 || levelIdx >= levels) {
|
|
842
|
+
return `out-of-bounds(bay=${bayIdx}, level=${levelIdx})`;
|
|
843
|
+
}
|
|
844
|
+
return `${bayIdx}-${rowIdx}-${levelIdx}`;
|
|
845
|
+
}
|
|
186
846
|
// ── 3D ───────────────────────────────────────────────────────────────────
|
|
187
847
|
buildRealObject() {
|
|
188
848
|
return new StorageRack3D(this);
|