@operato/scene-storage 10.0.0-beta.43 → 10.0.0-beta.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/box-3d.d.ts +2 -0
  3. package/dist/box-3d.js +103 -64
  4. package/dist/box-3d.js.map +1 -1
  5. package/dist/crane-3d.d.ts +10 -0
  6. package/dist/crane-3d.js +34 -5
  7. package/dist/crane-3d.js.map +1 -1
  8. package/dist/crane.d.ts +136 -6
  9. package/dist/crane.js +567 -46
  10. package/dist/crane.js.map +1 -1
  11. package/dist/pallet-3d.d.ts +2 -0
  12. package/dist/pallet-3d.js +103 -53
  13. package/dist/pallet-3d.js.map +1 -1
  14. package/dist/parcel-3d.d.ts +1 -0
  15. package/dist/parcel-3d.js +18 -1
  16. package/dist/parcel-3d.js.map +1 -1
  17. package/dist/rack-grid-3d.js +26 -8
  18. package/dist/rack-grid-3d.js.map +1 -1
  19. package/dist/rack-grid.d.ts +94 -10
  20. package/dist/rack-grid.js +468 -86
  21. package/dist/rack-grid.js.map +1 -1
  22. package/dist/storage-rack-3d.js +1 -1
  23. package/dist/storage-rack-3d.js.map +1 -1
  24. package/dist/storage-rack.d.ts +31 -6
  25. package/dist/storage-rack.js +96 -14
  26. package/dist/storage-rack.js.map +1 -1
  27. package/package.json +3 -3
  28. package/src/box-3d.ts +121 -68
  29. package/src/crane-3d.ts +34 -4
  30. package/src/crane.ts +615 -55
  31. package/src/pallet-3d.ts +122 -55
  32. package/src/parcel-3d.ts +19 -1
  33. package/src/rack-grid-3d.ts +31 -8
  34. package/src/rack-grid.ts +488 -82
  35. package/src/storage-rack-3d.ts +1 -1
  36. package/src/storage-rack.ts +96 -14
  37. package/test/test-coord-alignment.ts +2 -2
  38. package/test/test-crane-bay-match.ts +130 -0
  39. package/test/test-crane-binding-resolve.ts +168 -0
  40. package/test/test-crane-duration.ts +90 -0
  41. package/test/test-crane-rotation-reach.ts +218 -0
  42. package/test/test-rack-grid-3d-alignment.ts +235 -0
  43. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  44. package/test/test-rack-grid-cell.ts +2 -2
  45. package/test/test-rack-grid-location.ts +2 -2
  46. package/test/test-rack-grid-occupied-slots.ts +165 -0
  47. package/test/test-rack-grid-picking-position.ts +154 -0
  48. package/test/test-rack-grid-slot-api.ts +483 -0
  49. package/test/test-slot-ids-enumeration.ts +137 -0
  50. 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,7 @@ 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)) {
117
160
  static placement = 'floor';
118
161
  static align = 'bottom';
119
162
  static defaultDepth = (h) => h.ceiling - h.floor;
@@ -545,12 +588,118 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
545
588
  * truth, 없으면 cellOverrides[posKey].isEmpty fallback.
546
589
  */
547
590
  isBayEmpty(col, row = 0) {
591
+ // cell.state.isEmpty 가 *명시 true* 일 때만 우선. false / undefined 면 fallback
592
+ // 으로 cellOverrides 확인. _buildCells 가 모든 bay 에 cell 자동 생성 + default
593
+ // isEmpty=false 라서, "cell 객체 존재 만으로 cellOverrides 차단" 시 사용자가
594
+ // cellOverrides 만 설정한 모델에서 isEmpty 효과 0 회귀.
548
595
  const cell = this.cellAt(col, row);
549
- if (cell)
550
- return !!cell.state.isEmpty;
596
+ if (cell?.state?.isEmpty === true)
597
+ return true;
551
598
  const posKey = `${col}-${row}`;
552
599
  return !!this.cellOverrides[posKey]?.isEmpty;
553
600
  }
601
+ /**
602
+ * 모든 slot id 의 목록. columns × rackRows × shelves 조합. *isEmpty bay 는 제외*
603
+ * (물리적으로 없는 위치). SlottedHolder.slotIds — capability 기반 enumeration
604
+ * entry point.
605
+ */
606
+ slotIds() {
607
+ const ids = [];
608
+ const cols = this.columns;
609
+ const rows = this.rackRows;
610
+ const shelves = this.shelves;
611
+ for (let col = 0; col < cols; col++) {
612
+ for (let row = 0; row < rows; row++) {
613
+ if (this.isBayEmpty(col, row))
614
+ continue; // 물리적 없음 — 제외
615
+ for (let shelf = 0; shelf < shelves; shelf++) {
616
+ ids.push(`${col}-${row}-${shelf}`);
617
+ }
618
+ }
619
+ }
620
+ return ids;
621
+ }
622
+ /**
623
+ * 점유된 slot 의 id 목록 — state.data record + child *carrier* 둘 다.
624
+ *
625
+ * 중요: RackGridCell (시각 proxy) 의 *state.cellId 는 bayKey ("col-row")
626
+ * 형식* (`_syncChildCellIds` 가 set). slot 의 *full id ("col-row-shelf")* 와
627
+ * 다르다 — RackGridCell 은 *carrier 아닌 시각 proxy* 라 *occupied 카운트
628
+ * 대상 아님*. carriable child (= 실 carrier) 만 sweep.
629
+ *
630
+ * @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
631
+ */
632
+ occupiedSlotIds(filter) {
633
+ const set = new Set();
634
+ for (const r of this.records) {
635
+ const cid = r?.cellId;
636
+ if (typeof cid === 'string' && (!filter || filter(cid)))
637
+ set.add(cid);
638
+ }
639
+ for (const c of this.components ?? []) {
640
+ // *carriable 만* — RackGridCell (rack-grid-cell type) 의 bayKey 제외.
641
+ if (!c?.isCarriable)
642
+ continue;
643
+ const cid = c?.state?.cellId;
644
+ if (typeof cid === 'string' && (!filter || filter(cid)))
645
+ set.add(cid);
646
+ }
647
+ return Array.from(set);
648
+ }
649
+ /** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). isEmpty bay 자동 제외. */
650
+ emptySlotIds(filter) {
651
+ const occ = new Set(this.occupiedSlotIds());
652
+ const result = [];
653
+ for (const id of this.slotIds()) {
654
+ if (occ.has(id))
655
+ continue;
656
+ if (filter && !filter(id))
657
+ continue;
658
+ result.push(id);
659
+ }
660
+ return result;
661
+ }
662
+ /**
663
+ * slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
664
+ * reach 검사 같은 *match 전 단계* 에서 *anchor 생성 0* 으로 사용 가능.
665
+ * match 후 *실제 attach* 가 필요할 때 `getSlotAttachObject3d` 가 lazy 생성.
666
+ */
667
+ slotWorldPosition(slotId) {
668
+ const parsed = this.parseSlotId(slotId);
669
+ if (!parsed)
670
+ return undefined;
671
+ if (parsed.col < 0 || parsed.col >= this.columns)
672
+ return undefined;
673
+ if (parsed.row < 0 || parsed.row >= this.rackRows)
674
+ return undefined;
675
+ if (parsed.shelf < 0 || parsed.shelf >= this.shelves)
676
+ return undefined;
677
+ const ro = this._realObject;
678
+ if (!ro?.object3d)
679
+ return undefined;
680
+ const rs = this.state;
681
+ const width = rs?.width ?? 400;
682
+ const height = rs?.depth ?? 2000;
683
+ const depth = rs?.height ?? 200;
684
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, height * 0.9));
685
+ const cols = this.columns;
686
+ const rows = this.rackRows;
687
+ const shelves = this.shelves;
688
+ const shelfZone = height - shelfBase;
689
+ const bayW = width / cols;
690
+ const bayD = depth / rows;
691
+ const cellY = shelfZone / shelves;
692
+ const baseY = -height / 2;
693
+ const shelfBaseY = baseY + shelfBase;
694
+ const stockD = cellY * 0.7;
695
+ const cx = (parsed.col - cols / 2 + 0.5) * bayW;
696
+ const cellBottomY = shelfBaseY + parsed.shelf * cellY;
697
+ const cy = cellBottomY + stockD / 2;
698
+ const cz = (parsed.row - rows / 2 + 0.5) * bayD;
699
+ const v = new THREE.Vector3(cx, cy, cz);
700
+ ro.object3d.localToWorld(v);
701
+ return { x: v.x, y: v.y, z: v.z };
702
+ }
554
703
  buildRealObject() {
555
704
  return new RackGrid3D(this);
556
705
  }
