@operato/scene-storage 10.0.0-beta.43 → 10.0.0-beta.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/dist/box-3d.d.ts +2 -0
- package/dist/box-3d.js +103 -64
- package/dist/box-3d.js.map +1 -1
- package/dist/crane-3d.d.ts +10 -0
- package/dist/crane-3d.js +34 -5
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +136 -6
- package/dist/crane.js +567 -46
- package/dist/crane.js.map +1 -1
- package/dist/pallet-3d.d.ts +2 -0
- package/dist/pallet-3d.js +103 -53
- package/dist/pallet-3d.js.map +1 -1
- package/dist/parcel-3d.d.ts +1 -0
- package/dist/parcel-3d.js +18 -1
- package/dist/parcel-3d.js.map +1 -1
- package/dist/rack-grid-3d.js +26 -8
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid.d.ts +94 -10
- package/dist/rack-grid.js +468 -86
- package/dist/rack-grid.js.map +1 -1
- package/dist/storage-rack-3d.js +1 -1
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +31 -6
- package/dist/storage-rack.js +96 -14
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box-3d.ts +121 -68
- package/src/crane-3d.ts +34 -4
- package/src/crane.ts +615 -55
- package/src/pallet-3d.ts +122 -55
- package/src/parcel-3d.ts +19 -1
- package/src/rack-grid-3d.ts +31 -8
- package/src/rack-grid.ts +488 -82
- package/src/storage-rack-3d.ts +1 -1
- package/src/storage-rack.ts +96 -14
- package/test/test-coord-alignment.ts +2 -2
- package/test/test-crane-bay-match.ts +130 -0
- package/test/test-crane-binding-resolve.ts +168 -0
- package/test/test-crane-duration.ts +90 -0
- package/test/test-crane-rotation-reach.ts +218 -0
- package/test/test-rack-grid-3d-alignment.ts +235 -0
- package/test/test-rack-grid-3d-attach-real.ts +375 -0
- package/test/test-rack-grid-cell.ts +2 -2
- package/test/test-rack-grid-location.ts +2 -2
- package/test/test-rack-grid-occupied-slots.ts +165 -0
- package/test/test-rack-grid-picking-position.ts +154 -0
- package/test/test-rack-grid-slot-api.ts +483 -0
- package/test/test-slot-ids-enumeration.ts +137 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/rack-grid.ts
CHANGED
|
@@ -36,8 +36,10 @@ import {
|
|
|
36
36
|
import type { State } from '@hatiolab/things-scene'
|
|
37
37
|
import * as THREE from 'three'
|
|
38
38
|
import {
|
|
39
|
+
CarrierHolder,
|
|
39
40
|
Placeable,
|
|
40
41
|
SlotTarget,
|
|
42
|
+
type AttachFrame,
|
|
41
43
|
type Alignment,
|
|
42
44
|
type Heights,
|
|
43
45
|
type PlacementArchetype,
|
|
@@ -57,6 +59,13 @@ function _nextCellRefid(): number {
|
|
|
57
59
|
return _cellRefidCounter++
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Transient carrier (obtainCarrier 가 materialize 한 컴포넌트) 용 refid. 모델 안
|
|
63
|
+
// 다른 컴포넌트와 겹치지 않도록 별도 대역.
|
|
64
|
+
let _carrierRefidCounter = 200_000_000
|
|
65
|
+
function _nextCarrierRefid(): number {
|
|
66
|
+
return _carrierRefidCounter++
|
|
67
|
+
}
|
|
68
|
+
|
|
60
69
|
function buildNewCell(app: ApplicationContext) {
|
|
61
70
|
const refid = _nextCellRefid()
|
|
62
71
|
const m: any = Model.compile(
|
|
@@ -78,7 +87,42 @@ function buildNewCell(app: ApplicationContext) {
|
|
|
78
87
|
return m
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
const TABLE_LAYOUT = Layout.get('table')
|
|
90
|
+
const TABLE_LAYOUT: any = Layout.get('table')
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RackGrid 의 *layout-aware children* — `rack-grid-cell` (= 데이타-proxy 시각
|
|
94
|
+
* 자식) 만 Table 분배 대상. obtainCarrier 가 만든 *실물 carrier* (Carriable —
|
|
95
|
+
* Parcel/Pallet 등) 는 *transient* 라 layout 분배 외. Table.reflow 가 *carrier
|
|
96
|
+
* 도 cells 다음 인덱스로 분배* 하면 `heights[rackRows] = undefined` → carrier.
|
|
97
|
+
* state.height = NaN → BoxGeometry NaN → invisible. 그 회귀 방지.
|
|
98
|
+
*/
|
|
99
|
+
const RACK_GRID_LAYOUT: any = {
|
|
100
|
+
reflow(container: any) {
|
|
101
|
+
const all = container.components ?? []
|
|
102
|
+
const cellsOnly = all.filter((c: any) => c?.state?.type === 'rack-grid-cell')
|
|
103
|
+
if (cellsOnly.length === all.length) {
|
|
104
|
+
return TABLE_LAYOUT.reflow.call(TABLE_LAYOUT, container)
|
|
105
|
+
}
|
|
106
|
+
// Carrier 자식이 있는 경우 — container.components 를 *cells only* 로
|
|
107
|
+
// 임시 교체 후 base reflow. base 가 idx 기반 분배 시 carrier 가 *out of
|
|
108
|
+
// range* 인덱스에 안 오게.
|
|
109
|
+
const desc = Object.getOwnPropertyDescriptor(container, 'components')
|
|
110
|
+
Object.defineProperty(container, 'components', { value: cellsOnly, configurable: true, writable: true })
|
|
111
|
+
try {
|
|
112
|
+
TABLE_LAYOUT.reflow.call(TABLE_LAYOUT, container)
|
|
113
|
+
} finally {
|
|
114
|
+
if (desc) Object.defineProperty(container, 'components', desc)
|
|
115
|
+
else delete container.components
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
capturables(container: any) { return TABLE_LAYOUT.capturables(container) },
|
|
119
|
+
drawables(container: any) { return TABLE_LAYOUT.drawables(container) },
|
|
120
|
+
isStuck(component: any) { return TABLE_LAYOUT.isStuck?.(component) ?? true },
|
|
121
|
+
keyNavigate(container: any, component: any, e: any) {
|
|
122
|
+
return TABLE_LAYOUT.keyNavigate?.(container, component, e)
|
|
123
|
+
},
|
|
124
|
+
joinType: TABLE_LAYOUT.joinType
|
|
125
|
+
}
|
|
82
126
|
|
|
83
127
|
function arrayOf(value: number, size: number): number[] {
|
|
84
128
|
const arr: number[] = []
|
|
@@ -223,7 +267,7 @@ export interface RackGridState extends State {
|
|
|
223
267
|
|
|
224
268
|
@sceneComponent('rack-grid')
|
|
225
269
|
export default class RackGrid
|
|
226
|
-
extends Placeable(ContainerAbstract)
|
|
270
|
+
extends CarrierHolder(Placeable(ContainerAbstract))
|
|
227
271
|
implements SlottedHolder
|
|
228
272
|
{
|
|
229
273
|
declare state: RackGridState
|
|
@@ -665,12 +709,114 @@ export default class RackGrid
|
|
|
665
709
|
* truth, 없으면 cellOverrides[posKey].isEmpty fallback.
|
|
666
710
|
*/
|
|
667
711
|
isBayEmpty(col: number, row: number = 0): boolean {
|
|
712
|
+
// cell.state.isEmpty 가 *명시 true* 일 때만 우선. false / undefined 면 fallback
|
|
713
|
+
// 으로 cellOverrides 확인. _buildCells 가 모든 bay 에 cell 자동 생성 + default
|
|
714
|
+
// isEmpty=false 라서, "cell 객체 존재 만으로 cellOverrides 차단" 시 사용자가
|
|
715
|
+
// cellOverrides 만 설정한 모델에서 isEmpty 효과 0 회귀.
|
|
668
716
|
const cell = this.cellAt(col, row)
|
|
669
|
-
if (cell) return
|
|
717
|
+
if (cell?.state?.isEmpty === true) return true
|
|
670
718
|
const posKey = `${col}-${row}`
|
|
671
719
|
return !!this.cellOverrides[posKey]?.isEmpty
|
|
672
720
|
}
|
|
673
721
|
|
|
722
|
+
/**
|
|
723
|
+
* 모든 slot id 의 목록. columns × rackRows × shelves 조합. *isEmpty bay 는 제외*
|
|
724
|
+
* (물리적으로 없는 위치). SlottedHolder.slotIds — capability 기반 enumeration
|
|
725
|
+
* entry point.
|
|
726
|
+
*/
|
|
727
|
+
slotIds(): ReadonlyArray<string> {
|
|
728
|
+
const ids: string[] = []
|
|
729
|
+
const cols = this.columns
|
|
730
|
+
const rows = this.rackRows
|
|
731
|
+
const shelves = this.shelves
|
|
732
|
+
for (let col = 0; col < cols; col++) {
|
|
733
|
+
for (let row = 0; row < rows; row++) {
|
|
734
|
+
if (this.isBayEmpty(col, row)) continue // 물리적 없음 — 제외
|
|
735
|
+
for (let shelf = 0; shelf < shelves; shelf++) {
|
|
736
|
+
ids.push(`${col}-${row}-${shelf}`)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return ids
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 점유된 slot 의 id 목록 — state.data record + child *carrier* 둘 다.
|
|
745
|
+
*
|
|
746
|
+
* 중요: RackGridCell (시각 proxy) 의 *state.cellId 는 bayKey ("col-row")
|
|
747
|
+
* 형식* (`_syncChildCellIds` 가 set). slot 의 *full id ("col-row-shelf")* 와
|
|
748
|
+
* 다르다 — RackGridCell 은 *carrier 아닌 시각 proxy* 라 *occupied 카운트
|
|
749
|
+
* 대상 아님*. carriable child (= 실 carrier) 만 sweep.
|
|
750
|
+
*
|
|
751
|
+
* @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
|
|
752
|
+
*/
|
|
753
|
+
occupiedSlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
|
|
754
|
+
const set = new Set<string>()
|
|
755
|
+
for (const r of this.records as any[]) {
|
|
756
|
+
const cid = r?.cellId
|
|
757
|
+
if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
|
|
758
|
+
}
|
|
759
|
+
for (const c of (this as any).components ?? []) {
|
|
760
|
+
// *carriable 만* — RackGridCell (rack-grid-cell type) 의 bayKey 제외.
|
|
761
|
+
if (!(c as any)?.isCarriable) continue
|
|
762
|
+
const cid = (c as any)?.state?.cellId
|
|
763
|
+
if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
|
|
764
|
+
}
|
|
765
|
+
return Array.from(set)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). isEmpty bay 자동 제외. */
|
|
769
|
+
emptySlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
|
|
770
|
+
const occ = new Set(this.occupiedSlotIds())
|
|
771
|
+
const result: string[] = []
|
|
772
|
+
for (const id of this.slotIds()) {
|
|
773
|
+
if (occ.has(id)) continue
|
|
774
|
+
if (filter && !filter(id)) continue
|
|
775
|
+
result.push(id)
|
|
776
|
+
}
|
|
777
|
+
return result
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
|
|
782
|
+
* reach 검사 같은 *match 전 단계* 에서 *anchor 생성 0* 으로 사용 가능.
|
|
783
|
+
* match 후 *실제 attach* 가 필요할 때 `getSlotAttachObject3d` 가 lazy 생성.
|
|
784
|
+
*/
|
|
785
|
+
slotWorldPosition(slotId: string): { x: number; y: number; z: number } | undefined {
|
|
786
|
+
const parsed = this.parseSlotId(slotId)
|
|
787
|
+
if (!parsed) return undefined
|
|
788
|
+
if (parsed.col < 0 || parsed.col >= this.columns) return undefined
|
|
789
|
+
if (parsed.row < 0 || parsed.row >= this.rackRows) return undefined
|
|
790
|
+
if (parsed.shelf < 0 || parsed.shelf >= this.shelves) return undefined
|
|
791
|
+
const ro: any = (this as any)._realObject
|
|
792
|
+
if (!ro?.object3d) return undefined
|
|
793
|
+
|
|
794
|
+
const rs: any = this.state
|
|
795
|
+
const width = (rs?.width as number) ?? 400
|
|
796
|
+
const height = (rs?.depth as number) ?? 2000
|
|
797
|
+
const depth = (rs?.height as number) ?? 200
|
|
798
|
+
const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
|
|
799
|
+
const cols = this.columns
|
|
800
|
+
const rows = this.rackRows
|
|
801
|
+
const shelves = this.shelves
|
|
802
|
+
const shelfZone = height - shelfBase
|
|
803
|
+
const bayW = width / cols
|
|
804
|
+
const bayD = depth / rows
|
|
805
|
+
const cellY = shelfZone / shelves
|
|
806
|
+
const baseY = -height / 2
|
|
807
|
+
const shelfBaseY = baseY + shelfBase
|
|
808
|
+
const stockD = cellY * 0.7
|
|
809
|
+
|
|
810
|
+
const cx = (parsed.col - cols / 2 + 0.5) * bayW
|
|
811
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellY
|
|
812
|
+
const cy = cellBottomY + stockD / 2
|
|
813
|
+
const cz = (parsed.row - rows / 2 + 0.5) * bayD
|
|
814
|
+
|
|
815
|
+
const v = new THREE.Vector3(cx, cy, cz)
|
|
816
|
+
ro.object3d.localToWorld(v)
|
|
817
|
+
return { x: v.x, y: v.y, z: v.z }
|
|
818
|
+
}
|
|
819
|
+
|
|
674
820
|
buildRealObject(): RealObject | undefined {
|
|
675
821
|
return new RackGrid3D(this)
|
|
676
822
|
}
|
|
@@ -842,15 +988,18 @@ export default class RackGrid
|
|
|
842
988
|
// ── Grid layout ─────────────────────────────────────────
|
|
843
989
|
|
|
844
990
|
get columns(): number {
|
|
845
|
-
|
|
991
|
+
const v = this.state.columns
|
|
992
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 5))
|
|
846
993
|
}
|
|
847
994
|
|
|
848
995
|
get rackRows(): number {
|
|
849
|
-
|
|
996
|
+
const v = this.state.rows
|
|
997
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 1))
|
|
850
998
|
}
|
|
851
999
|
|
|
852
1000
|
get shelves(): number {
|
|
853
|
-
|
|
1001
|
+
const v = this.state.shelves
|
|
1002
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 4))
|
|
854
1003
|
}
|
|
855
1004
|
|
|
856
1005
|
// ── cellId / posKey 변환 ────────────────────────────────
|
|
@@ -861,11 +1010,11 @@ export default class RackGrid
|
|
|
861
1010
|
}
|
|
862
1011
|
|
|
863
1012
|
/** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
|
|
864
|
-
|
|
1013
|
+
slotIdOf(col: number, row: number = 1, shelf: number = 1): string {
|
|
865
1014
|
return `${col - 1}-${row - 1}-${shelf - 1}`
|
|
866
1015
|
}
|
|
867
1016
|
|
|
868
|
-
|
|
1017
|
+
parseSlotId(cellId: string): { col: number; row: number; shelf: number } | null {
|
|
869
1018
|
const parts = cellId.split('-')
|
|
870
1019
|
if (parts.length !== 3) return null
|
|
871
1020
|
const [c, r, s] = parts.map(Number)
|
|
@@ -920,7 +1069,7 @@ export default class RackGrid
|
|
|
920
1069
|
* section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
|
|
921
1070
|
*/
|
|
922
1071
|
locationOf(cellId: string): string | null {
|
|
923
|
-
const parsed = this.
|
|
1072
|
+
const parsed = this.parseSlotId(cellId)
|
|
924
1073
|
if (!parsed) return null
|
|
925
1074
|
const posKey = `${parsed.col}-${parsed.row}`
|
|
926
1075
|
|
|
@@ -1051,95 +1200,332 @@ export default class RackGrid
|
|
|
1051
1200
|
* 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
|
|
1052
1201
|
* 자식 추가 시 자동 할당 또는 사용자가 명시.
|
|
1053
1202
|
*/
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* cellId 매칭되는 RackGrid 의 직접 자식 carrier (operation archetype).
|
|
1205
|
+
* RackGrid 의 자식 중 rack-grid-cell (시각 proxy) 외에 obtainCarrier 가 add 한
|
|
1206
|
+
* transient carrier 들이 섞임 — 그 중 cellId / placement='operation' 매칭.
|
|
1207
|
+
* storage-rack 의 동일 패턴.
|
|
1208
|
+
*/
|
|
1209
|
+
private _carrierChildAt(cellId: string): Component | undefined {
|
|
1057
1210
|
const children = (this.components as Component[] | undefined) ?? []
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1211
|
+
return children.find(c => {
|
|
1212
|
+
const placement = (c.constructor as any).placement
|
|
1213
|
+
return placement === 'operation' && (c.state as any)?.cellId === cellId
|
|
1214
|
+
})
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* state.data 의 internal 갱신 — obtainCarrier / receiveAt 가 사용.
|
|
1219
|
+
* setState 와 달리 'change' / onchangeData / mapping cascade 우회 — 사용자 script
|
|
1220
|
+
* 의 자기-자신 호출 회귀 차단. 시각 갱신은 직접 RealObject.rebuildStockMesh 호출.
|
|
1221
|
+
* storage-rack 의 동일 패턴.
|
|
1222
|
+
*/
|
|
1223
|
+
private _setDataSilently(newData: any[]): void {
|
|
1224
|
+
const self = this as any
|
|
1225
|
+
if (!self._state) self._state = {}
|
|
1226
|
+
self._state.data = newData
|
|
1227
|
+
self._cachedState = null
|
|
1228
|
+
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
1068
1229
|
}
|
|
1069
1230
|
|
|
1070
|
-
// ── SlottedHolder 컨트랙 —
|
|
1231
|
+
// ── SlottedHolder 컨트랙 — RackGrid 자체의 records 에서 직접 처리 ────
|
|
1232
|
+
//
|
|
1233
|
+
// Plan A 정신: stock 데이터는 RackGrid 의 state.data 에 sparse 로 통합 저장.
|
|
1234
|
+
// 자식 컴포넌트는 시각용 rack-grid-cell (light proxy) 뿐 carrier 보유 X.
|
|
1235
|
+
// storage-rack 의 본체 로직 (transient materialize / silent setData) 을 차용,
|
|
1236
|
+
// 좌표 / 사이즈만 RackGrid 의 cellAt(col,row).bounds + getSlotSize 로 적응.
|
|
1071
1237
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1238
|
+
/** state.data record 또는 이미 transient materialize 된 carrier child 가 있는가. */
|
|
1239
|
+
hasCarrierAt(slotIdOrLocation: string): boolean {
|
|
1240
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1241
|
+
if (!cellId) return false
|
|
1242
|
+
if (this._carrierChildAt(cellId)) return true
|
|
1243
|
+
return this.records.some(r => r.cellId === cellId)
|
|
1077
1244
|
}
|
|
1078
1245
|
|
|
1246
|
+
/**
|
|
1247
|
+
* carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
|
|
1248
|
+
* materialize 후 RackGrid 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
|
|
1249
|
+
* record 도 child 도 없으면 null.
|
|
1250
|
+
*/
|
|
1079
1251
|
obtainCarrier(slotIdOrLocation: string): Component | null {
|
|
1080
|
-
const
|
|
1081
|
-
if (!
|
|
1082
|
-
|
|
1252
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1253
|
+
if (!cellId) return null
|
|
1254
|
+
|
|
1255
|
+
const existing = this._carrierChildAt(cellId)
|
|
1256
|
+
if (existing) return existing
|
|
1257
|
+
|
|
1258
|
+
const records = this.records as Array<any>
|
|
1259
|
+
const idx = records.findIndex(r => r?.cellId === cellId)
|
|
1260
|
+
if (idx === -1) return null
|
|
1261
|
+
const record = records[idx]
|
|
1262
|
+
|
|
1263
|
+
const carrierType = (record.type as string) || 'parcel'
|
|
1264
|
+
const CarrierClass = (Component as any).register(carrierType) as
|
|
1265
|
+
| (new (...args: any[]) => Component) | undefined
|
|
1266
|
+
if (!CarrierClass) {
|
|
1267
|
+
console.warn(`[rack-grid] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`)
|
|
1268
|
+
return null
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// RackGrid-inner 좌표 — anchor (getSlotAttachObject3d) / stock InstancedMesh 와
|
|
1272
|
+
// *동일 식* 사용: 균등 분할 + center origin → top-left origin 변환.
|
|
1273
|
+
// 이전엔 widths/heights *비례 분할* 을 썼는데 anchor/stock 은 *균등 분할* 이라
|
|
1274
|
+
// 어긋났음. update loop 이 carrier.state.left/top 기반으로 위치 재계산해도
|
|
1275
|
+
// anchor 와 일치하도록 *처음부터 같은 식* 으로 박는다.
|
|
1276
|
+
const parsed = this.parseSlotId(cellId)
|
|
1083
1277
|
if (!parsed) return null
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1278
|
+
const rackWidth = (this.state.width as number) ?? 400
|
|
1279
|
+
const rackDepthY = (this.state.height as number) ?? 200 // 2D height = 3D Z = inner Y
|
|
1280
|
+
const stateDepth = (this.state.depth as number) ?? 2000 // 3D Y (vertical)
|
|
1281
|
+
const bayW = rackWidth / this.columns
|
|
1282
|
+
const bayD = rackDepthY / this.rackRows
|
|
1283
|
+
// anchor X(center) = (col - cols/2 + 0.5) * bayW → 2D top-left center = anchor.X + rackWidth/2
|
|
1284
|
+
const cellCenterInnerX = (parsed.col + 0.5) * bayW
|
|
1285
|
+
const cellCenterInnerY = (parsed.row + 0.5) * bayD
|
|
1286
|
+
// shelf level Y — anchor (getSlotAttachObject3d) 와 동일 식.
|
|
1287
|
+
const shelfBase = Math.max(0, Math.min(((this.state as any).shelfBaseHeight as number) || 0, stateDepth * 0.9))
|
|
1288
|
+
const shelfZone = stateDepth - shelfBase
|
|
1289
|
+
const cellYHeight = shelfZone / this.shelves
|
|
1290
|
+
const shelfBaseY = -stateDepth / 2 + shelfBase
|
|
1291
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellYHeight
|
|
1292
|
+
|
|
1293
|
+
const slotSize = this.getSlotSize(cellId) ?? { width: 50, height: 50, depth: 50 }
|
|
1294
|
+
// record 의 사이즈가 *0* 또는 *non-finite (NaN/Infinity)* 이면 slotSize fallback —
|
|
1295
|
+
// nullish 만으로는 NaN/0 통과해 BoxGeometry NaN bug 유발.
|
|
1296
|
+
const pickSize = (v: any, fb: number) =>
|
|
1297
|
+
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fb
|
|
1298
|
+
const carrierW = pickSize(record.width, slotSize.width)
|
|
1299
|
+
const carrierH = pickSize(record.height, slotSize.height)
|
|
1300
|
+
const carrierD = pickSize(record.depth, slotSize.depth)
|
|
1301
|
+
|
|
1302
|
+
// 차원 / 좌표 finite 검증 — 어느 단계에서 NaN 이 들어왔는지 명시 진단.
|
|
1303
|
+
if (
|
|
1304
|
+
!Number.isFinite(carrierW) || !Number.isFinite(carrierH) || !Number.isFinite(carrierD) ||
|
|
1305
|
+
!Number.isFinite(cellCenterInnerX) || !Number.isFinite(cellCenterInnerY)
|
|
1306
|
+
) {
|
|
1307
|
+
console.error('[rack-grid] obtainCarrier: non-finite dim/pos — carrier 생성 차단', {
|
|
1308
|
+
cellId,
|
|
1309
|
+
carrierW, carrierH, carrierD,
|
|
1310
|
+
cellCenterInnerX, cellCenterInnerY,
|
|
1311
|
+
slotSize,
|
|
1312
|
+
recordSize: { w: record.width, h: record.height, d: record.depth },
|
|
1313
|
+
gridDims: {
|
|
1314
|
+
columns: this.columns, rackRows: this.rackRows, shelves: this.shelves,
|
|
1315
|
+
stateW: (this.state as any)?.width, stateH: (this.state as any)?.height,
|
|
1316
|
+
stateD: (this.state as any)?.depth
|
|
1317
|
+
}
|
|
1318
|
+
})
|
|
1319
|
+
return null
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
|
|
1323
|
+
// component 와 충돌해 parent 가 잘못 잡힘.
|
|
1324
|
+
const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
|
|
1325
|
+
const carrierState: any = {
|
|
1326
|
+
...recordCopy,
|
|
1327
|
+
type: carrierType,
|
|
1328
|
+
cellId, // 슬롯 주소
|
|
1329
|
+
refid: _nextCarrierRefid(), // refid 충돌 회피
|
|
1330
|
+
width: carrierW,
|
|
1331
|
+
height: carrierH,
|
|
1332
|
+
depth: carrierD,
|
|
1333
|
+
left: cellCenterInnerX - carrierW / 2,
|
|
1334
|
+
top: cellCenterInnerY - carrierH / 2,
|
|
1335
|
+
// zPos = carrier 의 *parent-local 3D Y bottom*. things-scene 의 RealObject 가
|
|
1336
|
+
// 3D Y center = zPos + depth/2 로 계산. 미명시 시 NaN — carrier.matrixWorld NaN
|
|
1337
|
+
// → fork 가 NaN target 따라가서 빈 포크질. anchor 식 (cellBottomY + stockD/2)
|
|
1338
|
+
// 와 일치하도록 zPos = cellBottomY 명시.
|
|
1339
|
+
zPos: cellBottomY
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const carrier = new CarrierClass(carrierState, (this as any)._app)
|
|
1343
|
+
// silent: true — Plan A transient materialize 는 내부 use, mapping cascade 차단.
|
|
1344
|
+
;(this as any).addComponent(carrier, { silent: true })
|
|
1345
|
+
|
|
1346
|
+
// 3D 강제 빌드 + holder attach + manual placement/anchor.
|
|
1347
|
+
void (carrier as any).realObject
|
|
1348
|
+
;(carrier as any).applyHolderAttachPoint?.()
|
|
1349
|
+
;(carrier as any).realObject?.setTransientPlacement?.({ policy: 'carried' })
|
|
1350
|
+
const anchor = this.getSlotAttachObject3d(cellId)
|
|
1351
|
+
const carrierObj3d = (carrier as any).realObject?.object3d
|
|
1352
|
+
if (anchor && carrierObj3d) {
|
|
1353
|
+
anchor.add(carrierObj3d)
|
|
1354
|
+
carrierObj3d.position.set(0, 0, 0)
|
|
1355
|
+
carrierObj3d.updateMatrixWorld(true)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// record 제거 — silent (mapping cascade 회피). 동일 cellId 의 *모든* record 정리.
|
|
1359
|
+
this._setDataSilently(records.filter(r => r?.cellId !== cellId))
|
|
1360
|
+
|
|
1361
|
+
return carrier
|
|
1086
1362
|
}
|
|
1087
1363
|
|
|
1364
|
+
/**
|
|
1365
|
+
* cell 이 carrier 를 받을 수 있는가.
|
|
1366
|
+
* - isEmpty 위치 는 거부 (modeling 차원 location-less)
|
|
1367
|
+
* - state.data 에 record 있으면 점유 → false
|
|
1368
|
+
* - 자기 자신 carrier 가 child 면 idempotent true (자기 자리 복귀)
|
|
1369
|
+
*/
|
|
1088
1370
|
canReceiveAt(slotIdOrLocation: string, carrier?: Component): boolean {
|
|
1089
|
-
const
|
|
1090
|
-
if (!
|
|
1091
|
-
const parsed = this.
|
|
1371
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1372
|
+
if (!cellId) return false
|
|
1373
|
+
const parsed = this.parseSlotId(cellId)
|
|
1092
1374
|
if (!parsed) return false
|
|
1093
|
-
// isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
|
|
1094
1375
|
const override = this.cellOverrides[`${parsed.col}-${parsed.row}`]
|
|
1095
1376
|
if (override?.isEmpty) return false
|
|
1096
|
-
|
|
1097
|
-
|
|
1377
|
+
|
|
1378
|
+
const records = this.records as Array<any>
|
|
1379
|
+
if (records.some(r => r?.cellId === cellId)) return false
|
|
1380
|
+
const existingChild = this._carrierChildAt(cellId)
|
|
1381
|
+
if (existingChild && existingChild !== carrier) return false
|
|
1382
|
+
return true
|
|
1098
1383
|
}
|
|
1099
1384
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1385
|
+
/**
|
|
1386
|
+
* Carrier 가 RackGrid 의 slot 으로 들어옴 — 즉시 dispose + state.data 에 record 로
|
|
1387
|
+
* 환원. 결과: stock InstancedMesh 가 그 자리에 instance 표시, RackGrid 의 자식 트리는
|
|
1388
|
+
* 깨끗 (rack-grid-cell 만 남음).
|
|
1389
|
+
*/
|
|
1390
|
+
async receiveAt(slotIdOrLocation: string, carrier: Component, _options?: any): Promise<void> {
|
|
1391
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1392
|
+
if (!cellId) throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`)
|
|
1393
|
+
|
|
1394
|
+
// disposed carrier 의 재처리 차단
|
|
1395
|
+
if ((carrier as any)?._disposed) {
|
|
1396
|
+
throw new Error(
|
|
1397
|
+
`RackGrid.receiveAt("${cellId}"): carrier is already disposed. ` +
|
|
1398
|
+
'After a successful pickAndPlace the carrier becomes a state.data record — ' +
|
|
1399
|
+
'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.'
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
1403
|
+
;(this as any).trigger?.('transfer-rejected', {
|
|
1404
|
+
type: 'transfer-rejected',
|
|
1405
|
+
component: carrier, container: this, reason: 'slot-occupied', cellId
|
|
1406
|
+
})
|
|
1407
|
+
return
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const record = this.recordFromCarrier(carrier, cellId)
|
|
1411
|
+
|
|
1412
|
+
// Carrier 의 parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 RackGrid 자신.
|
|
1413
|
+
const carrierParent: any = (carrier as any).parent
|
|
1414
|
+
if (carrierParent && typeof carrierParent.removeComponent === 'function') {
|
|
1415
|
+
carrierParent.removeComponent(carrier)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// 명시적 Three.js detach — RealObject.dispose() 의 clear() 는 object3d 자체를
|
|
1419
|
+
// scene graph 의 parent 에서 떼지 않으므로 ghost 방지 위해 명시 제거.
|
|
1420
|
+
const carrierObj3d: any = (carrier as any)._realObject?.object3d
|
|
1421
|
+
if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
|
|
1422
|
+
carrierObj3d.parent.remove(carrierObj3d)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
;(carrier as any).dispose?.()
|
|
1426
|
+
|
|
1427
|
+
// state.data 에 push — silent (mapping cascade 회피). 동일 cellId 중복 방어.
|
|
1428
|
+
const currentRecords = (this.records as any[]).filter(r => r?.cellId !== cellId)
|
|
1429
|
+
this._setDataSilently([...currentRecords, record])
|
|
1430
|
+
|
|
1431
|
+
;(this as any).trigger?.('transfer-received', {
|
|
1432
|
+
type: 'transfer-received',
|
|
1433
|
+
component: carrier, container: this, slotId: cellId, record
|
|
1434
|
+
})
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Carrier 의 state 를 state.data record 로 추출. transform/position 관련은 record
|
|
1439
|
+
* 와 무관해 skip. (storage-rack 의 동일 패턴.)
|
|
1440
|
+
*/
|
|
1441
|
+
recordFromCarrier(carrier: Component, cellId: string): SlotRecord {
|
|
1442
|
+
const state: any = (carrier as any).state ?? {}
|
|
1443
|
+
const SKIP_KEYS = new Set([
|
|
1444
|
+
'left', 'top', 'zPos',
|
|
1445
|
+
'transform', 'rotation', 'scale',
|
|
1446
|
+
'_transferSlotId',
|
|
1447
|
+
'cellId',
|
|
1448
|
+
'id',
|
|
1449
|
+
'refid'
|
|
1450
|
+
])
|
|
1451
|
+
const record: any = { cellId, type: state.type }
|
|
1452
|
+
for (const key of Object.keys(state)) {
|
|
1453
|
+
if (SKIP_KEYS.has(key)) continue
|
|
1454
|
+
record[key] = state[key]
|
|
1108
1455
|
}
|
|
1109
|
-
return
|
|
1456
|
+
return record
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// ── CarrierHolder — attach frame for direct carrier children ─────────────
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* children gate — CarrierHolder.containable 의 *carriable-only* 제한을 완화.
|
|
1463
|
+
* RackGrid 의 자식은 두 종류:
|
|
1464
|
+
* - RackGridCell : modeling-time visual proxy (carriable 아님)
|
|
1465
|
+
* - carrier : Plan A 의 obtainCarrier 가 materialize (carriable)
|
|
1466
|
+
* 둘 다 허용해야 정상 동작.
|
|
1467
|
+
*/
|
|
1468
|
+
containable(component: Component): boolean {
|
|
1469
|
+
if (!component) return false
|
|
1470
|
+
if ((component as any)?.state?.type === 'rack-grid-cell') return true
|
|
1471
|
+
return (component as any).isCarriable === true
|
|
1110
1472
|
}
|
|
1111
1473
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1474
|
+
/**
|
|
1475
|
+
* Attach frame for direct-child carrier — `applyHolderAttachPoint` 가 이 결과의
|
|
1476
|
+
* `attach` 에 carrier obj3d 를 attach 하고 `localPosition` 을 set. cell anchor
|
|
1477
|
+
* 반환 + carried placement 와 함께 동작 — carrier 가 cell 위치에 고정.
|
|
1478
|
+
*/
|
|
1479
|
+
attachPointFor(carrier: Component): AttachFrame | null {
|
|
1480
|
+
const cellId = (carrier as any)?.state?.cellId as string | undefined
|
|
1481
|
+
if (cellId) {
|
|
1482
|
+
const obj = this.getSlotAttachObject3d(cellId)
|
|
1483
|
+
if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
|
|
1484
|
+
}
|
|
1485
|
+
const root = this._realObject?.object3d
|
|
1486
|
+
if (!root) return null
|
|
1487
|
+
return { attach: root, localPosition: { x: 0, y: 0, z: 0 } }
|
|
1115
1488
|
}
|
|
1116
1489
|
|
|
1117
1490
|
/**
|
|
1118
1491
|
* Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
|
|
1119
|
-
* 위치한 invisible Object3D. popup tether
|
|
1120
|
-
*
|
|
1492
|
+
* 위치한 invisible Object3D. popup tether 등이 이 object3d 의 matrixWorld 를
|
|
1493
|
+
* 사용. lazy 생성 + cache.
|
|
1121
1494
|
*/
|
|
1122
|
-
private
|
|
1495
|
+
private _attachAnchorBySlot: Map<string, THREE.Object3D> = new Map()
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Anchor position cache 의 signature — rack 의 차원 state 변경 시 무효화 trigger.
|
|
1499
|
+
* 동일 signature 인 동안 *anchor.position.set 재계산 skip*. crane 의 첫
|
|
1500
|
+
* findAdjacentSlots 가 *수많은 anchor 의 position.set 매 호출* — *동일 차원
|
|
1501
|
+
* state 일 때* 의 *중복 계산 차단*.
|
|
1502
|
+
*/
|
|
1503
|
+
private _attachAnchorSig?: string
|
|
1123
1504
|
|
|
1124
1505
|
getSlotAttachObject3d(slotId: string): THREE.Object3D | undefined {
|
|
1125
|
-
const parsed = this.
|
|
1126
|
-
if (!parsed)
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1506
|
+
const parsed = this.parseSlotId(slotId)
|
|
1507
|
+
if (!parsed) {
|
|
1508
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: invalid cellId format "${slotId}"`)
|
|
1509
|
+
return undefined
|
|
1510
|
+
}
|
|
1511
|
+
if (parsed.col < 0 || parsed.col >= this.columns) {
|
|
1512
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: col=${parsed.col} out of range [0, ${this.columns})`)
|
|
1513
|
+
return undefined
|
|
1514
|
+
}
|
|
1515
|
+
if (parsed.row < 0 || parsed.row >= this.rackRows) {
|
|
1516
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: row=${parsed.row} out of range [0, ${this.rackRows})`)
|
|
1517
|
+
return undefined
|
|
1518
|
+
}
|
|
1519
|
+
if (parsed.shelf < 0 || parsed.shelf >= this.shelves) {
|
|
1520
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: shelf=${parsed.shelf} out of range [0, ${this.shelves}) — cellId "${slotId}" — fork 가 fallback 위치 (한 층 아래 등) 로 갈 수 있음`)
|
|
1521
|
+
return undefined
|
|
1522
|
+
}
|
|
1130
1523
|
|
|
1131
1524
|
const ro: any = (this as any)._realObject
|
|
1132
1525
|
if (!ro?.object3d) return undefined
|
|
1133
1526
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
obj = new THREE.Object3D()
|
|
1137
|
-
obj.name = `rack-grid-anchor:${slotId}`
|
|
1138
|
-
ro.object3d.add(obj)
|
|
1139
|
-
this._attachAnchorByCell.set(slotId, obj)
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// 위치 갱신 — Stock InstancedMesh 의 instance 위치 와 동일 공식
|
|
1527
|
+
// signature 검사 — 차원 state 변경 시 *모든 anchor invalidate* 후 새로 생성.
|
|
1528
|
+
// 동일 sig 일 때 *기존 anchor 그대로 사용 (position.set skip)*.
|
|
1143
1529
|
const rs: any = this.state
|
|
1144
1530
|
const cols = this.columns
|
|
1145
1531
|
const rows = this.rackRows
|
|
@@ -1148,19 +1534,39 @@ export default class RackGrid
|
|
|
1148
1534
|
const height = (rs?.depth as number) ?? 2000
|
|
1149
1535
|
const depth = (rs?.height as number) ?? 200
|
|
1150
1536
|
const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
|
|
1151
|
-
const
|
|
1152
|
-
const bayW = width / cols
|
|
1153
|
-
const bayD = depth / rows
|
|
1154
|
-
const cellY = shelfZone / shelves
|
|
1155
|
-
const baseY = -height / 2
|
|
1156
|
-
const shelfBaseY = baseY + shelfBase
|
|
1157
|
-
const stockD = cellY * 0.7
|
|
1537
|
+
const sig = `${cols}-${rows}-${shelves}-${width}-${height}-${depth}-${shelfBase}`
|
|
1158
1538
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1539
|
+
if (this._attachAnchorSig !== sig) {
|
|
1540
|
+
// 차원 변경 — 모든 기존 anchor 폐기 후 sig 갱신.
|
|
1541
|
+
for (const oldObj of this._attachAnchorBySlot.values()) {
|
|
1542
|
+
ro.object3d.remove(oldObj)
|
|
1543
|
+
}
|
|
1544
|
+
this._attachAnchorBySlot.clear()
|
|
1545
|
+
this._attachAnchorSig = sig
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
let obj = this._attachAnchorBySlot.get(slotId)
|
|
1549
|
+
if (!obj) {
|
|
1550
|
+
obj = new THREE.Object3D()
|
|
1551
|
+
obj.name = `rack-grid-anchor:${slotId}`
|
|
1552
|
+
ro.object3d.add(obj)
|
|
1553
|
+
this._attachAnchorBySlot.set(slotId, obj)
|
|
1554
|
+
|
|
1555
|
+
// 신규 anchor — position 계산 + set (1 회 만, 이후 호출은 cache hit).
|
|
1556
|
+
const shelfZone = height - shelfBase
|
|
1557
|
+
const bayW = width / cols
|
|
1558
|
+
const bayD = depth / rows
|
|
1559
|
+
const cellY = shelfZone / shelves
|
|
1560
|
+
const baseY = -height / 2
|
|
1561
|
+
const shelfBaseY = baseY + shelfBase
|
|
1562
|
+
const stockD = cellY * 0.7
|
|
1563
|
+
|
|
1564
|
+
const cx = (parsed.col - cols / 2 + 0.5) * bayW
|
|
1565
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellY
|
|
1566
|
+
const cy = cellBottomY + stockD / 2
|
|
1567
|
+
const cz = (parsed.row - rows / 2 + 0.5) * bayD
|
|
1568
|
+
obj.position.set(cx, cy, cz)
|
|
1569
|
+
}
|
|
1164
1570
|
// *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
|
|
1165
1571
|
ro.object3d.updateMatrixWorld(true)
|
|
1166
1572
|
obj.updateMatrixWorld(true)
|
|
@@ -1168,7 +1574,7 @@ export default class RackGrid
|
|
|
1168
1574
|
}
|
|
1169
1575
|
|
|
1170
1576
|
getSlotSize(slotId: string): { width: number; height: number; depth: number } | undefined {
|
|
1171
|
-
const parsed = this.
|
|
1577
|
+
const parsed = this.parseSlotId(slotId)
|
|
1172
1578
|
if (!parsed) return undefined
|
|
1173
1579
|
const rs: any = this.state
|
|
1174
1580
|
const width = (rs?.width as number) ?? 400
|
|
@@ -1185,7 +1591,7 @@ export default class RackGrid
|
|
|
1185
1591
|
}
|
|
1186
1592
|
|
|
1187
1593
|
cellCenter2D(slotId: string): { x: number; y: number } | null {
|
|
1188
|
-
const parsed = this.
|
|
1594
|
+
const parsed = this.parseSlotId(slotId)
|
|
1189
1595
|
if (!parsed) return null
|
|
1190
1596
|
const rs: any = this.state
|
|
1191
1597
|
const left = (rs?.left as number) ?? 0
|