@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/dist/rack-grid.js CHANGED
@@ -30,9 +30,9 @@ import { __decorate } from "tslib";
30
30
  * - cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments) — *grid + shelf*
31
31
  * - 자식 StorageRack 내부에서는 `0-0-${shelf-1}` (자체 cellId 형식). RackGrid 가 변환.
32
32
  */
33
- import { ContainerAbstract, Layout, Model, sceneComponent } from '@hatiolab/things-scene';
33
+ import { Component, ContainerAbstract, Layout, Model, sceneComponent } from '@hatiolab/things-scene';
34
34
  import * as THREE from 'three';
35
- import { Placeable, SlotTarget } from '@operato/scene-base';
35
+ import { CarrierHolder, Placeable, SlotTarget } from '@operato/scene-base';
36
36
  import { RackGrid3D } from './rack-grid-3d.js';
37
37
  // RackGridCell 의 refid 충돌 회피 — load 시 동적 생성되는 cell 의 refid 가 *부모
38
38
  // RackGrid + 모델 안 다른 컴포넌트* refid 와 겹치지 않도록 *높은 시작값 + monotonic
@@ -41,6 +41,12 @@ let _cellRefidCounter = 100_000_000;
41
41
  function _nextCellRefid() {
42
42
  return _cellRefidCounter++;
43
43
  }