@@ -721,13 +870,16 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
721
870
  }
722
871
  // ── Grid layout ─────────────────────────────────────────
723
872
  get columns() {
724
- return Math.max(1, Math.floor(this.state.columns ?? 5));
873
+ const v = this.state.columns;
874
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 5));
725
875
  }
726
876
  get rackRows() {
727
- return Math.max(1, Math.floor(this.state.rows ?? 1));
877
+ const v = this.state.rows;
878
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 1));
728
879
  }
729
880
  get shelves() {
730
- return Math.max(1, Math.floor(this.state.shelves ?? 4));
881
+ const v = this.state.shelves;
882
+ return Math.max(1, Math.floor(typeof v === 'number' && Number.isFinite(v) ? v : 4));
731
883
  }
732
884
  // ── cellId / posKey 변환 ────────────────────────────────
733
885
  /** posKey = `${col-1}-${row-1}` (0-based, 2 segments). 1-based input. */
@@ -735,10 +887,10 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
735
887
  return `${col - 1}-${row - 1}`;
736
888
  }
737
889
  /** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
738
- cellIdOf(col, row = 1, shelf = 1) {
890
+ slotIdOf(col, row = 1, shelf = 1) {
739
891
  return `${col - 1}-${row - 1}-${shelf - 1}`;
740
892
  }
741
- parseCellId(cellId) {
893
+ parseSlotId(cellId) {
742
894
  const parts = cellId.split('-');
743
895
  if (parts.length !== 3)
744
896
  return null;
@@ -789,7 +941,7 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
789
941
  * section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
790
942
  */
