@operato/scene-storage 10.0.0-beta.44 → 10.0.0-beta.47
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 +44 -0
- 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 +578 -48
- package/dist/crane.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 +103 -10
- package/dist/rack-grid.js +484 -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 +40 -6
- package/dist/storage-rack.js +111 -14
- package/dist/storage-rack.js.map +1 -1
- package/package.json +4 -4
- package/src/crane-3d.ts +34 -4
- package/src/crane.ts +625 -57
- package/src/parcel-3d.ts +19 -1
- package/src/rack-grid-3d.ts +31 -8
- package/src/rack-grid.ts +504 -82
- package/src/storage-rack-3d.ts +1 -1
- package/src/storage-rack.ts +111 -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/test/things-scene-loader-impl.mjs +37 -0
- package/test/things-scene-loader.mjs +24 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
package/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,11 +267,27 @@ 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
|
|
230
274
|
|
|
275
|
+
// Phase Auto-Nav (AN-PR-2) — Obstacle 자격. AMR / mover 의 자동 path planning
|
|
276
|
+
// 시 회피 대상. `state.isObstacle` 명시 false 시 override (예외 처리).
|
|
277
|
+
get isObstacle(): boolean {
|
|
278
|
+
return (this.state as any)?.isObstacle !== false
|
|
279
|
+
}
|
|
280
|
+
obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
|
|
281
|
+
const s: any = this.state
|
|
282
|
+
if (typeof s?.left !== 'number') return null
|
|
283
|
+
return {
|
|
284
|
+
left: s.left, top: s.top,
|
|
285
|
+
width: s.width, height: s.height,
|
|
286
|
+
y: typeof s.zPos === 'number' ? s.zPos : 0,
|
|
287
|
+
zHeight: typeof s.depth === 'number' ? s.depth : 0
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
231
291
|
static placement: PlacementArchetype = 'floor'
|
|
232
292
|
static align: Alignment = 'bottom'
|
|
233
293
|
static defaultDepth = (h: Heights) => h.ceiling - h.floor
|
|
@@ -665,12 +725,114 @@ export default class RackGrid
|
|
|
665
725
|
* truth, 없으면 cellOverrides[posKey].isEmpty fallback.
|
|
666
726
|
*/
|
|
667
727
|
isBayEmpty(col: number, row: number = 0): boolean {
|
|
728
|
+
// cell.state.isEmpty 가 *명시 true* 일 때만 우선. false / undefined 면 fallback
|
|
729
|
+
// 으로 cellOverrides 확인. _buildCells 가 모든 bay 에 cell 자동 생성 + default
|
|
730
|
+
// isEmpty=false 라서, "cell 객체 존재 만으로 cellOverrides 차단" 시 사용자가
|
|
731
|
+
// cellOverrides 만 설정한 모델에서 isEmpty 효과 0 회귀.
|
|
668
732
|
const cell = this.cellAt(col, row)
|
|
669
|
-
if (cell) return
|
|
733
|
+
if (cell?.state?.isEmpty === true) return true
|
|
670
734
|
const posKey = `${col}-${row}`
|
|
671
735
|
return !!this.cellOverrides[posKey]?.isEmpty
|
|
672
736
|
}
|
|
673
737
|
|
|
738
|
+
/**
|
|
739
|
+
* 모든 slot id 의 목록. columns × rackRows × shelves 조합. *isEmpty bay 는 제외*
|
|
740
|
+
* (물리적으로 없는 위치). SlottedHolder.slotIds — capability 기반 enumeration
|
|
741
|
+
* entry point.
|
|
742
|
+
*/
|
|
743
|
+
slotIds(): ReadonlyArray<string> {
|
|
744
|
+
const ids: string[] = []
|
|
745
|
+
const cols = this.columns
|
|
746
|
+
const rows = this.rackRows
|
|
747
|
+
const shelves = this.shelves
|
|
748
|
+
for (let col = 0; col < cols; col++) {
|
|
749
|
+
for (let row = 0; row < rows; row++) {
|
|
750
|
+
if (this.isBayEmpty(col, row)) continue // 물리적 없음 — 제외
|
|
751
|
+
for (let shelf = 0; shelf < shelves; shelf++) {
|
|
752
|
+
ids.push(`${col}-${row}-${shelf}`)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return ids
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 점유된 slot 의 id 목록 — state.data record + child *carrier* 둘 다.
|
|
761
|
+
*
|
|
762
|
+
* 중요: RackGridCell (시각 proxy) 의 *state.cellId 는 bayKey ("col-row")
|
|
763
|
+
* 형식* (`_syncChildCellIds` 가 set). slot 의 *full id ("col-row-shelf")* 와
|
|
764
|
+
* 다르다 — RackGridCell 은 *carrier 아닌 시각 proxy* 라 *occupied 카운트
|
|
765
|
+
* 대상 아님*. carriable child (= 실 carrier) 만 sweep.
|
|
766
|
+
*
|
|
767
|
+
* @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
|
|
768
|
+
*/
|
|
769
|
+
occupiedSlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
|
|
770
|
+
const set = new Set<string>()
|
|
771
|
+
for (const r of this.records as any[]) {
|
|
772
|
+
const cid = r?.cellId
|
|
773
|
+
if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
|
|
774
|
+
}
|
|
775
|
+
for (const c of (this as any).components ?? []) {
|
|
776
|
+
// *carriable 만* — RackGridCell (rack-grid-cell type) 의 bayKey 제외.
|
|
777
|
+
if (!(c as any)?.isCarriable) continue
|
|
778
|
+
const cid = (c as any)?.state?.cellId
|
|
779
|
+
if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
|
|
780
|
+
}
|
|
781
|
+
return Array.from(set)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). isEmpty bay 자동 제외. */
|
|
785
|
+
emptySlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
|
|
786
|
+
const occ = new Set(this.occupiedSlotIds())
|
|
787
|
+
const result: string[] = []
|
|
788
|
+
for (const id of this.slotIds()) {
|
|
789
|
+
if (occ.has(id)) continue
|
|
790
|
+
if (filter && !filter(id)) continue
|
|
791
|
+
result.push(id)
|
|
792
|
+
}
|
|
793
|
+
return result
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
|
|
798
|
+
* reach 검사 같은 *match 전 단계* 에서 *anchor 생성 0* 으로 사용 가능.
|
|
799
|
+
* match 후 *실제 attach* 가 필요할 때 `getSlotAttachObject3d` 가 lazy 생성.
|
|
800
|
+
*/
|
|
801
|
+
slotWorldPosition(slotId: string): { x: number; y: number; z: number } | undefined {
|
|
802
|
+
const parsed = this.parseSlotId(slotId)
|
|
803
|
+
if (!parsed) return undefined
|
|
804
|
+
if (parsed.col < 0 || parsed.col >= this.columns) return undefined
|
|
805
|
+
if (parsed.row < 0 || parsed.row >= this.rackRows) return undefined
|
|
806
|
+
if (parsed.shelf < 0 || parsed.shelf >= this.shelves) return undefined
|
|
807
|
+
const ro: any = (this as any)._realObject
|
|
808
|
+
if (!ro?.object3d) return undefined
|
|
809
|
+
|
|
810
|
+
const rs: any = this.state
|
|
811
|
+
const width = (rs?.width as number) ?? 400
|
|
812
|
+
const height = (rs?.depth as number) ?? 2000
|
|
813
|
+
const depth = (rs?.height as number) ?? 200
|
|
814
|
+
const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
|
|
815
|
+
const cols = this.columns
|
|
816
|
+
const rows = this.rackRows
|
|
817
|
+
const shelves = this.shelves
|
|
818
|
+
const shelfZone = height - shelfBase
|
|
819
|
+
const bayW = width / cols
|
|
820
|
+
const bayD = depth / rows
|
|
821
|
+
const cellY = shelfZone / shelves
|
|
822
|
+
const baseY = -height / 2
|
|
823
|
+
const shelfBaseY = baseY + shelfBase
|
|
824
|
+
const stockD = cellY * 0.7
|
|
825
|
+
|
|
826
|
+
const cx = (parsed.col - cols / 2 + 0.5) * bayW
|
|
827
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellY
|
|
828
|
+
const cy = cellBottomY + stockD / 2
|
|
829
|
+
const cz = (parsed.row - rows / 2 + 0.5) * bayD
|
|
830
|
+
|
|
831
|
+
const v = new THREE.Vector3(cx, cy, cz)
|
|
832
|
+
ro.object3d.localToWorld(v)
|
|
833
|
+
return { x: v.x, y: v.y, z: v.z }
|
|
834
|
+
}
|
|
835
|
+
|
|
674
836
|
buildRealObject(): RealObject | undefined {
|
|
675
837
|
return new RackGrid3D(this)
|
|
676
838
|
}
|
|
@@ -842,15 +1004,18 @@ export default class RackGrid
|
|
|
842
1004
|
// ── Grid layout ─────────────────────────────────────────
|
|
843
1005
|
|
|
844
1006
|
get columns(): number {
|
|
845
|
-
|
|
1007
|
+
const v = this.state.columns
|
|
1008
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 5))
|
|
846
1009
|
}
|
|
847
1010
|
|
|
848
1011
|
get rackRows(): number {
|
|
849
|
-
|
|
1012
|
+
const v = this.state.rows
|
|
1013
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 1))
|
|
850
1014
|
}
|
|
851
1015
|
|
|
852
1016
|
get shelves(): number {
|
|
853
|
-
|
|
1017
|
+
const v = this.state.shelves
|
|
1018
|
+
return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 4))
|
|
854
1019
|
}
|
|
855
1020
|
|
|
856
1021
|
// ── cellId / posKey 변환 ────────────────────────────────
|
|
@@ -861,11 +1026,11 @@ export default class RackGrid
|
|
|
861
1026
|
}
|
|
862
1027
|
|
|
863
1028
|
/** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
|
|
864
|
-
|
|
1029
|
+
slotIdOf(col: number, row: number = 1, shelf: number = 1): string {
|
|
865
1030
|
return `${col - 1}-${row - 1}-${shelf - 1}`
|
|
866
1031
|
}
|
|
867
1032
|
|
|
868
|
-
|
|
1033
|
+
parseSlotId(cellId: string): { col: number; row: number; shelf: number } | null {
|
|
869
1034
|
const parts = cellId.split('-')
|
|
870
1035
|
if (parts.length !== 3) return null
|
|
871
1036
|
const [c, r, s] = parts.map(Number)
|
|
@@ -920,7 +1085,7 @@ export default class RackGrid
|
|
|
920
1085
|
* section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
|
|
921
1086
|
*/
|
|
922
1087
|
locationOf(cellId: string): string | null {
|
|
923
|
-
const parsed = this.
|
|
1088
|
+
const parsed = this.parseSlotId(cellId)
|
|
924
1089
|
if (!parsed) return null
|
|
925
1090
|
const posKey = `${parsed.col}-${parsed.row}`
|
|
926
1091
|
|
|
@@ -1051,95 +1216,332 @@ export default class RackGrid
|
|
|
1051
1216
|
* 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
|
|
1052
1217
|
* 자식 추가 시 자동 할당 또는 사용자가 명시.
|
|
1053
1218
|
*/
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1219
|
+
/**
|
|
1220
|
+
* cellId 매칭되는 RackGrid 의 직접 자식 carrier (operation archetype).
|
|
1221
|
+
* RackGrid 의 자식 중 rack-grid-cell (시각 proxy) 외에 obtainCarrier 가 add 한
|
|
1222
|
+
* transient carrier 들이 섞임 — 그 중 cellId / placement='operation' 매칭.
|
|
1223
|
+
* storage-rack 의 동일 패턴.
|
|
1224
|
+
*/
|
|
1225
|
+
private _carrierChildAt(cellId: string): Component | undefined {
|
|
1057
1226
|
const children = (this.components as Component[] | undefined) ?? []
|
|
1058
|
-
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1227
|
+
return children.find(c => {
|
|
1228
|
+
const placement = (c.constructor as any).placement
|
|
1229
|
+
return placement === 'operation' && (c.state as any)?.cellId === cellId
|
|
1230
|
+
})
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* state.data 의 internal 갱신 — obtainCarrier / receiveAt 가 사용.
|
|
1235
|
+
* setState 와 달리 'change' / onchangeData / mapping cascade 우회 — 사용자 script
|
|
1236
|
+
* 의 자기-자신 호출 회귀 차단. 시각 갱신은 직접 RealObject.rebuildStockMesh 호출.
|
|
1237
|
+
* storage-rack 의 동일 패턴.
|
|
1238
|
+
*/
|
|
1239
|
+
private _setDataSilently(newData: any[]): void {
|
|
1240
|
+
const self = this as any
|
|
1241
|
+
if (!self._state) self._state = {}
|
|
1242
|
+
self._state.data = newData
|
|
1243
|
+
self._cachedState = null
|
|
1244
|
+
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
1068
1245
|
}
|
|
1069
1246
|
|
|
1070
|
-
// ── SlottedHolder 컨트랙 —
|
|
1247
|
+
// ── SlottedHolder 컨트랙 — RackGrid 자체의 records 에서 직접 처리 ────
|
|
1248
|
+
//
|
|
1249
|
+
// Plan A 정신: stock 데이터는 RackGrid 의 state.data 에 sparse 로 통합 저장.
|
|
1250
|
+
// 자식 컴포넌트는 시각용 rack-grid-cell (light proxy) 뿐 carrier 보유 X.
|
|
1251
|
+
// storage-rack 의 본체 로직 (transient materialize / silent setData) 을 차용,
|
|
1252
|
+
// 좌표 / 사이즈만 RackGrid 의 cellAt(col,row).bounds + getSlotSize 로 적응.
|
|
1071
1253
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1254
|
+
/** state.data record 또는 이미 transient materialize 된 carrier child 가 있는가. */
|
|
1255
|
+
hasCarrierAt(slotIdOrLocation: string): boolean {
|
|
1256
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1257
|
+
if (!cellId) return false
|
|
1258
|
+
if (this._carrierChildAt(cellId)) return true
|
|
1259
|
+
return this.records.some(r => r.cellId === cellId)
|
|
1077
1260
|
}
|
|
1078
1261
|
|
|
1262
|
+
/**
|
|
1263
|
+
* carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
|
|
1264
|
+
* materialize 후 RackGrid 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
|
|
1265
|
+
* record 도 child 도 없으면 null.
|
|
1266
|
+
*/
|
|
1079
1267
|
obtainCarrier(slotIdOrLocation: string): Component | null {
|
|
1080
|
-
const
|
|
1081
|
-
if (!
|
|
1082
|
-
|
|
1268
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1269
|
+
if (!cellId) return null
|
|
1270
|
+
|
|
1271
|
+
const existing = this._carrierChildAt(cellId)
|
|
1272
|
+
if (existing) return existing
|
|
1273
|
+
|
|
1274
|
+
const records = this.records as Array<any>
|
|
1275
|
+
const idx = records.findIndex(r => r?.cellId === cellId)
|
|
1276
|
+
if (idx === -1) return null
|
|
1277
|
+
const record = records[idx]
|
|
1278
|
+
|
|
1279
|
+
const carrierType = (record.type as string) || 'parcel'
|
|
1280
|
+
const CarrierClass = (Component as any).register(carrierType) as
|
|
1281
|
+
| (new (...args: any[]) => Component) | undefined
|
|
1282
|
+
if (!CarrierClass) {
|
|
1283
|
+
console.warn(`[rack-grid] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`)
|
|
1284
|
+
return null
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// RackGrid-inner 좌표 — anchor (getSlotAttachObject3d) / stock InstancedMesh 와
|
|
1288
|
+
// *동일 식* 사용: 균등 분할 + center origin → top-left origin 변환.
|
|
1289
|
+
// 이전엔 widths/heights *비례 분할* 을 썼는데 anchor/stock 은 *균등 분할* 이라
|
|
1290
|
+
// 어긋났음. update loop 이 carrier.state.left/top 기반으로 위치 재계산해도
|
|
1291
|
+
// anchor 와 일치하도록 *처음부터 같은 식* 으로 박는다.
|
|
1292
|
+
const parsed = this.parseSlotId(cellId)
|
|
1083
1293
|
if (!parsed) return null
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1294
|
+
const rackWidth = (this.state.width as number) ?? 400
|
|
1295
|
+
const rackDepthY = (this.state.height as number) ?? 200 // 2D height = 3D Z = inner Y
|
|
1296
|
+
const stateDepth = (this.state.depth as number) ?? 2000 // 3D Y (vertical)
|
|
1297
|
+
const bayW = rackWidth / this.columns
|
|
1298
|
+
const bayD = rackDepthY / this.rackRows
|
|
1299
|
+
// anchor X(center) = (col - cols/2 + 0.5) * bayW → 2D top-left center = anchor.X + rackWidth/2
|
|
1300
|
+
const cellCenterInnerX = (parsed.col + 0.5) * bayW
|
|
1301
|
+
const cellCenterInnerY = (parsed.row + 0.5) * bayD
|
|
1302
|
+
// shelf level Y — anchor (getSlotAttachObject3d) 와 동일 식.
|
|
1303
|
+
const shelfBase = Math.max(0, Math.min(((this.state as any).shelfBaseHeight as number) || 0, stateDepth * 0.9))
|
|
1304
|
+
const shelfZone = stateDepth - shelfBase
|
|
1305
|
+
const cellYHeight = shelfZone / this.shelves
|
|
1306
|
+
const shelfBaseY = -stateDepth / 2 + shelfBase
|
|
1307
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellYHeight
|
|
1308
|
+
|
|
1309
|
+
const slotSize = this.getSlotSize(cellId) ?? { width: 50, height: 50, depth: 50 }
|
|
1310
|
+
// record 의 사이즈가 *0* 또는 *non-finite (NaN/Infinity)* 이면 slotSize fallback —
|
|
1311
|
+
// nullish 만으로는 NaN/0 통과해 BoxGeometry NaN bug 유발.
|
|
1312
|
+
const pickSize = (v: any, fb: number) =>
|
|
1313
|
+
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fb
|
|
1314
|
+
const carrierW = pickSize(record.width, slotSize.width)
|
|
1315
|
+
const carrierH = pickSize(record.height, slotSize.height)
|
|
1316
|
+
const carrierD = pickSize(record.depth, slotSize.depth)
|
|
1317
|
+
|
|
1318
|
+
// 차원 / 좌표 finite 검증 — 어느 단계에서 NaN 이 들어왔는지 명시 진단.
|
|
1319
|
+
if (
|
|
1320
|
+
!Number.isFinite(carrierW) || !Number.isFinite(carrierH) || !Number.isFinite(carrierD) ||
|
|
1321
|
+
!Number.isFinite(cellCenterInnerX) || !Number.isFinite(cellCenterInnerY)
|
|
1322
|
+
) {
|
|
1323
|
+
console.error('[rack-grid] obtainCarrier: non-finite dim/pos — carrier 생성 차단', {
|
|
1324
|
+
cellId,
|
|
1325
|
+
carrierW, carrierH, carrierD,
|
|
1326
|
+
cellCenterInnerX, cellCenterInnerY,
|
|
1327
|
+
slotSize,
|
|
1328
|
+
recordSize: { w: record.width, h: record.height, d: record.depth },
|
|
1329
|
+
gridDims: {
|
|
1330
|
+
columns: this.columns, rackRows: this.rackRows, shelves: this.shelves,
|
|
1331
|
+
stateW: (this.state as any)?.width, stateH: (this.state as any)?.height,
|
|
1332
|
+
stateD: (this.state as any)?.depth
|
|
1333
|
+
}
|
|
1334
|
+
})
|
|
1335
|
+
return null
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
|
|
1339
|
+
// component 와 충돌해 parent 가 잘못 잡힘.
|
|
1340
|
+
const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
|
|
1341
|
+
const carrierState: any = {
|
|
1342
|
+
...recordCopy,
|
|
1343
|
+
type: carrierType,
|
|
1344
|
+
cellId, // 슬롯 주소
|
|
1345
|
+
refid: _nextCarrierRefid(), // refid 충돌 회피
|
|
1346
|
+
width: carrierW,
|
|
1347
|
+
height: carrierH,
|
|
1348
|
+
depth: carrierD,
|
|
1349
|
+
left: cellCenterInnerX - carrierW / 2,
|
|
1350
|
+
top: cellCenterInnerY - carrierH / 2,
|
|
1351
|
+
// zPos = carrier 의 *parent-local 3D Y bottom*. things-scene 의 RealObject 가
|
|
1352
|
+
// 3D Y center = zPos + depth/2 로 계산. 미명시 시 NaN — carrier.matrixWorld NaN
|
|
1353
|
+
// → fork 가 NaN target 따라가서 빈 포크질. anchor 식 (cellBottomY + stockD/2)
|
|
1354
|
+
// 와 일치하도록 zPos = cellBottomY 명시.
|
|
1355
|
+
zPos: cellBottomY
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const carrier = new CarrierClass(carrierState, (this as any)._app)
|
|
1359
|
+
// silent: true — Plan A transient materialize 는 내부 use, mapping cascade 차단.
|
|
1360
|
+
;(this as any).addComponent(carrier, { silent: true })
|
|
1361
|
+
|
|
1362
|
+
// 3D 강제 빌드 + holder attach + manual placement/anchor.
|
|
1363
|
+
void (carrier as any).realObject
|
|
1364
|
+
;(carrier as any).applyHolderAttachPoint?.()
|
|
1365
|
+
;(carrier as any).realObject?.setTransientPlacement?.({ policy: 'carried' })
|
|
1366
|
+
const anchor = this.getSlotAttachObject3d(cellId)
|
|
1367
|
+
const carrierObj3d = (carrier as any).realObject?.object3d
|
|
1368
|
+
if (anchor && carrierObj3d) {
|
|
1369
|
+
anchor.add(carrierObj3d)
|
|
1370
|
+
carrierObj3d.position.set(0, 0, 0)
|
|
1371
|
+
carrierObj3d.updateMatrixWorld(true)
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// record 제거 — silent (mapping cascade 회피). 동일 cellId 의 *모든* record 정리.
|
|
1375
|
+
this._setDataSilently(records.filter(r => r?.cellId !== cellId))
|
|
1376
|
+
|
|
1377
|
+
return carrier
|
|
1086
1378
|
}
|
|
1087
1379
|
|
|
1380
|
+
/**
|
|
1381
|
+
* cell 이 carrier 를 받을 수 있는가.
|
|
1382
|
+
* - isEmpty 위치 는 거부 (modeling 차원 location-less)
|
|
1383
|
+
* - state.data 에 record 있으면 점유 → false
|
|
1384
|
+
* - 자기 자신 carrier 가 child 면 idempotent true (자기 자리 복귀)
|
|
1385
|
+
*/
|
|
1088
1386
|
canReceiveAt(slotIdOrLocation: string, carrier?: Component): boolean {
|
|
1089
|
-
const
|
|
1090
|
-
if (!
|
|
1091
|
-
const parsed = this.
|
|
1387
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1388
|
+
if (!cellId) return false
|
|
1389
|
+
const parsed = this.parseSlotId(cellId)
|
|
1092
1390
|
if (!parsed) return false
|
|
1093
|
-
// isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
|
|
1094
1391
|
const override = this.cellOverrides[`${parsed.col}-${parsed.row}`]
|
|
1095
1392
|
if (override?.isEmpty) return false
|
|
1096
|
-
|
|
1097
|
-
|
|
1393
|
+
|
|
1394
|
+
const records = this.records as Array<any>
|
|
1395
|
+
if (records.some(r => r?.cellId === cellId)) return false
|
|
1396
|
+
const existingChild = this._carrierChildAt(cellId)
|
|
1397
|
+
if (existingChild && existingChild !== carrier) return false
|
|
1398
|
+
return true
|
|
1098
1399
|
}
|
|
1099
1400
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1401
|
+
/**
|
|
1402
|
+
* Carrier 가 RackGrid 의 slot 으로 들어옴 — 즉시 dispose + state.data 에 record 로
|
|
1403
|
+
* 환원. 결과: stock InstancedMesh 가 그 자리에 instance 표시, RackGrid 의 자식 트리는
|
|
1404
|
+
* 깨끗 (rack-grid-cell 만 남음).
|
|
1405
|
+
*/
|
|
1406
|
+
async receiveAt(slotIdOrLocation: string, carrier: Component, _options?: any): Promise<void> {
|
|
1407
|
+
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1408
|
+
if (!cellId) throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`)
|
|
1409
|
+
|
|
1410
|
+
// disposed carrier 의 재처리 차단
|
|
1411
|
+
if ((carrier as any)?._disposed) {
|
|
1412
|
+
throw new Error(
|
|
1413
|
+
`RackGrid.receiveAt("${cellId}"): carrier is already disposed. ` +
|
|
1414
|
+
'After a successful pickAndPlace the carrier becomes a state.data record — ' +
|
|
1415
|
+
'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.'
|
|
1416
|
+
)
|
|
1417
|
+
}
|
|
1418
|
+
if (!this.canReceiveAt(cellId, carrier)) {
|
|
1419
|
+
;(this as any).trigger?.('transfer-rejected', {
|
|
1420
|
+
type: 'transfer-rejected',
|
|
1421
|
+
component: carrier, container: this, reason: 'slot-occupied', cellId
|
|
1422
|
+
})
|
|
1423
|
+
return
|
|
1108
1424
|
}
|
|
1109
|
-
|
|
1425
|
+
|
|
1426
|
+
const record = this.recordFromCarrier(carrier, cellId)
|
|
1427
|
+
|
|
1428
|
+
// Carrier 의 parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 RackGrid 자신.
|
|
1429
|
+
const carrierParent: any = (carrier as any).parent
|
|
1430
|
+
if (carrierParent && typeof carrierParent.removeComponent === 'function') {
|
|
1431
|
+
carrierParent.removeComponent(carrier)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// 명시적 Three.js detach — RealObject.dispose() 의 clear() 는 object3d 자체를
|
|
1435
|
+
// scene graph 의 parent 에서 떼지 않으므로 ghost 방지 위해 명시 제거.
|
|
1436
|
+
const carrierObj3d: any = (carrier as any)._realObject?.object3d
|
|
1437
|
+
if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
|
|
1438
|
+
carrierObj3d.parent.remove(carrierObj3d)
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
;(carrier as any).dispose?.()
|
|
1442
|
+
|
|
1443
|
+
// state.data 에 push — silent (mapping cascade 회피). 동일 cellId 중복 방어.
|
|
1444
|
+
const currentRecords = (this.records as any[]).filter(r => r?.cellId !== cellId)
|
|
1445
|
+
this._setDataSilently([...currentRecords, record])
|
|
1446
|
+
|
|
1447
|
+
;(this as any).trigger?.('transfer-received', {
|
|
1448
|
+
type: 'transfer-received',
|
|
1449
|
+
component: carrier, container: this, slotId: cellId, record
|
|
1450
|
+
})
|
|
1110
1451
|
}
|
|
1111
1452
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1453
|
+
/**
|
|
1454
|
+
* Carrier 의 state 를 state.data record 로 추출. transform/position 관련은 record
|
|
1455
|
+
* 와 무관해 skip. (storage-rack 의 동일 패턴.)
|
|
1456
|
+
*/
|
|
1457
|
+
recordFromCarrier(carrier: Component, cellId: string): SlotRecord {
|
|
1458
|
+
const state: any = (carrier as any).state ?? {}
|
|
1459
|
+
const SKIP_KEYS = new Set([
|
|
1460
|
+
'left', 'top', 'zPos',
|
|
1461
|
+
'transform', 'rotation', 'scale',
|
|
1462
|
+
'_transferSlotId',
|
|
1463
|
+
'cellId',
|
|
1464
|
+
'id',
|
|
1465
|
+
'refid'
|
|
1466
|
+
])
|
|
1467
|
+
const record: any = { cellId, type: state.type }
|
|
1468
|
+
for (const key of Object.keys(state)) {
|
|
1469
|
+
if (SKIP_KEYS.has(key)) continue
|
|
1470
|
+
record[key] = state[key]
|
|
1471
|
+
}
|
|
1472
|
+
return record
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ── CarrierHolder — attach frame for direct carrier children ─────────────
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* children gate — CarrierHolder.containable 의 *carriable-only* 제한을 완화.
|
|
1479
|
+
* RackGrid 의 자식은 두 종류:
|
|
1480
|
+
* - RackGridCell : modeling-time visual proxy (carriable 아님)
|
|
1481
|
+
* - carrier : Plan A 의 obtainCarrier 가 materialize (carriable)
|
|
1482
|
+
* 둘 다 허용해야 정상 동작.
|
|
1483
|
+
*/
|
|
1484
|
+
containable(component: Component): boolean {
|
|
1485
|
+
if (!component) return false
|
|
1486
|
+
if ((component as any)?.state?.type === 'rack-grid-cell') return true
|
|
1487
|
+
return (component as any).isCarriable === true
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Attach frame for direct-child carrier — `applyHolderAttachPoint` 가 이 결과의
|
|
1492
|
+
* `attach` 에 carrier obj3d 를 attach 하고 `localPosition` 을 set. cell anchor
|
|
1493
|
+
* 반환 + carried placement 와 함께 동작 — carrier 가 cell 위치에 고정.
|
|
1494
|
+
*/
|
|
1495
|
+
attachPointFor(carrier: Component): AttachFrame | null {
|
|
1496
|
+
const cellId = (carrier as any)?.state?.cellId as string | undefined
|
|
1497
|
+
if (cellId) {
|
|
1498
|
+
const obj = this.getSlotAttachObject3d(cellId)
|
|
1499
|
+
if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
|
|
1500
|
+
}
|
|
1501
|
+
const root = this._realObject?.object3d
|
|
1502
|
+
if (!root) return null
|
|
1503
|
+
return { attach: root, localPosition: { x: 0, y: 0, z: 0 } }
|
|
1115
1504
|
}
|
|
1116
1505
|
|
|
1117
1506
|
/**
|
|
1118
1507
|
* Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
|
|
1119
|
-
* 위치한 invisible Object3D. popup tether
|
|
1120
|
-
*
|
|
1508
|
+
* 위치한 invisible Object3D. popup tether 등이 이 object3d 의 matrixWorld 를
|
|
1509
|
+
* 사용. lazy 생성 + cache.
|
|
1510
|
+
*/
|
|
1511
|
+
private _attachAnchorBySlot: Map<string, THREE.Object3D> = new Map()
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Anchor position cache 의 signature — rack 의 차원 state 변경 시 무효화 trigger.
|
|
1515
|
+
* 동일 signature 인 동안 *anchor.position.set 재계산 skip*. crane 의 첫
|
|
1516
|
+
* findAdjacentSlots 가 *수많은 anchor 의 position.set 매 호출* — *동일 차원
|
|
1517
|
+
* state 일 때* 의 *중복 계산 차단*.
|
|
1121
1518
|
*/
|
|
1122
|
-
private
|
|
1519
|
+
private _attachAnchorSig?: string
|
|
1123
1520
|
|
|
1124
1521
|
getSlotAttachObject3d(slotId: string): THREE.Object3D | undefined {
|
|
1125
|
-
const parsed = this.
|
|
1126
|
-
if (!parsed)
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1522
|
+
const parsed = this.parseSlotId(slotId)
|
|
1523
|
+
if (!parsed) {
|
|
1524
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: invalid cellId format "${slotId}"`)
|
|
1525
|
+
return undefined
|
|
1526
|
+
}
|
|
1527
|
+
if (parsed.col < 0 || parsed.col >= this.columns) {
|
|
1528
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: col=${parsed.col} out of range [0, ${this.columns})`)
|
|
1529
|
+
return undefined
|
|
1530
|
+
}
|
|
1531
|
+
if (parsed.row < 0 || parsed.row >= this.rackRows) {
|
|
1532
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: row=${parsed.row} out of range [0, ${this.rackRows})`)
|
|
1533
|
+
return undefined
|
|
1534
|
+
}
|
|
1535
|
+
if (parsed.shelf < 0 || parsed.shelf >= this.shelves) {
|
|
1536
|
+
console.warn(`[rack-grid] getSlotAttachObject3d: shelf=${parsed.shelf} out of range [0, ${this.shelves}) — cellId "${slotId}" — fork 가 fallback 위치 (한 층 아래 등) 로 갈 수 있음`)
|
|
1537
|
+
return undefined
|
|
1538
|
+
}
|
|
1130
1539
|
|
|
1131
1540
|
const ro: any = (this as any)._realObject
|
|
1132
1541
|
if (!ro?.object3d) return undefined
|
|
1133
1542
|
|
|
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 위치 와 동일 공식
|
|
1543
|
+
// signature 검사 — 차원 state 변경 시 *모든 anchor invalidate* 후 새로 생성.
|
|
1544
|
+
// 동일 sig 일 때 *기존 anchor 그대로 사용 (position.set skip)*.
|
|
1143
1545
|
const rs: any = this.state
|
|
1144
1546
|
const cols = this.columns
|
|
1145
1547
|
const rows = this.rackRows
|
|
@@ -1148,19 +1550,39 @@ export default class RackGrid
|
|
|
1148
1550
|
const height = (rs?.depth as number) ?? 2000
|
|
1149
1551
|
const depth = (rs?.height as number) ?? 200
|
|
1150
1552
|
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
|
|
1553
|
+
const sig = `${cols}-${rows}-${shelves}-${width}-${height}-${depth}-${shelfBase}`
|
|
1158
1554
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1555
|
+
if (this._attachAnchorSig !== sig) {
|
|
1556
|
+
// 차원 변경 — 모든 기존 anchor 폐기 후 sig 갱신.
|
|
1557
|
+
for (const oldObj of this._attachAnchorBySlot.values()) {
|
|
1558
|
+
ro.object3d.remove(oldObj)
|
|
1559
|
+
}
|
|
1560
|
+
this._attachAnchorBySlot.clear()
|
|
1561
|
+
this._attachAnchorSig = sig
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
let obj = this._attachAnchorBySlot.get(slotId)
|
|
1565
|
+
if (!obj) {
|
|
1566
|
+
obj = new THREE.Object3D()
|
|
1567
|
+
obj.name = `rack-grid-anchor:${slotId}`
|
|
1568
|
+
ro.object3d.add(obj)
|
|
1569
|
+
this._attachAnchorBySlot.set(slotId, obj)
|
|
1570
|
+
|
|
1571
|
+
// 신규 anchor — position 계산 + set (1 회 만, 이후 호출은 cache hit).
|
|
1572
|
+
const shelfZone = height - shelfBase
|
|
1573
|
+
const bayW = width / cols
|
|
1574
|
+
const bayD = depth / rows
|
|
1575
|
+
const cellY = shelfZone / shelves
|
|
1576
|
+
const baseY = -height / 2
|
|
1577
|
+
const shelfBaseY = baseY + shelfBase
|
|
1578
|
+
const stockD = cellY * 0.7
|
|
1579
|
+
|
|
1580
|
+
const cx = (parsed.col - cols / 2 + 0.5) * bayW
|
|
1581
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellY
|
|
1582
|
+
const cy = cellBottomY + stockD / 2
|
|
1583
|
+
const cz = (parsed.row - rows / 2 + 0.5) * bayD
|
|
1584
|
+
obj.position.set(cx, cy, cz)
|
|
1585
|
+
}
|
|
1164
1586
|
// *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
|
|
1165
1587
|
ro.object3d.updateMatrixWorld(true)
|
|
1166
1588
|
obj.updateMatrixWorld(true)
|
|
@@ -1168,7 +1590,7 @@ export default class RackGrid
|
|
|
1168
1590
|
}
|
|
1169
1591
|
|
|
1170
1592
|
getSlotSize(slotId: string): { width: number; height: number; depth: number } | undefined {
|
|
1171
|
-
const parsed = this.
|
|
1593
|
+
const parsed = this.parseSlotId(slotId)
|
|
1172
1594
|
if (!parsed) return undefined
|
|
1173
1595
|
const rs: any = this.state
|
|
1174
1596
|
const width = (rs?.width as number) ?? 400
|
|
@@ -1185,7 +1607,7 @@ export default class RackGrid
|
|
|
1185
1607
|
}
|
|
1186
1608
|
|
|
1187
1609
|
cellCenter2D(slotId: string): { x: number; y: number } | null {
|
|
1188
|
-
const parsed = this.
|
|
1610
|
+
const parsed = this.parseSlotId(slotId)
|
|
1189
1611
|
if (!parsed) return null
|
|
1190
1612
|
const rs: any = this.state
|
|
1191
1613
|
const left = (rs?.left as number) ?? 0
|