44
+ // Transient carrier (obtainCarrier 가 materialize 한 컴포넌트) 용 refid. 모델 안
45
+ // 다른 컴포넌트와 겹치지 않도록 별도 대역.
46
+ let _carrierRefidCounter = 200_000_000;
47
+ function _nextCarrierRefid() {
48
+ return _carrierRefidCounter++;
49
+ }
44
50
  function buildNewCell(app) {
45
51
  const refid = _nextCellRefid();
46
52
  const m = Model.compile({
@@ -60,6 +66,43 @@ function buildNewCell(app) {
60
66
  return m;
61
67
  }
62
68
  const TABLE_LAYOUT = Layout.get('table');
69
+ /**
70
+ * RackGrid 의 *layout-aware children* — `rack-grid-cell` (= 데이타-proxy 시각
71
+ * 자식) 만 Table 분배 대상. obtainCarrier 가 만든 *실물 carrier* (Carriable —
72
+ * Parcel/Pallet 등) 는 *transient* 라 layout 분배 외. Table.reflow 가 *carrier
73
+ * 도 cells 다음 인덱스로 분배* 하면 `heights[rackRows] = undefined` → carrier.
74
+ * state.height = NaN → BoxGeometry NaN → invisible. 그 회귀 방지.
75
+ */
76
+ const RACK_GRID_LAYOUT = {
77
+ reflow(container) {
78
+ const all = container.components ?? [];
79
+ const cellsOnly = all.filter((c) => c?.state?.type === 'rack-grid-cell');
80
+ if (cellsOnly.length === all.length) {
81
+ return TABLE_LAYOUT.reflow.call(TABLE_LAYOUT, container);
82
+ }
83
+ // Carrier 자식이 있는 경우 — container.components 를 *cells only* 로
84
+ // 임시 교체 후 base reflow. base 가 idx 기반 분배 시 carrier 가 *out of
85
+ // range* 인덱스에 안 오게.
86
+ const desc = Object.getOwnPropertyDescriptor(container, 'components');
87
+ Object.defineProperty(container, 'components', { value: cellsOnly, configurable: true, writable: true });
88
+ try {
89
+ TABLE_LAYOUT.reflow.call(TABLE_LAYOUT, container);
90
+ }
91
+ finally {
92
+ if (desc)
93
+ Object.defineProperty(container, 'components', desc);
94
+ else
95
+ delete container.components;
96
+ }
97
+ },
98
+ capturables(container) { return TABLE_LAYOUT.capturables(container); },
99
+ drawables(container) { return TABLE_LAYOUT.drawables(container); },
100
+ isStuck(component) { return TABLE_LAYOUT.isStuck?.(component) ?? true; },
101
+ keyNavigate(container, component, e) {
102
+ return TABLE_LAYOUT.keyNavigate?.(container, component, e);
103
+ },
104
+ joinType: TABLE_LAYOUT.joinType
105
+ };
63
106
  function arrayOf(value, size) {
64
107
  const arr = [];
65
108
  for (let i = 0; i < size; i++)
@@ -113,7 +156,23 @@ const rowControlHandler = {
113
156
  // handler 안 `this` 가 *해당 RackGrid 인스턴스* 를 가리키도록 (화살표 함수가
114
157
  // getter 의 `this` 를 capture). rack-table-cell 의 동일 패턴.
115
158
  // ─── RackGrid ─────────────────────────────────────────────────────────────────
116
- let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
159
+ let RackGrid = class RackGrid extends CarrierHolder(Placeable(ContainerAbstract)) {
160
+ // Phase Auto-Nav (AN-PR-2) — Obstacle 자격. AMR / mover 의 자동 path planning
161
+ // 시 회피 대상. `state.isObstacle` 명시 false 시 override (예외 처리).
162
+ get isObstacle() {
163
+ return this.state?.isObstacle !== false;
164
+ }
165
+ obstacleBoundingBox() {
166
+ const s = this.state;
167
+ if (typeof s?.left !== 'number')
168
+ return null;
169
+ return {
170
+ left: s.left, top: s.top,
171
+ width: s.width, height: s.height,
172
+ y: typeof s.zPos === 'number' ? s.zPos : 0,
173
+ zHeight: typeof s.depth === 'number' ? s.depth : 0
174
+ };
175
+ }
117
176
  static placement = 'floor';
118
177
  static align = 'bottom';
119
178
  static defaultDepth = (h) => h.ceiling - h.floor;
@@ -545,12 +604,118 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
545
604
  * truth, 없으면 cellOverrides[posKey].isEmpty fallback.
546
605
  */
547
606
  isBayEmpty(col, row = 0) {
607
+ // cell.state.isEmpty 가 *명시 true* 일 때만 우선. false / undefined 면 fallback
608
+ // 으로 cellOverrides 확인. _buildCells 가 모든 bay 에 cell 자동 생성 + default
609
+ // isEmpty=false 라서, "cell 객체 존재 만으로 cellOverrides 차단" 시 사용자가
610
+ // cellOverrides 만 설정한 모델에서 isEmpty 효과 0 회귀.
548
611
  const cell = this.cellAt(col, row);
549
- if (cell)
550
- return !!cell.state.isEmpty;
612
+ if (cell?.state?.isEmpty === true)
613
+ return true;
551
614
  const posKey = `${col}-${row}`;
552
615
  return !!this.cellOverrides[posKey]?.isEmpty;
553
616
  }
617
+ /**
618
+ * 모든 slot id 의 목록. columns × rackRows × shelves 조합. *isEmpty bay 는 제외*
619
+ * (물리적으로 없는 위치). SlottedHolder.slotIds — capability 기반 enumeration
620
+ * entry point.
621
+ */
622
+ slotIds() {
623
+ const ids = [];
624
+ const cols = this.columns;
625
+ const rows = this.rackRows;
626
+ const shelves = this.shelves;
627
+ for (let col = 0; col < cols; col++) {
628
+ for (let row = 0; row < rows; row++) {
629
+ if (this.isBayEmpty(col, row))
630
+ continue; // 물리적 없음 — 제외
631
+ for (let shelf = 0; shelf < shelves; shelf++) {
632
+ ids.push(`${col}-${row}-${shelf}`);
633
+ }
634
+ }
635
+ }
636
+ return ids;
637
+ }
638
+ /**
639
+ * 점유된 slot 의 id 목록 — state.data record + child *carrier* 둘 다.
640
+ *
641
+ * 중요: RackGridCell (시각 proxy) 의 *state.cellId 는 bayKey ("col-row")
642
+ * 형식* (`_syncChildCellIds` 가 set). slot 의 *full id ("col-row-shelf")* 와
643
+ * 다르다 — RackGridCell 은 *carrier 아닌 시각 proxy* 라 *occupied 카운트
644
+ * 대상 아님*. carriable child (= 실 carrier) 만 sweep.
645
+ *
646
+ * @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
647
+ */
648
+ occupiedSlotIds(filter) {
649
+ const set = new Set();
650
+ for (const r of this.records) {
651
+ const cid = r?.cellId;
652
+ if (typeof cid === 'string' && (!filter || filter(cid)))
653
+ set.add(cid);
654
+ }
655
+ for (const c of this.components ?? []) {
656
+ // *carriable 만* — RackGridCell (rack-grid-cell type) 의 bayKey 제외.
657
+ if (!c?.isCarriable)
658
+ continue;
659
+ const cid = c?.state?.cellId;
660
+ if (typeof cid === 'string' && (!filter || filter(cid)))
661
+ set.add(cid);
662
+ }
663
+ return Array.from(set);
664
+ }
665
+ /** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). isEmpty bay 자동 제외. */
666
+ emptySlotIds(filter) {
667
+ const occ = new Set(this.occupiedSlotIds());
668
+ const result = [];
669
+ for (const id of this.slotIds()) {
670
+ if (occ.has(id))
671
+ continue;
672
+ if (filter && !filter(id))
673
+ continue;
674
+ result.push(id);
675
+ }
676
+ return result;
677
+ }
678
+ /**
679
+ * slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
680
+ * reach 검사 같은 *match 전 단계* 에서 *anchor 생성 0* 으로 사용 가능.
681
+ * match 후 *실제 attach* 가 필요할 때 `getSlotAttachObject3d` 가 lazy 생성.
682
+ */
683
+ slotWorldPosition(slotId) {
684
+ const parsed = this.parseSlotId(slotId);
685
+ if (!parsed)
686
+ return undefined;
687
+ if (parsed.col < 0 || parsed.col >= this.columns)
688
+ return undefined;
689
+ if (parsed.row < 0 || parsed.row >= this.rackRows)
690
+ return undefined;
691
+ if (parsed.shelf < 0 || parsed.shelf >= this.shelves)
692
+ return undefined;
693
+ const ro = this._realObject;
694
+ if (!ro?.object3d)
695
+ return undefined;
696
+ const rs = this.state;
697
+ const width = rs?.width ?? 400;
698
+ const height = rs?.depth ?? 2000;
699
+ const depth = rs?.height ?? 200;
700
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, height * 0.9));
701
+ const cols = this.columns;
702
+ const rows = this.rackRows;
703
+ const shelves = this.shelves;
704
+ const shelfZone = height - shelfBase;
705
+ const bayW = width / cols;
706
+ const bayD = depth / rows;
707
+ const cellY = shelfZone / shelves;
708
+ const baseY = -height / 2;
709
+ const shelfBaseY = baseY + shelfBase;
710
+ const stockD = cellY * 0.7;
711
+ const cx = (parsed.col - cols / 2 + 0.5) * bayW;
712
+ const cellBottomY = shelfBaseY + parsed.shelf * cellY;
713
+ const cy = cellBottomY + stockD / 2;
714
+ const cz = (parsed.row - rows / 2 + 0.5) * bayD;
715
+ const v = new THREE.Vector3(cx, cy, cz);
716
+ ro.object3d.localToWorld(v);
717
+ return { x: v.x, y: v.y, z: v.z };
718
+ }
554
719
  buildRealObject() {
555
720
  return new RackGrid3D(this);
556
721
  }
@@ -721,13 +886,16 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
721
886
  }
722
887
  // ── Grid layout ─────────────────────────────────────────
723
888
  get columns() {
724
- return Math.max(1, Math.floor(this.state.columns ?? 5));
889
+ const v = this.state.columns;
890
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 5));
725
891
  }
726
892
  get rackRows() {
727
- return Math.max(1, Math.floor(this.state.rows ?? 1));
893
+ const v = this.state.rows;
894
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 1));
728
895
  }