791
943
  locationOf(cellId) {
792
- const parsed = this.parseCellId(cellId);
944
+ const parsed = this.parseSlotId(cellId);
793
945
  if (!parsed)
794
946
  return null;
795
947
  const posKey = `${parsed.col}-${parsed.row}`;
@@ -913,99 +1065,312 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
913
1065
  * 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
914
1066
  * 자식 추가 시 자동 할당 또는 사용자가 명시.
915
1067
  */
916
- _childRackAt(posKey) {
917
- const parsed = this.parsePosKey(posKey);
918
- if (!parsed)
919
- return null;
1068
+ /**
1069
+ * cellId 매칭되는 RackGrid 의 직접 자식 carrier (operation archetype).
1070
+ * RackGrid 의 자식 중 rack-grid-cell (시각 proxy) 외에 obtainCarrier 가 add 한
1071
+ * transient carrier 들이 섞임 — 그 중 cellId / placement='operation' 매칭.
1072
+ * storage-rack 의 동일 패턴.
1073
+ */
1074
+ _carrierChildAt(cellId) {
920
1075
  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;
1076
+ return children.find(c => {
1077
+ const placement = c.constructor.placement;
1078
+ return placement === 'operation' && c.state?.cellId === cellId;
1079
+ });
932
1080
  }
933
- // ── SlottedHolder 컨트랙 — 자식 StorageRack 으로 위임 ────
934
- hasCarrierAt(slotId) {
935
- const parsed = this.parseCellId(slotId);
936
- if (!parsed)
1081
+ /**
1082
+ * state.data 의 internal 갱신 — obtainCarrier / receiveAt 가 사용.
1083
+ * setState 달리 'change' / onchangeData / mapping cascade 우회 — 사용자 script
1084
+ * 의 자기-자신 호출 회귀 차단. 시각 갱신은 직접 RealObject.rebuildStockMesh 호출.
1085
+ * storage-rack 의 동일 패턴.
1086
+ */
1087
+ _setDataSilently(newData) {
1088
+ const self = this;
1089
+ if (!self._state)
1090
+ self._state = {};
1091
+ self._state.data = newData;
1092
+ self._cachedState = null;
1093
+ this._realObject?.rebuildStockMesh?.();
1094
+ }
1095
+ // ── SlottedHolder 컨트랙 — RackGrid 자체의 records 에서 직접 처리 ────
1096
+ //
1097
+ // Plan A 정신: stock 데이터는 RackGrid 의 state.data 에 sparse 로 통합 저장.
1098
+ // 자식 컴포넌트는 시각용 rack-grid-cell (light proxy) 뿐 carrier 보유 X.
1099
+ // storage-rack 의 본체 로직 (transient materialize / silent setData) 을 차용,
1100
+ // 좌표 / 사이즈만 RackGrid 의 cellAt(col,row).bounds + getSlotSize 로 적응.
1101
+ /** state.data record 또는 이미 transient materialize 된 carrier child 가 있는가. */
1102
+ hasCarrierAt(slotIdOrLocation) {
1103
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1104
+ if (!cellId)
937
1105
  return false;
938
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
939
- return child?.hasCarrierAt(`0-0-${parsed.shelf}`) ?? false;
1106
+ if (this._carrierChildAt(cellId))
1107
+ return true;
1108
+ return this.records.some(r => r.cellId === cellId);
940
1109
  }
1110
+ /**
1111
+ * carrier 를 obtain — 이미 child 면 그대로, 아니면 state.data record 로 transient
1112
+ * materialize 후 RackGrid 의 직접 자식으로 add 하고 state.data 에서 그 record 제거.
1113
+ * record 도 child 도 없으면 null.
1114
+ */
941
1115
  obtainCarrier(slotIdOrLocation) {
942
- const slotId = this._resolveToCellId(slotIdOrLocation);
943
- if (!slotId)
1116
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1117
+ if (!cellId)
1118
+ return null;
1119
+ const existing = this._carrierChildAt(cellId);
1120
+ if (existing)
1121
+ return existing;
1122
+ const records = this.records;
1123
+ const idx = records.findIndex(r => r?.cellId === cellId);
1124
+ if (idx === -1)
1125
+ return null;
1126
+ const record = records[idx];
1127
+ const carrierType = record.type || 'parcel';
1128
+ const CarrierClass = Component.register(carrierType);
1129
+ if (!CarrierClass) {
1130
+ console.warn(`[rack-grid] obtainCarrier("${cellId}"): carrier type "${carrierType}" 미등록`);
944
1131
  return null;
945
- const parsed = this.parseCellId(slotId);
1132
+ }
1133
+ // RackGrid-inner 좌표 — anchor (getSlotAttachObject3d) / stock InstancedMesh 와
1134
+ // *동일 식* 사용: 균등 분할 + center origin → top-left origin 변환.
1135
+ // 이전엔 widths/heights *비례 분할* 을 썼는데 anchor/stock 은 *균등 분할* 이라
1136
+ // 어긋났음. update loop 이 carrier.state.left/top 기반으로 위치 재계산해도
1137
+ // anchor 와 일치하도록 *처음부터 같은 식* 으로 박는다.
1138
+ const parsed = this.parseSlotId(cellId);
946
1139
  if (!parsed)
947
1140
  return null;
948
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
949
- return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null;
1141
+ const rackWidth = this.state.width ?? 400;
1142
+ const rackDepthY = this.state.height ?? 200; // 2D height = 3D Z = inner Y
1143
+ const stateDepth = this.state.depth ?? 2000; // 3D Y (vertical)
1144
+ const bayW = rackWidth / this.columns;
1145
+ const bayD = rackDepthY / this.rackRows;
1146
+ // anchor X(center) = (col - cols/2 + 0.5) * bayW → 2D top-left center = anchor.X + rackWidth/2
1147
+ const cellCenterInnerX = (parsed.col + 0.5) * bayW;
1148
+ const cellCenterInnerY = (parsed.row + 0.5) * bayD;
1149
+ // shelf level Y — anchor (getSlotAttachObject3d) 와 동일 식.
1150
+ const shelfBase = Math.max(0, Math.min(this.state.shelfBaseHeight || 0, stateDepth * 0.9));
1151
+ const shelfZone = stateDepth - shelfBase;
1152
+ const cellYHeight = shelfZone / this.shelves;
1153
+ const shelfBaseY = -stateDepth / 2 + shelfBase;
1154
+ const cellBottomY = shelfBaseY + parsed.shelf * cellYHeight;
1155
+ const slotSize = this.getSlotSize(cellId) ?? { width: 50, height: 50, depth: 50 };
1156
+ // record 의 사이즈가 *0* 또는 *non-finite (NaN/Infinity)* 이면 slotSize fallback —
1157
+ // nullish 만으로는 NaN/0 통과해 BoxGeometry NaN bug 유발.
1158
+ const pickSize = (v, fb) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fb;
1159
+ const carrierW = pickSize(record.width, slotSize.width);
1160
+ const carrierH = pickSize(record.height, slotSize.height);
1161
+ const carrierD = pickSize(record.depth, slotSize.depth);
1162
+ // 차원 / 좌표 finite 검증 — 어느 단계에서 NaN 이 들어왔는지 명시 진단.
1163
+ if (!Number.isFinite(carrierW) || !Number.isFinite(carrierH) || !Number.isFinite(carrierD) ||
1164
+ !Number.isFinite(cellCenterInnerX) || !Number.isFinite(cellCenterInnerY)) {
1165
+ console.error('[rack-grid] obtainCarrier: non-finite dim/pos — carrier 생성 차단', {
1166
+ cellId,
1167
+ carrierW, carrierH, carrierD,
1168
+ cellCenterInnerX, cellCenterInnerY,
1169
+ slotSize,
1170
+ recordSize: { w: record.width, h: record.height, d: record.depth },
1171
+ gridDims: {
1172
+ columns: this.columns, rackRows: this.rackRows, shelves: this.shelves,
1173
+ stateW: this.state?.width, stateH: this.state?.height,
1174
+ stateD: this.state?.depth
1175
+ }
1176
+ });
1177
+ return null;
1178
+ }
1179
+ // record 에서 id / refid / transform 류는 *제외* — id 가 들어가면 scene 안 기존
1180
+ // component 와 충돌해 parent 가 잘못 잡힘.
1181
+ const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record;
1182
+ const carrierState = {
1183
+ ...recordCopy,
1184
+ type: carrierType,
1185
+ cellId, // 슬롯 주소
1186
+ refid: _nextCarrierRefid(), // refid 충돌 회피
1187
+ width: carrierW,
1188
+ height: carrierH,
1189
+ depth: carrierD,
1190
+ left: cellCenterInnerX - carrierW / 2,
1191
+ top: cellCenterInnerY - carrierH / 2,
1192
+ // zPos = carrier 의 *parent-local 3D Y bottom*. things-scene 의 RealObject 가
1193
+ // 3D Y center = zPos + depth/2 로 계산. 미명시 시 NaN — carrier.matrixWorld NaN
1194
+ // → fork 가 NaN target 따라가서 빈 포크질. anchor 식 (cellBottomY + stockD/2)
1195
+ // 와 일치하도록 zPos = cellBottomY 명시.
1196
+ zPos: cellBottomY
1197
+ };
1198
+ const carrier = new CarrierClass(carrierState, this._app);
1199
+ this.addComponent(carrier, { silent: true });
1200
+ // 3D 강제 빌드 + holder attach + manual placement/anchor.
1201
+ void carrier.realObject;
1202
+ carrier.applyHolderAttachPoint?.();
1203
+ carrier.realObject?.setTransientPlacement?.({ policy: 'carried' });
1204
+ const anchor = this.getSlotAttachObject3d(cellId);
1205
+ const carrierObj3d = carrier.realObject?.object3d;
1206
+ if (anchor && carrierObj3d) {
1207
+ anchor.add(carrierObj3d);
1208
+ carrierObj3d.position.set(0, 0, 0);
1209
+ carrierObj3d.updateMatrixWorld(true);
1210
+ }
1211
+ // record 제거 — silent (mapping cascade 회피). 동일 cellId 의 *모든* record 정리.
1212
+ this._setDataSilently(records.filter(r => r?.cellId !== cellId));
1213
+ return carrier;
950
1214
  }
1215
+ /**
1216
+ * cell 이 carrier 를 받을 수 있는가.
1217
+ * - isEmpty 위치 는 거부 (modeling 차원 location-less)
1218
+ * - state.data 에 record 있으면 점유 → false
1219
+ * - 자기 자신 carrier 가 child 면 idempotent true (자기 자리 복귀)
1220
+ */
951
1221
  canReceiveAt(slotIdOrLocation, carrier) {
952
- const slotId = this._resolveToCellId(slotIdOrLocation);
953
- if (!slotId)
1222
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1223
+ if (!cellId)
954
1224
  return false;
955
- const parsed = this.parseCellId(slotId);
1225
+ const parsed = this.parseSlotId(cellId);
956
1226
  if (!parsed)
957
1227
  return false;
958
- // isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
959
1228
  const override = this.cellOverrides[`${parsed.col}-${parsed.row}`];
960
1229
  if (override?.isEmpty)
961
1230
  return false;
962
- const child = this._childRackAt(`${parsed.col}-${parsed.row}`);
963
- return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false;
1231
+ const records = this.records;
1232
+ if (records.some(r => r?.cellId === cellId))
1233
+ return false;
1234
+ const existingChild = this._carrierChildAt(cellId);
1235
+ if (existingChild && existingChild !== carrier)
1236
+ return false;
1237
+ return true;
964
1238
  }
965
- async receiveAt(slotIdOrLocation, carrier, options) {
966
- const slotId = this._resolveToCellId(slotIdOrLocation);
967
- if (!slotId)
1239
+ /**
1240
+ * Carrier RackGrid 의 slot 으로 들어옴 — 즉시 dispose + state.data 에 record 로
1241
+ * 환원. 결과: stock InstancedMesh 가 그 자리에 instance 표시, RackGrid 의 자식 트리는
1242
+ * 깨끗 (rack-grid-cell 만 남음).
1243
+ */
1244
+ async receiveAt(slotIdOrLocation, carrier, _options) {
1245
+ const cellId = this._resolveToCellId(slotIdOrLocation);
1246
+ if (!cellId)
968
1247
  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}`);
1248
+ // disposed carrier 의 재처리 차단
1249
+ if (carrier?._disposed) {
1250
+ throw new Error(`RackGrid.receiveAt("${cellId}"): carrier is already disposed. ` +
1251
+ 'After a successful pickAndPlace the carrier becomes a state.data record — ' +
1252
+ 'use rack.obtainCarrier(cellId) to get a fresh transient carrier instead.');
1253
+ }
1254
+ if (!this.canReceiveAt(cellId, carrier)) {
1255
+ ;
1256
+ this.trigger?.('transfer-rejected', {
1257
+ type: 'transfer-rejected',
1258
+ component: carrier, container: this, reason: 'slot-occupied', cellId
1259
+ });
1260
+ return;
1261
+ }
1262
+ const record = this.recordFromCarrier(carrier, cellId);
1263
+ // Carrier 의 parent 에서 떼고 dispose. parent 는 보통 mover (crane) 이거나 RackGrid 자신.
1264
+ const carrierParent = carrier.parent;
1265
+ if (carrierParent && typeof carrierParent.removeComponent === 'function') {
1266
+ carrierParent.removeComponent(carrier);
975
1267
  }
976
- return child.receiveAt(`0-0-${parsed.shelf}`, carrier, options);
1268
+ // 명시적 Three.js detach — RealObject.dispose() clear() 는 object3d 자체를
1269
+ // scene graph 의 parent 에서 떼지 않으므로 ghost 방지 위해 명시 제거.
1270
+ const carrierObj3d = carrier._realObject?.object3d;
1271
+ if (carrierObj3d?.parent && typeof carrierObj3d.parent.remove === 'function') {
1272
+ carrierObj3d.parent.remove(carrierObj3d);
1273
+ }
1274
+ ;
1275
+ carrier.dispose?.();
1276
+ // state.data 에 push — silent (mapping cascade 회피). 동일 cellId 중복 방어.
1277
+ const currentRecords = this.records.filter(r => r?.cellId !== cellId);
1278
+ this._setDataSilently([...currentRecords, record]);
1279
+ this.trigger?.('transfer-received', {
1280
+ type: 'transfer-received',
1281
+ component: carrier, container: this, slotId: cellId, record
1282
+ });
977
1283
  }
978
- recordFromCarrier(carrier, slotId) {
979
- const { id, refid, transform, ...rest } = (carrier.state || {});
980
- return { ...rest, cellId: slotId };
1284
+ /**
1285
+ * Carrier state state.data record 추출. transform/position 관련은 record
1286
+ * 무관해 skip. (storage-rack 의 동일 패턴.)
1287
+ */
1288
+ recordFromCarrier(carrier, cellId) {
1289
+ const state = carrier.state ?? {};
1290
+ const SKIP_KEYS = new Set([
1291
+ 'left', 'top', 'zPos',
1292
+ 'transform', 'rotation', 'scale',
1293
+ '_transferSlotId',
1294
+ 'cellId',
1295
+ 'id',
1296
+ 'refid'
1297
+ ]);
1298
+ const record = { cellId, type: state.type };
1299
+ for (const key of Object.keys(state)) {
1300
+ if (SKIP_KEYS.has(key))
1301
+ continue;
1302
+ record[key] = state[key];
1303
+ }
1304
+ return record;
1305
+ }
1306
+ // ── CarrierHolder — attach frame for direct carrier children ─────────────
1307
+ /**
1308
+ * children gate — CarrierHolder.containable 의 *carriable-only* 제한을 완화.
1309
+ * RackGrid 의 자식은 두 종류:
1310
+ * - RackGridCell : modeling-time visual proxy (carriable 아님)
1311
+ * - carrier : Plan A 의 obtainCarrier 가 materialize (carriable)
1312
+ * 둘 다 허용해야 정상 동작.
1313
+ */
1314
+ containable(component) {
1315
+ if (!component)
1316
+ return false;
1317
+ if (component?.state?.type === 'rack-grid-cell')
1318
+ return true;
1319
+ return component.isCarriable === true;
1320
+ }
1321
+ /**
1322
+ * Attach frame for direct-child carrier — `applyHolderAttachPoint` 가 이 결과의
1323
+ * `attach` 에 carrier obj3d 를 attach 하고 `localPosition` 을 set. cell anchor
1324
+ * 반환 + carried placement 와 함께 동작 — carrier 가 cell 위치에 고정.
1325
+ */
1326
+ attachPointFor(carrier) {
1327
+ const cellId = carrier?.state?.cellId;
1328
+ if (cellId) {
1329
+ const obj = this.getSlotAttachObject3d(cellId);
1330
+ if (obj)
1331
+ return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } };
1332
+ }
1333
+ const root = this._realObject?.object3d;
1334
+ if (!root)
1335
+ return null;
1336
+ return { attach: root, localPosition: { x: 0, y: 0, z: 0 } };
981
1337
  }
982
1338
  /**
983
1339
  * Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
984
- * 위치한 invisible Object3D. popup tether / Carriable.applyHolderAttachPoint
985
- * object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
1340
+ * 위치한 invisible Object3D. popup tether 등이 object3d 의 matrixWorld 를
1341
+ * 사용. lazy 생성 + cache.
986
1342
  */
987
- _attachAnchorByCell = new Map();
1343
+ _attachAnchorBySlot = new Map();
1344
+ /**
1345
+ * Anchor position cache 의 signature — rack 의 차원 state 변경 시 무효화 trigger.
1346
+ * 동일 signature 인 동안 *anchor.position.set 재계산 skip*. crane 의 첫
1347
+ * findAdjacentSlots 가 *수많은 anchor 의 position.set 매 호출* — *동일 차원
1348
+ * state 일 때* 의 *중복 계산 차단*.
1349
+ */
1350
+ _attachAnchorSig;
988
1351
  getSlotAttachObject3d(slotId) {
989
- const parsed = this.parseCellId(slotId);
990
- if (!parsed)
1352
+ const parsed = this.parseSlotId(slotId);
1353
+ if (!parsed) {
1354
+ console.warn(`[rack-grid] getSlotAttachObject3d: invalid cellId format "${slotId}"`);
991
1355
  return undefined;
992
- if (parsed.col < 0 || parsed.col >= this.columns)
1356
+ }
1357
+ if (parsed.col < 0 || parsed.col >= this.columns) {
1358
+ console.warn(`[rack-grid] getSlotAttachObject3d: col=${parsed.col} out of range [0, ${this.columns})`);
993
1359
  return undefined;
994
- if (parsed.row < 0 || parsed.row >= this.rackRows)
1360
+ }
1361
+ if (parsed.row < 0 || parsed.row >= this.rackRows) {
1362
+ console.warn(`[rack-grid] getSlotAttachObject3d: row=${parsed.row} out of range [0, ${this.rackRows})`);
995
1363
  return undefined;
996
- if (parsed.shelf < 0 || parsed.shelf >= this.shelves)
1364
+ }
1365
+ if (parsed.shelf < 0 || parsed.shelf >= this.shelves) {
1366
+ console.warn(`[rack-grid] getSlotAttachObject3d: shelf=${parsed.shelf} out of range [0, ${this.shelves}) — cellId "${slotId}" — fork 가 fallback 위치 (한 층 아래 등) 로 갈 수 있음`);
997
1367
  return undefined;
1368
+ }
998
1369
  const ro = this._realObject;
999
1370
  if (!ro?.object3d)
1000
1371
  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 위치 와 동일 공식
1372
+ // signature 검사 — 차원 state 변경 시 *모든 anchor invalidate* 후 새로 생성.
1373
+ // 동일 sig 일 때 *기존 anchor 그대로 사용 (position.set skip)*.
1009
1374
  const rs = this.state;
1010
1375
  const cols = this.columns;
1011
1376
  const rows = this.rackRows;
@@ -1014,25 +1379,42 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
1014
1379
  const height = rs?.depth ?? 2000;
1015
1380
  const depth = rs?.height ?? 200;
1016
1381
  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);
1382
+ const sig = `${cols}-${rows}-${shelves}-${width}-${height}-${depth}-${shelfBase}`;
1383
+ if (this._attachAnchorSig !== sig) {
1384
+ // 차원 변경 모든 기존 anchor 폐기 후 sig 갱신.
1385
+ for (const oldObj of this._attachAnchorBySlot.values()) {
1386
+ ro.object3d.remove(oldObj);
1387
+ }
1388
+ this._attachAnchorBySlot.clear();
1389
+ this._attachAnchorSig = sig;
1390
+ }
1391
+ let obj = this._attachAnchorBySlot.get(slotId);
1392
+ if (!obj) {
1393
+ obj = new THREE.Object3D();
1394
+ obj.name = `rack-grid-anchor:${slotId}`;
1395
+ ro.object3d.add(obj);
1396
+ this._attachAnchorBySlot.set(slotId, obj);
1397
+ // 신규 anchor — position 계산 + set (1 회 만, 이후 호출은 cache hit).
1398
+ const shelfZone = height - shelfBase;
1399
+ const bayW = width / cols;
1400
+ const bayD = depth / rows;
1401
+ const cellY = shelfZone / shelves;
1402
+ const baseY = -height / 2;
1403
+ const shelfBaseY = baseY + shelfBase;
1404
+ const stockD = cellY * 0.7;
1405
+ const cx = (parsed.col - cols / 2 + 0.5) * bayW;
1406
+ const cellBottomY = shelfBaseY + parsed.shelf * cellY;
1407
+ const cy = cellBottomY + stockD / 2;
1408
+ const cz = (parsed.row - rows / 2 + 0.5) * bayD;
1409
+ obj.position.set(cx, cy, cz);
1410
+ }
1029
1411
  // *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
1030
1412
  ro.object3d.updateMatrixWorld(true);
1031
1413
  obj.updateMatrixWorld(true);
1032
1414
  return obj;
1033
1415
  }
1034
1416
  getSlotSize(slotId) {
1035
- const parsed = this.parseCellId(slotId);
1417
+ const parsed = this.parseSlotId(slotId);
1036
1418
  if (!parsed)
1037
1419
  return undefined;
1038
1420
  const rs = this.state;
@@ -1049,7 +1431,7 @@ let RackGrid = class RackGrid extends Placeable(ContainerAbstract) {
1049
1431
  };
1050
1432
  }
1051
1433
  cellCenter2D(slotId) {
1052
- const parsed = this.parseCellId(slotId);
1434
+ const parsed = this.parseSlotId(slotId);
1053
1435
  if (!parsed)
1054
1436
  return null;
1055
1437
  const rs = this.state;