@operato/scene-storage 10.0.0-beta.44 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/crane-3d.d.ts +10 -0
  3. package/dist/crane-3d.js +34 -5
  4. package/dist/crane-3d.js.map +1 -1
  5. package/dist/crane.d.ts +136 -6
  6. package/dist/crane.js +567 -46
  7. package/dist/crane.js.map +1 -1
  8. package/dist/parcel-3d.d.ts +1 -0
  9. package/dist/parcel-3d.js +18 -1
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.js +26 -8
  12. package/dist/rack-grid-3d.js.map +1 -1
  13. package/dist/rack-grid.d.ts +94 -10
  14. package/dist/rack-grid.js +468 -86
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/storage-rack-3d.js +1 -1
  17. package/dist/storage-rack-3d.js.map +1 -1
  18. package/dist/storage-rack.d.ts +31 -6
  19. package/dist/storage-rack.js +96 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +615 -55
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +488 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +96 -14
  29. package/test/test-coord-alignment.ts +2 -2
  30. package/test/test-crane-bay-match.ts +130 -0
  31. package/test/test-crane-binding-resolve.ts +168 -0
  32. package/test/test-crane-duration.ts +90 -0
  33. package/test/test-crane-rotation-reach.ts +218 -0
  34. package/test/test-rack-grid-3d-alignment.ts +235 -0
  35. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  36. package/test/test-rack-grid-cell.ts +2 -2
  37. package/test/test-rack-grid-location.ts +2 -2
  38. package/test/test-rack-grid-occupied-slots.ts +165 -0
  39. package/test/test-rack-grid-picking-position.ts +154 -0
  40. package/test/test-rack-grid-slot-api.ts +483 -0
  41. package/test/test-slot-ids-enumeration.ts +137 -0
  42. 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 !!cell.state.isEmpty
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
- return Math.max(1, Math.floor(this.state.columns ?? 5))
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
- return Math.max(1, Math.floor(this.state.rows ?? 1))
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
- return Math.max(1, Math.floor(this.state.shelves ?? 4))
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
- cellIdOf(col: number, row: number = 1, shelf: number = 1): string {
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
- parseCellId(cellId: string): { col: number; row: number; shelf: number } | null {
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.parseCellId(cellId)
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
- private _childRackAt(posKey: string): StorageRack | null {
1055
- const parsed = this.parsePosKey(posKey)
1056
- if (!parsed) return null
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
- for (const c of children) {
1059
- const type = (c.state as any)?.type
1060
- if (type !== 'storage-rack') continue
1061
- const childCol = ((c.state as any)?.column ?? 1) - 1
1062
- const childRow = ((c.state as any)?.row ?? 1) - 1
1063
- if (childCol === parsed.col && childRow === parsed.row) {
1064
- return c as unknown as StorageRack
1065
- }
1066
- }
1067
- return null
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 컨트랙 — 자식 StorageRack 으로 위임 ────
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
- hasCarrierAt(slotId: string): boolean {
1073
- const parsed = this.parseCellId(slotId)
1074
- if (!parsed) return false
1075
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1076
- return child?.hasCarrierAt(`0-0-${parsed.shelf}`) ?? false
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 slotId = this._resolveToCellId(slotIdOrLocation)
1081
- if (!slotId) return null
1082
- const parsed = this.parseCellId(slotId)
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 child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1085
- return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null
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 slotId = this._resolveToCellId(slotIdOrLocation)
1090
- if (!slotId) return false
1091
- const parsed = this.parseCellId(slotId)
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
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1097
- return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false
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
- async receiveAt(slotIdOrLocation: string, carrier: Component, options?: any): Promise<void> {
1101
- const slotId = this._resolveToCellId(slotIdOrLocation)
1102
- if (!slotId) throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`)
1103
- const parsed = this.parseCellId(slotId)
1104
- if (!parsed) throw new Error(`RackGrid.receiveAt: invalid cellId "${slotId}"`)
1105
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1106
- if (!child) {
1107
- throw new Error(`RackGrid.receiveAt: no StorageRack at posKey=${parsed.col}-${parsed.row}`)
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 child.receiveAt(`0-0-${parsed.shelf}`, carrier, options)
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
- recordFromCarrier(carrier: Component, slotId: string): SlotRecord {
1113
- const { id, refid, transform, ...rest } = ((carrier.state as any) || {}) as any
1114
- return { ...rest, cellId: slotId }
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 / Carriable.applyHolderAttachPoint
1120
- * object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
1492
+ * 위치한 invisible Object3D. popup tether 등이 object3d 의 matrixWorld 를
1493
+ * 사용. lazy 생성 + cache.
1121
1494
  */
1122
- private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
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.parseCellId(slotId)
1126
- if (!parsed) return undefined
1127
- if (parsed.col < 0 || parsed.col >= this.columns) return undefined
1128
- if (parsed.row < 0 || parsed.row >= this.rackRows) return undefined
1129
- if (parsed.shelf < 0 || parsed.shelf >= this.shelves) return undefined
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
- let obj = this._attachAnchorByCell.get(slotId)
1135
- if (!obj) {
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 shelfZone = height - shelfBase
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
- const cx = (parsed.col - cols / 2 + 0.5) * bayW
1160
- const cellBottomY = shelfBaseY + parsed.shelf * cellY
1161
- const cy = cellBottomY + stockD / 2
1162
- const cz = (parsed.row - rows / 2 + 0.5) * bayD
1163
- obj.position.set(cx, cy, cz)
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.parseCellId(slotId)
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.parseCellId(slotId)
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