729
896
  get shelves() {
730
- return Math.max(1, Math.floor(this.state.shelves ?? 4));
897
+ const v = this.state.shelves;
898
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 4));
731
899
  }
732
900
  // ── cellId / posKey 변환 ────────────────────────────────
733
901
  /** posKey = `${col-1}-${row-1}` (0-based, 2 segments). 1-based input. */
@@ -735,10 +903,10 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
735
903
  return `${col - 1}-${row - 1}`;
736
904
  }
737
905
  /** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
738
- cellIdOf(col, row = 1, shelf = 1) {
906
+ slotIdOf(col, row = 1, shelf = 1) {
739
907
  return `${col - 1}-${row - 1}-${shelf - 1}`;
740
908
  }
741
- parseCellId(cellId) {
909
+ parseSlotId(cellId) {
742
910
  const parts = cellId.split('-');
743
911
  if (parts.length !== 3)
744
912
  return null;
@@ -789,7 +957,7 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
789
957
  * section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
790
958
  */
791
959
  locationOf(cellId) {
792
- const parsed = this.parseCellId(cellId);
960
+ const parsed = this.parseSlotId(cellId);
793
961
  if (!parsed)
794
962
  return null;
795
963
  const posKey = `${parsed.col}-${parsed.row}`;
@@ -913,99 +1081,312 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
913
1081
  * 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
914
1082
  * 자식 추가 시 자동 할당 또는 사용자가 명시.
915
1083
  */
916
- _childRackAt(posKey) {
917
- const parsed = this.parsePosKey(posKey);
918
- if (!parsed)
919
- return null;
1084
+ /**
1085
+ * cellId 매칭되는 RackGrid 의 직접 자식 carrier (operation archetype).
1086
+ * RackGrid 의 자식 중 rack-grid-cell (시각 proxy) 외에 obtainCarrier 가 add 한
1087
+ * transient carrier 들이 섞임 — 그 중 cellId / placement='operation' 매칭.
1088
+ * storage-rack 의 동일 패턴.
1089
+ */
1090
+ _carrierChildAt(cellId) {
920
1091
  const children = this.components ?? [];
921
- for (const c of children) {
922
- const type = c.state?.type;
923
- if (type !== 'storage-rack')
924
- continue;
925
- const childCol = (c.state?.column ?? 1) - 1;
926
- const childRow = (c.state?.row ?? 1) - 1;
927
- if (childCol === parsed.col && childRow === parsed.row) {
928
- return c;
929
- }
930
- }
931
- return null;
1092
+ return children.find(c => {
1093
+ const placement = c.constructor.placement;
1094
+ return placement === 'operation' && c.state?.cellId === cellId;
1095
+ });
932
1096
  }
933
- // ── SlottedHolder 컨트랙 — 자식 StorageRack 으로 위임 ────
934
- hasCarrierAt(slotId) {
935
- const parsed = this.parseCellId(slotId);
936
- if (!parsed)
1097
+ /**
1098
+ * state.data 의 internal 갱신 — obtainCarrier / receiveAt 가 사용.
1099
+ * setState 달리 'change' / onchangeData / mapping cascade 우회 — 사용자 script
1100
+ * 의 자기-자신 호출 회귀 차단. 시각 갱신은 직접 RealObject.rebuildStockMesh 호출.
1101
+ * storage-rack 의 동일 패턴.
1102
+ */
1103
+ _setDataSilently(newData) {
1104
+ const self = this;
1105
+ if (!self._state)
1106
+ self._state = {};
1107
+ self._state.data = newData;
1108
+ self._cachedState = null;
1109
+ this._realObject?.rebuildStockMesh?.();
1110
+ }
1111
+ // ── SlottedHolder 컨트랙 — RackGrid 자체의 records 에서 직접 처리 ────
1112
+ //
1113
+ // Plan A 정신: stock 데이터는 RackGrid 의 state.data 에 sparse 로 통합 저장.
1114
+ // 자식 컴포넌트는 시각용 rack-grid-cell (light proxy) 뿐 carrier 보유 X.
1115
+ // storage-rack 의 본체 로직 (transient materialize / silent setData) 을 차용,
1116
+ // 좌표 / 사이즈만 RackGrid 의 cellAt(col,row).bounds + getSlotSize 로 적응.
1117
+ /** state.data record 또는 이미 transient materialize 된 carrier child 가 있는가. */
1118
+ hasCarrierAt(slotIdOrLocation) {
1119
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1120
+ if (!cellId)
937
1121
  return false;
938
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
939
- return child?.hasCarrierAt(`0-0-${parsed.shelf}`) ?? false;
1122
+ if (this._carrierChildAt(cellId))
1123
+ return true;
1124
+ return this.records.some(r => r.cellId === cellId);
940
1125
  }
1126
+ /**
1127
+ * carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
1128
+ * materialize 후 RackGrid 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
1129
+ * record 도 child 도 없으면 null.
1130
+ */
941
1131
  obtainCarrier(slotIdOrLocation) {
942
- const slotId = this._resolveToCellId(slotIdOrLocation);
943
- if (!slotId)
1132
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1133
+ if (!cellId)
944
1134
  return null;
945
- const parsed = this.parseCellId(slotId);
1135
+ const existing = this._carrierChildAt(cellId);
1136
+ if (existing)
1137
+ return existing;
1138
+ const records = this.records;
1139
+ const idx = records.findIndex(r => r?.cellId === cellId);
1140
+ if (idx === -1)
1141
+ return null;
1142
+ const record = records[idx];
1143
+ const carrierType = record.type || 'parcel';
1144
+ const CarrierClass = Component.register(carrierType);
1145
+ if (!CarrierClass) {
1146
+ console.warn(`[rack-grid] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`);
1147
+ return null;
1148
+ }
1149
+ // RackGrid-inner 좌표 — anchor (getSlotAttachObject3d) / stock InstancedMesh 와
1150
+ // *동일 식* 사용: 균등 분할 + center origin → top-left origin 변환.
1151
+ // 이전엔 widths/heights *비례 분할* 을 썼는데 anchor/stock 은 *균등 분할* 이라
1152
+ // 어긋났음. update loop 이 carrier.state.left/top 기반으로 위치 재계산해도
1153
+ // anchor 와 일치하도록 *처음부터 같은 식* 으로 박는다.
1154
+ const parsed = this.parseSlotId(cellId);
946
1155
  if (!parsed)
947
1156
  return null;
948
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
949
- return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null;
1157
+ const rackWidth = this.state.width ?? 400;
1158
+ const rackDepthY = this.state.height ?? 200; // 2D height = 3D Z = inner Y
1159
+ const stateDepth = this.state.depth ?? 2000; // 3D Y (vertical)
1160
+ const bayW = rackWidth / this.columns;
1161
+ const bayD = rackDepthY / this.rackRows;
1162
+ // anchor X(center) = (col - cols/2 + 0.5) * bayW → 2D top-left center = anchor.X + rackWidth/2
1163
+ const cellCenterInnerX = (parsed.col + 0.5) * bayW;
1164
+ const cellCenterInnerY = (parsed.row + 0.5) * bayD;
1165
+ // shelf level Y — anchor (getSlotAttachObject3d) 와 동일 식.
1166
+ const shelfBase = Math.max(0, Math.min(this.state.shelfBaseHeight || 0, stateDepth * 0.9));
1167
+ const shelfZone = stateDepth - shelfBase;
1168
+ const cellYHeight = shelfZone / this.shelves;
1169
+ const shelfBaseY = -stateDepth / 2 + shelfBase;
1170
+ const cellBottomY = shelfBaseY + parsed.shelf * cellYHeight;
1171
+ const slotSize = this.getSlotSize(cellId) ?? { width: 50, height: 50, depth: 50 };
1172
+ // record 의 사이즈가 *0* 또는 *non-finite (NaN/Infinity)* 이면 slotSize fallback —
1173
+ // nullish 만으로는 NaN/0 통과해 BoxGeometry NaN bug 유발.
1174
+ const pickSize = (v, fb) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fb;
1175
+ const carrierW = pickSize(record.width, slotSize.width);
1176
+ const carrierH = pickSize(record.height, slotSize.height);
1177
+ const carrierD = pickSize(record.depth, slotSize.depth);
1178
+ // 차원 / 좌표 finite 검증 — 어느 단계에서 NaN 이 들어왔는지 명시 진단.
1179
+ if (!Number.isFinite(carrierW) || !Number.isFinite(carrierH) || !Number.isFinite(carrierD) ||
1180
+ !Number.isFinite(cellCenterInnerX) || !Number.isFinite(cellCenterInnerY)) {
1181
+ console.error('[rack-grid] obtainCarrier: non-finite dim/pos — carrier 생성 차단', {
1182
+ cellId,
1183
+ carrierW, carrierH, carrierD,
1184
+ cellCenterInnerX, cellCenterInnerY,
1185
+ slotSize,
1186
+ recordSize: { w: record.width, h: record.height, d: record.depth },
1187
+ gridDims: {
1188
+ columns: this.columns, rackRows: this.rackRows, shelves: this.shelves,
1189
+ stateW: this.state?.width, stateH: this.state?.height,
1190
+ stateD: this.state?.depth
1191
+ }
1192
+ });
1193
+ return null;
1194
+ }
1195
+ // record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
1196
+ // component 와 충돌해 parent 가 잘못 잡힘.
1197
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record;
1198
+ const carrierState = {
1199
+ ...recordCopy,
1200
+ type: carrierType,
1201
+ cellId, // 슬롯 주소
1202
+ refid: _nextCarrierRefid(), // refid 충돌 회피
1203
+ width: carrierW,
1204
+ height: carrierH,
1205
+ depth: carrierD,
1206
+ left: cellCenterInnerX - carrierW / 2,
1207
+ top: cellCenterInnerY - carrierH / 2,
1208
+ // zPos = carrier 의 *parent-local 3D Y bottom*. things-scene 의 RealObject 가
1209
+ // 3D Y center = zPos + depth/2 로 계산. 미명시 시 NaN — carrier.matrixWorld NaN
1210
+ // → fork 가 NaN target 따라가서 빈 포크질. anchor 식 (cellBottomY + stockD/2)
1211
+ // 와 일치하도록 zPos = cellBottomY 명시.
1212
+ zPos: cellBottomY
1213
+ };
1214
+ const carrier = new CarrierClass(carrierState, this._app);
1215
+ this.addComponent(carrier, { silent: true });
1216
+ // 3D 강제 빌드 + holder attach + manual placement/anchor.
1217
+ void carrier.realObject;
1218
+ carrier.applyHolderAttachPoint?.();
1219
+ carrier.realObject?.setTransientPlacement?.({ policy: 'carried' });
1220
+ const anchor = this.getSlotAttachObject3d(cellId);
1221
+ const carrierObj3d = carrier.realObject?.object3d;
1222
+ if (anchor && carrierObj3d) {
1223
+ anchor.add(carrierObj3d);
1224
+ carrierObj3d.position.set(0, 0, 0);
1225
+ carrierObj3d.updateMatrixWorld(true);
1226
+ }
1227
+ // record 제거 — silent (mapping cascade 회피). 동일 cellId 의 *모든* record 정리.
1228
+ this._setDataSilently(records.filter(r => r?.cellId !== cellId));
1229
+ return carrier;
950
1230
  }
1231
+ /**
1232
+ * cell 이 carrier 를 받을 수 있는가.
1233
+ * - isEmpty 위치 는 거부 (modeling 차원 location-less)
1234
+ * - state.data 에 record 있으면 점유 → false
1235
+ * - 자기 자신 carrier 가 child 면 idempotent true (자기 자리 복귀)
1236
+ */
951
1237
  canReceiveAt(slotIdOrLocation, carrier) {
952
- const slotId = this._resolveToCellId(slotIdOrLocation);
953
- if (!slotId)
1238
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1239
+ if (!cellId)
954
1240
  return false;
955
- const parsed = this.parseCellId(slotId);
1241
+ const parsed = this.parseSlotId(cellId);
956
1242
  if (!parsed)
957
1243
  return false;
958
- // isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
959
1244
  const override = this.cellOverrides[`${parsed.col}-${parsed.row}`];
960
1245
  if (override?.isEmpty)
961
1246
  return false;
962
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
963
- return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false;
1247
+ const records = this.records;
1248
+ if (records.some(r => r?.cellId === cellId))
1249
+ return false;
1250
+ const existingChild = this._carrierChildAt(cellId);
1251
+ if (existingChild && existingChild !== carrier)
1252
+ return false;
1253
+ return true;
964
1254
  }
965
- async receiveAt(slotIdOrLocation, carrier, options) {
966
- const slotId = this._resolveToCellId(slotIdOrLocation);
967
- if (!slotId)
1255
+ /**
1256
+ * Carrier RackGrid 의 slot 으로 들어옴 — 즉시 dispose + state.data 에 record 로
1257
+ * 환원. 결과: stock InstancedMesh 가 그 자리에 instance 표시, RackGrid 의 자식 트리는
1258
+ * 깨끗 (rack-grid-cell 만 남음).
1259
+ */
1260
+ async receiveAt(slotIdOrLocation, carrier, _options) {
1261
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1262
+ if (!cellId)
968
1263
  throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`);
969
- const parsed = this.parseCellId(slotId);
970
- if (!parsed)
971
- throw new Error(`RackGrid.receiveAt: invalid cellId "${slotId}"`);
972
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
973
- if (!child) {
974
- throw new Error(`RackGrid.receiveAt: no StorageRack at posKey=${parsed.col}-${parsed.row}`);
1264
+ // disposed carrier 의 재처리 차단
1265
+ if (carrier?._disposed) {
1266
+ throw new Error(`RackGrid.receiveAt("${cellId}"): carrier is already disposed. ` +
1267
+ 'After a successful pickAndPlace the carrier becomes a state.data record — ' +
1268
+ 'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.');
1269
+ }
1270
+ if (!this.canReceiveAt(cellId, carrier)) {
1271
+ ;
1272
+ this.trigger?.('transfer-rejected', {
1273
+ type: 'transfer-rejected',
1274
+ component: carrier, container: this, reason: 'slot-occupied', cellId
1275
+ });
1276
+ return;
1277
+ }
1278
+ const record = this.recordFromCarrier(carrier, cellId);
1279
+ // Carrier 의 parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 RackGrid 자신.
1280
+ const carrierParent = carrier.parent;
1281
+ if (carrierParent && typeof carrierParent.removeComponent === 'function') {
1282
+ carrierParent.removeComponent(carrier);
975
1283
  }
976
- return child.receiveAt(`0-0-${parsed.shelf}`, carrier, options);
1284
+ // 명시적 Three.js detach — RealObject.dispose() clear() 는 object3d 자체를
1285
+ // scene graph 의 parent 에서 떼지 않으므로 ghost 방지 위해 명시 제거.
1286
+ const carrierObj3d = carrier._realObject?.object3d;
1287
+ if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
1288
+ carrierObj3d.parent.remove(carrierObj3d);
1289
+ }
1290
+ ;
1291
+ carrier.dispose?.();
1292
+ // state.data 에 push — silent (mapping cascade 회피). 동일 cellId 중복 방어.
1293
+ const currentRecords = this.records.filter(r => r?.cellId !== cellId);
1294
+ this._setDataSilently([...currentRecords, record]);
1295
+ this.trigger?.('transfer-received', {
1296
+ type: 'transfer-received',
1297
+ component: carrier, container: this, slotId: cellId, record
1298
+ });
977
1299
  }
978
- recordFromCarrier(carrier, slotId) {
979
- const { id, refid, transform, ...rest } = (carrier.state || {});
980
- return { ...rest, cellId: slotId };
1300
+ /**
1301
+ * Carrier state state.data record 추출. transform/position 관련은 record
1302
+ * 무관해 skip. (storage-rack 의 동일 패턴.)
1303
+ */
1304
+ recordFromCarrier(carrier, cellId) {
1305
+ const state = carrier.state ?? {};
1306
+ const SKIP_KEYS = new Set([
1307
+ 'left', 'top', 'zPos',
1308
+ 'transform', 'rotation', 'scale',
1309
+ '_transferSlotId',
1310
+ 'cellId',
1311
+ 'id',
1312
+ 'refid'
1313
+ ]);
1314
+ const record = { cellId, type: state.type };
1315
+ for (const key of Object.keys(state)) {
1316
+ if (SKIP_KEYS.has(key))
1317
+ continue;
1318
+ record[key] = state[key];
1319
+ }
1320
+ return record;
1321
+ }
1322
+ // ── CarrierHolder — attach frame for direct carrier children ─────────────
1323
+ /**
1324
+ * children gate — CarrierHolder.containable 의 *carriable-only* 제한을 완화.
1325
+ * RackGrid 의 자식은 두 종류:
1326
+ * - RackGridCell : modeling-time visual proxy (carriable 아님)
1327
+ * - carrier : Plan A 의 obtainCarrier 가 materialize (carriable)
1328
+ * 둘 다 허용해야 정상 동작.
1329
+ */
1330
+ containable(component) {
1331
+ if (!component)
1332
+ return false;
1333
+ if (component?.state?.type === 'rack-grid-cell')
1334
+ return true;
1335
+ return component.isCarriable === true;
1336
+ }
1337
+ /**
1338
+ * Attach frame for direct-child carrier — `applyHolderAttachPoint` 가 이 결과의
1339
+ * `attach` 에 carrier obj3d 를 attach 하고 `localPosition` 을 set. cell anchor
1340
+ * 반환 + carried placement 와 함께 동작 — carrier 가 cell 위치에 고정.
1341
+ */
1342
+ attachPointFor(carrier) {
1343
+ const cellId = carrier?.state?.cellId;
1344
+ if (cellId) {
1345
+ const obj = this.getSlotAttachObject3d(cellId);
1346
+ if (obj)
1347
+ return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } };
1348
+ }
1349
+ const root = this._realObject?.object3d;
1350
+ if (!root)
1351
+ return null;
1352
+ return { attach: root, localPosition: { x: 0, y: 0, z: 0 } };
981
1353
  }
982
1354
  /**
983
1355
  * Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
984
- * 위치한 invisible Object3D. popup tether / Carriable.applyHolderAttachPoint
985
- * object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
1356
+ * 위치한 invisible Object3D. popup tether 등이 object3d 의 matrixWorld 를
1357
+ * 사용. lazy 생성 + cache.
1358
+ */
1359
+ _attachAnchorBySlot = new Map();
1360
+ /**
1361
+ * Anchor position cache 의 signature — rack 의 차원 state 변경 시 무효화 trigger.
1362
+ * 동일 signature 인 동안 *anchor.position.set 재계산 skip*. crane 의 첫
1363
+ * findAdjacentSlots 가 *수많은 anchor 의 position.set 매 호출* — *동일 차원
1364
+ * state 일 때* 의 *중복 계산 차단*.
986
1365
  */
987
- _attachAnchorByCell = new Map();
1366
+ _attachAnchorSig;
988
1367
  getSlotAttachObject3d(slotId) {
989
- const parsed = this.parseCellId(slotId);
990
- if (!parsed)
1368
+ const parsed = this.parseSlotId(slotId);
1369
+ if (!parsed) {
1370
+ console.warn(`[rack-grid] getSlotAttachObject3d: invalid cellId format "${slotId}"`);
991
1371
  return undefined;
992
- if (parsed.col < 0 || parsed.col >= this.columns)
1372
+ }
1373
+ if (parsed.col < 0 || parsed.col >= this.columns) {
1374
+ console.warn(`[rack-grid] getSlotAttachObject3d: col=${parsed.col} out of range [0, ${this.columns})`);
993
1375
  return undefined;
994
- if (parsed.row < 0 || parsed.row >= this.rackRows)
1376
+ }
1377
+ if (parsed.row < 0 || parsed.row >= this.rackRows) {
1378
+ console.warn(`[rack-grid] getSlotAttachObject3d: row=${parsed.row} out of range [0, ${this.rackRows})`);
995
1379
  return undefined;
996
- if (parsed.shelf < 0 || parsed.shelf >= this.shelves)
1380
+ }
1381
+ if (parsed.shelf < 0 || parsed.shelf >= this.shelves) {
1382
+ console.warn(`[rack-grid] getSlotAttachObject3d: shelf=${parsed.shelf} out of range [0, ${this.shelves}) — cellId "${slotId}" — fork 가 fallback 위치 (한 층 아래 등) 로 갈 수 있음`);
997
1383
  return undefined;
1384
+ }
998
1385
  const ro = this._realObject;
999
1386
  if (!ro?.object3d)
1000
1387
  return undefined;
1001
- let obj = this._attachAnchorByCell.get(slotId);
1002
- if (!obj) {
1003
- obj = new THREE.Object3D();
1004
- obj.name = `rack-grid-anchor:${slotId}`;
1005
- ro.object3d.add(obj);
1006
- this._attachAnchorByCell.set(slotId, obj);
1007
- }
1008
- // 위치 갱신 — Stock InstancedMesh 의 instance 위치 와 동일 공식
1388
+ // signature 검사 — 차원 state 변경 시 *모든 anchor invalidate* 후 새로 생성.
1389
+ // 동일 sig 일 때 *기존 anchor 그대로 사용 (position.set skip)*.
1009
1390
  const rs = this.state;
1010
1391
  const cols = this.columns;
1011
1392
  const rows = this.rackRows;
@@ -1014,25 +1395,42 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
1014
1395
  const height = rs?.depth ?? 2000;
1015
1396
  const depth = rs?.height ?? 200;
1016
1397
  const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, height * 0.9));
1017
- const shelfZone = height - shelfBase;
1018
- const bayW = width / cols;
1019
- const bayD = depth / rows;
1020
- const cellY = shelfZone / shelves;
1021
- const baseY = -height / 2;
1022
- const shelfBaseY = baseY + shelfBase;
1023
- const stockD = cellY * 0.7;
1024
- const cx = (parsed.col - cols / 2 + 0.5) * bayW;
1025
- const cellBottomY = shelfBaseY + parsed.shelf * cellY;
1026
- const cy = cellBottomY + stockD / 2;
1027
- const cz = (parsed.row - rows / 2 + 0.5) * bayD;
1028
- obj.position.set(cx, cy, cz);
1398
+ const sig = `${cols}-${rows}-${shelves}-${width}-${height}-${depth}-${shelfBase}`;
1399
+ if (this._attachAnchorSig !== sig) {
1400
+ // 차원 변경 모든 기존 anchor 폐기 후 sig 갱신.
1401
+ for (const oldObj of this._attachAnchorBySlot.values()) {
1402
+ ro.object3d.remove(oldObj);
1403
+ }
1404
+ this._attachAnchorBySlot.clear();
1405
+ this._attachAnchorSig = sig;
1406
+ }
1407
+ let obj = this._attachAnchorBySlot.get(slotId);
1408
+ if (!obj) {
1409
+ obj = new THREE.Object3D();
1410
+ obj.name = `rack-grid-anchor:${slotId}`;
1411
+ ro.object3d.add(obj);
1412
+ this._attachAnchorBySlot.set(slotId, obj);
1413
+ // 신규 anchor — position 계산 + set (1 회 만, 이후 호출은 cache hit).
1414
+ const shelfZone = height - shelfBase;
1415
+ const bayW = width / cols;
1416
+ const bayD = depth / rows;
1417
+ const cellY = shelfZone / shelves;
1418
+ const baseY = -height / 2;
1419
+ const shelfBaseY = baseY + shelfBase;
1420
+ const stockD = cellY * 0.7;
1421
+ const cx = (parsed.col - cols / 2 + 0.5) * bayW;
1422
+ const cellBottomY = shelfBaseY + parsed.shelf * cellY;
1423
+ const cy = cellBottomY + stockD / 2;
1424
+ const cz = (parsed.row - rows / 2 + 0.5) * bayD;
1425
+ obj.position.set(cx, cy, cz);
1426
+ }
1029
1427
  // *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
1030
1428
  ro.object3d.updateMatrixWorld(true);
1031
1429
  obj.updateMatrixWorld(true);
1032
1430
  return obj;
1033
1431
  }
1034
1432
  getSlotSize(slotId) {
1035
- const parsed = this.parseCellId(slotId);
1433
+ const parsed = this.parseSlotId(slotId);
1036
1434
  if (!parsed)
1037
1435
  return undefined;
1038
1436
  const rs = this.state;
@@ -1049,7 +1447,7 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
1049
1447
  };
1050
1448
  }
1051
1449
  cellCenter2D(slotId) {
1052
- const parsed = this.parseCellId(slotId);
1450
+ const parsed = this.parseSlotId(slotId);
1053
1451
  if (!parsed)
1054
1452
  return null;
1055
1453
  const rs = this.state;