@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +44 -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 +578 -48
  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 +103 -10
  14. package/dist/rack-grid.js +484 -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 +40 -6
  19. package/dist/storage-rack.js +111 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +625 -57
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +504 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +111 -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/test/things-scene-loader-impl.mjs +37 -0
  43. package/test/things-scene-loader.mjs +24 -0
  44. package/translations/en.json +2 -0
  45. package/translations/ja.json +2 -0
  46. package/translations/ko.json +2 -0
  47. package/translations/ms.json +2 -0
  48. package/translations/zh.json +2 -0
  49. 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 !!cell.state.isEmpty
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
- return Math.max(1, Math.floor(this.state.columns ?? 5))
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
- return Math.max(1, Math.floor(this.state.rows ?? 1))
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
- return Math.max(1, Math.floor(this.state.shelves ?? 4))
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
- cellIdOf(col: number, row: number = 1, shelf: number = 1): string {
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
- parseCellId(cellId: string): { col: number; row: number; shelf: number } | null {
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.parseCellId(cellId)
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
- private _childRackAt(posKey: string): StorageRack | null {
1055
- const parsed = this.parsePosKey(posKey)
1056
- if (!parsed) return null
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
- 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
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 컨트랙 — 자식 StorageRack 으로 위임 ────
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
- 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
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 slotId = this._resolveToCellId(slotIdOrLocation)
1081
- if (!slotId) return null
1082
- const parsed = this.parseCellId(slotId)
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 child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1085
- return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null
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 slotId = this._resolveToCellId(slotIdOrLocation)
1090
- if (!slotId) return false
1091
- const parsed = this.parseCellId(slotId)
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
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1097
- return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false
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
- 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}`)
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
- return child.receiveAt(`0-0-${parsed.shelf}`, carrier, options)
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
- recordFromCarrier(carrier: Component, slotId: string): SlotRecord {
1113
- const { id, refid, transform, ...rest } = ((carrier.state as any) || {}) as any
1114
- return { ...rest, cellId: slotId }
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 / Carriable.applyHolderAttachPoint
1120
- * object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
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 _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
1519
+ private _attachAnchorSig?: string
1123
1520
 
1124
1521
  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
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
- 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 위치 와 동일 공식
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 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
1553
+ const sig = `${cols}-${rows}-${shelves}-${width}-${height}-${depth}-${shelfBase}`
1158
1554
 
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)
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.parseCellId(slotId)
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.parseCellId(slotId)
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