@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/crane.js CHANGED
@@ -3,7 +3,8 @@ import { __decorate } from "tslib";
3
3
  * Copyright © HatioLab Inc. All rights reserved.
4
4
  */
5
5
  import { ContainerAbstract, ContainerCapacity, Transfer, getWorldPose, sceneComponent } from '@hatiolab/things-scene';
6
- import { CarrierHolder, Legendable, Mover, Placeable } from '@operato/scene-base';
6
+ import { CarrierHolder, Legendable, Mover, Placeable, isSlottedHolder, findDispatcher, resolveTransferTarget } from '@operato/scene-base';
7
+ import * as THREE from 'three';
7
8
  import { Crane3D } from './crane-3d.js';
8
9
  const BODY_LEGEND = {
9
10
  idle: '#888',
@@ -45,6 +46,12 @@ const NATURE = {
45
46
  label: 'simulate',
46
47
  name: 'simulate'
47
48
  },
49
+ {
50
+ type: 'string',
51
+ label: 'bound-holders',
52
+ name: 'boundHolders',
53
+ placeholder: 'SlottedHolder id (rack-1, rack-2, ...). 미명시 시 scene 전체 fallback.'
54
+ },
48
55
  {
49
56
  type: 'number',
50
57
  label: 'carriage-position',
@@ -69,6 +76,12 @@ const NATURE = {
69
76
  name: 'forkLength',
70
77
  placeholder: 'mm — fork prong 최대 신축 길이 (default 600)'
71
78
  },
79
+ {
80
+ type: 'number',
81
+ label: 'speed',
82
+ name: 'speed',
83
+ placeholder: 'scene units/sec — carriage 운동 속도 (default 250)'
84
+ },
72
85
  {
73
86
  type: 'number',
74
87
  label: 'fork-extension',
@@ -155,6 +168,12 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
155
168
  * 미설정 = state-driven) 에 carrier 가 *지하* 로 가는 결함.
156
169
  */
157
170
  get slots() {
171
+ // Phase Z PR-4: 사용자가 state.forkSlots 명시 시 그대로 활용. 미명시 시 단일
172
+ // 'forks' default (backward compat). multi-fork crane (twin / quad) 모델링
173
+ // 가능 — [{ id: 'fork-L', ... }, { id: 'fork-R', ... }] 형태로.
174
+ const custom = this.state?.forkSlots;
175
+ if (Array.isArray(custom) && custom.length > 0)
176
+ return custom;
158
177
  return [{ id: 'forks', maxCount: 1, localPosition: { x: 0, y: 0, z: 0 } }];
159
178
  }
160
179
  /**
@@ -272,12 +291,36 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
272
291
  : craneCenterLocal;
273
292
  const ccx = craneCenterAbs.x;
274
293
  const ccy = craneCenterAbs.y;
275
- const rotation = this.state.rotation ?? 0;
276
- const cos = Math.cos(rotation);
277
- const sin = Math.sin(rotation);
278
294
  const dx = tcx - ccx;
279
295
  const dy = tcy - ccy;
280
- const railLocalX = dx * cos + dy * sin;
296
+ // Rail axis 결정 *2D state.rotation 무관*. 실제 3D scene 안의 crane mesh 가
297
+ // *어떤 방향* 에 놓였든 *그 visual rail 방향* 따라 carriage 운동. crane.object3d
298
+ // 의 matrixWorld 에서 local X axis (= rail mesh 의 long axis = BoxGeometry(width,..)
299
+ // 의 X 방향) 의 world 방향 벡터 추출. 2D rotation→3D 매핑 컨벤션 (.rotation.y vs
300
+ // rotation.z, 부호) 영향 0 — 실 visual 방향 그대로 사용.
301
+ const ro = this._realObject;
302
+ const obj3d = ro?.object3d;
303
+ let railLocalX;
304
+ if (obj3d) {
305
+ obj3d.updateMatrixWorld(true);
306
+ const m = obj3d.matrixWorld.elements;
307
+ // 3D world frame: local X axis = (m[0], m[1], m[2]). 2D Y == 3D Z 매핑.
308
+ // 사용자 (dx, dy) = 2D scene 좌표 차이 = 3D (X, Z) 차이.
309
+ const axisX = m[0];
310
+ const axisZ = m[2];
311
+ const len = Math.hypot(axisX, axisZ);
312
+ if (len > 1e-9) {
313
+ const ux = axisX / len;
314
+ const uz = axisZ / len;
315
+ railLocalX = dx * ux + dy * uz;
316
+ }
317
+ else {
318
+ railLocalX = dx;
319
+ }
320
+ }
321
+ else {
322
+ railLocalX = dx;
323
+ }
281
324
  const cw = this.state.carriageWidth ?? W * 0.1;
282
325
  const minPos = cw / 2;
283
326
  const maxPos = W - cw / 2;
@@ -290,7 +333,9 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
290
333
  const tween = { carriagePosition: newCarriagePos };
291
334
  const targetBottomWorldY = resolveCarrierBottomY(target);
292
335
  if (targetBottomWorldY !== null) {
293
- const isHoldingCarrier = this.components?.some?.((c) => c?._transferSlotId === 'forks') ?? false;
336
+ // Phase Z PR-4: 임의 slot 의 carrier 검사 (multi-fork 대비). 단일 'forks'
337
+ // 가정 제거 — c._transferSlotId 가 set 됐으면 어떤 slot 이든 holding.
338
+ const isHoldingCarrier = this.components?.some?.((c) => c?._transferSlotId != null) ?? false;
294
339
  const liftH = numOr(this.state.forkLift, 30);
295
340
  const approachWorldY = targetBottomWorldY + (isHoldingCarrier ? liftH : 0);
296
341
  const ro = this._realObject;
@@ -302,8 +347,35 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
302
347
  else {
303
348
  tween.carriageHeight = approachWorldY;
304
349
  }
350
+ // Mast 높이 한계 (= crane.state.depth) 초과 시 carriageHeight 가 *clamp* 되어
351
+ // fork 가 *상한까지만* 도달 → 더 높은 cell 은 *한 층 아래에서 포킹*. 데이터
352
+ // 결함 (rack 보다 짧은 crane) 을 silent 무시 안 하도록 명시 경고.
353
+ const mastMax = numOr(this.state.depth, Math.max(numOr(this.state.width, 200), numOr(this.state.height, 200)) * 4);
354
+ if (typeof tween.carriageHeight === 'number' && tween.carriageHeight > mastMax) {
355
+ console.warn(`[crane] carriageHeight=${tween.carriageHeight.toFixed(1)} > mast max=${mastMax.toFixed(1)} (state.depth)` +
356
+ ` — fork 가 mast 상한에서 멈춰 target 한 층 아래에서 포킹 가능. crane.state.depth 증가 필요.`);
357
+ }
358
+ }
359
+ // Duration = *carriage 실 운동 거리 / speed*. 한 cycle 이 이동 거리 비례 시간.
360
+ // - X 운동 거리 = |newCarriagePos - 현재 carriagePosition|
361
+ // - Y 운동 거리 = |tween.carriageHeight - 현재 carriageHeight| (carrier 있는 경우)
362
+ // - 두 운동은 *동시 lerp* → max 거리 만큼 시간 소요.
363
+ // options.duration 명시 시 그 값 우선. 미명시 시 state.speed 또는 default.
364
+ let duration;
365
+ if (typeof options.duration === 'number') {
366
+ duration = options.duration;
367
+ }
368
+ else {
369
+ const curCP = numOr(this.state.carriagePosition, this._canonicalDefault('carriagePosition'));
370
+ const curCH = numOr(this.state.carriageHeight, this._canonicalDefault('carriageHeight'));
371
+ const dxRail = Math.abs(tween.carriagePosition - curCP);
372
+ const dyMast = typeof tween.carriageHeight === 'number' ? Math.abs(tween.carriageHeight - curCH) : 0;
373
+ const dist = Math.max(dxRail, dyMast);
374
+ const speed = (typeof options.speed === 'number' ? options.speed : undefined) ??
375
+ (typeof this.state.speed === 'number' ? this.state.speed : undefined) ??
376
+ 250; // DEFAULT_SPEED — scene units/sec (이전 500 → 사용자 체감 빠름 → 절반)
377
+ duration = speed > 0 ? Math.max(200, (dist / speed) * 1000) : 1500;
305
378
  }
306
- const duration = options.duration ?? 1500;
307
379
  this.setState({ status: 'moving' });
308
380
  await this._tween(tween, duration);
309
381
  this.setState({ status: 'idle' });
@@ -386,8 +458,9 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
386
458
  // 2. carrier lower — forkLiftRT liftH → 0. carrier 가 cell 바닥 안착.
387
459
  await this._tween({ forkLiftRT: 0 }, D_LIFT);
388
460
  // 3. dispatch — carrier world 위치 = cell 내 attach 위치, jump 없음. Transfer 추적.
461
+ // Phase Z PR-4: 임의 slot 의 carrier 검사 (multi-fork 대비).
389
462
  const comps = this.components;
390
- const carrier = comps?.find?.((c) => c?._transferSlotId === 'forks')
463
+ const carrier = comps?.find?.((c) => c?._transferSlotId != null)
391
464
  ?? comps?.[0];
392
465
  if (carrier) {
393
466
  try {
@@ -415,6 +488,321 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
415
488
  deposit(carrier, cell, options) {
416
489
  return this.place(carrier, cell, options);
417
490
  }
491
+ // ── Capability-based adjacency discovery ──────────────────────────────────
492
+ /**
493
+ * scene 의 모든 SlottedHolder 의 *slot 중에서 crane 의 작업 reach 안에 들어
494
+ * 오는 slot 들* 을 반환. 자동 simulate / WCS-less 시나리오의 enumeration entry.
495
+ *
496
+ * 매칭 알고리즘 (공간 reach 박스):
497
+ * - crane.world center (axis-aligned 가정 — rotation 무시)
498
+ * - 각 slot.anchor.world position 이
499
+ * |x - cx| ≤ width/2 + reachXZ AND |z - cz| ≤ height/2 + reachXZ
500
+ * AND |y - cy| ≤ depth/2 + reachY
501
+ * 범위 안인가
502
+ * - reachXZ = `crane.state.height` (fork forward 폭). reachY = `crane.state.depth`/2.
503
+ *
504
+ * RackGrid 의 *isEmpty aisle 통로 자리에 crane 이 배치* 된 케이스도 자동 cover —
505
+ * 통로 양옆 bay 의 slot anchor world 가 reach 안에 자연히 들어옴.
506
+ *
507
+ * @param opts.includeSelf 자기 자신을 holder 로 포함할지 (default false — crane
508
+ * 은 SlottedHolder 아니지만 안전 차원).
509
+ */
510
+ /**
511
+ * Cycle 단위 cache 없음 — **매 cycle 협의** (capability negotiation 의 본질).
512
+ * crane.carriagePosition 변경 / rack 추가-이동 / 동적 forkLength 등 *runtime
513
+ * 변화 대응*. cost 는 *slotWorldPosition (anchor 0 생성) + canReach (cheap
514
+ * 좌표 비교)* — 매 cycle 호출 부담 작음.
515
+ *
516
+ * 이 field 는 *_oneCycle 사용을 위한 *호출 당 임시 holder grouping** —
517
+ * findAdjacentSlots 결과의 byHolder mapping. caller (_oneCycle) 가 사용 후
518
+ * 폐기.
519
+ */
520
+ _adjacentByHolder;
521
+ /** Cache 없음 — invalidate 호출도 no-op (호환 차원 keep). */
522
+ invalidateAdjacentSlots() {
523
+ this._adjacentByHolder = undefined;
524
+ }
525
+ /**
526
+ * 자동 detect 결과 cache — *boundHolders 미명시 시* 사용. crane / rack 위치가
527
+ * 정적이라 *1회 detect 후 영구 cache 안전*. 위치/회전/차원 변경 시 invalidate.
528
+ */
529
+ _autoBoundCache;
530
+ /** Auto bound cache invalidate — crane state 변경 시 호출. */
531
+ invalidateBoundCache() {
532
+ this._autoBoundCache = undefined;
533
+ }
534
+ /**
535
+ * Scene traverse + reach 검사 — 어느 slot 1개라도 reach 안인 holder 들 list.
536
+ * boundHolders 미명시 시 *runtime auto-detect* 결과. crane/rack 정적 위치
537
+ * 가정 — 1회 호출 후 cache.
538
+ */
539
+ detectBoundHolders() {
540
+ const ro = this._realObject;
541
+ const obj3d = ro?.object3d;
542
+ if (!obj3d)
543
+ return [];
544
+ const craneWorld = new THREE.Vector3();
545
+ obj3d.getWorldPosition(craneWorld);
546
+ const s = this.state;
547
+ const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000;
548
+ const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600;
549
+ const forkLen = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
550
+ ? s.forkLength : craneH * 0.6;
551
+ const xHalf = craneW / 2;
552
+ const zHalf = forkLen;
553
+ const root = this.root;
554
+ if (!root)
555
+ return [];
556
+ const result = [];
557
+ const seen = new Set();
558
+ const visit = (component) => {
559
+ if (!component || seen.has(component))
560
+ return;
561
+ seen.add(component);
562
+ if (component !== this && isSlottedHolder(component)) {
563
+ const holder = component;
564
+ const slots = holder.slotIds?.();
565
+ if (slots && slots.length > 0) {
566
+ const getPos = holder.slotWorldPosition?.bind(holder);
567
+ // 랙 자동 인식 — *fork 가 닿는 Z 거리* 만 검사. X 는 무관 — 사용자 의도:
568
+ // 크레인 옆 (포크가 닿는 거리) 에 있는 랙은 *X 방향 어디에 있든* 담당.
569
+ // rail 의 실 X 길이 = 그 랙의 X 길이 (자동 정렬 모델링 가정).
570
+ let matched = false;
571
+ for (const slotId of slots) {
572
+ let pos;
573
+ if (getPos) {
574
+ pos = getPos(slotId);
575
+ }
576
+ else {
577
+ const anchor = holder.getSlotAttachObject3d(slotId);
578
+ if (anchor) {
579
+ const w = new THREE.Vector3();
580
+ anchor.getWorldPosition(w);
581
+ pos = { x: w.x, y: w.y, z: w.z };
582
+ }
583
+ }
584
+ if (!pos)
585
+ continue;
586
+ if (Math.abs(pos.z - craneWorld.z) <= zHalf) {
587
+ matched = true;
588
+ break;
589
+ }
590
+ }
591
+ if (matched)
592
+ result.push(holder);
593
+ }
594
+ }
595
+ const children = component.components;
596
+ if (Array.isArray(children))
597
+ for (const c of children)
598
+ visit(c);
599
+ };
600
+ visit(root);
601
+ return result;
602
+ }
603
+ /**
604
+ * Bound holder instance resolve.
605
+ * 1. boundHolders state 명시 → 그 id list resolve (explicit binding)
606
+ * 2. 미명시 → detectBoundHolders() 1회 + auto-cache (implicit binding,
607
+ * crane / rack 정적 위치 가정)
608
+ *
609
+ * 둘 다 *binding scope* — findAdjacentSlots 가 그 list 만 traverse.
610
+ */
611
+ boundHolderInstances() {
612
+ const raw = this.state?.boundHolders;
613
+ let ids = [];
614
+ if (typeof raw === 'string') {
615
+ ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0);
616
+ }
617
+ else if (Array.isArray(raw)) {
618
+ ids = raw.filter((x) => typeof x === 'string' && x.length > 0);
619
+ }
620
+ if (ids.length > 0) {
621
+ // 명시 binding — explicit list resolve
622
+ const root = this.root;
623
+ if (!root)
624
+ return [];
625
+ const result = [];
626
+ for (const id of ids) {
627
+ const c = typeof root.findById === 'function' ? root.findById(id) : undefined;
628
+ if (c && isSlottedHolder(c))
629
+ result.push(c);
630
+ }
631
+ return result;
632
+ }
633
+ // 미명시 — auto-detect cache
634
+ if (this._autoBoundCache === undefined) {
635
+ this._autoBoundCache = this.detectBoundHolders();
636
+ }
637
+ return this._autoBoundCache;
638
+ }
639
+ findAdjacentSlots(opts = {}) {
640
+ const ro = this._realObject;
641
+ const obj3d = ro?.object3d;
642
+ if (!obj3d)
643
+ return [];
644
+ // crane 은 고정 rail 따라 움직임 (회전 없음) — axis-aligned 박스 비교.
645
+ const craneWorld = new THREE.Vector3();
646
+ obj3d.getWorldPosition(craneWorld);
647
+ const s = this.state;
648
+ const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000;
649
+ const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600;
650
+ const forkLen = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
651
+ ? s.forkLength : craneH * 0.6;
652
+ // X reach 정책:
653
+ // - bound holder 명시/auto-detect 시 → *X 무관*. bound 자체가 scope 정의 —
654
+ // bound rack 의 *X 영역 전체* 가 rail 의 실 X 영역. carriage 가 그 X 안
655
+ // 의 모든 bay 까지 자동 도달.
656
+ // - 미bound 시 → state.width 기반 axis-aligned (fallback).
657
+ const boundCandidates = this.boundHolderInstances();
658
+ // slotId format = "{col-or-bay}-{row}-{shelf-or-level}" (양쪽 holder 동일).
659
+ // 매칭 단위 = (col, row) pair = *last '-' 앞 부분*. row 별로 Z 좌표 다르고
660
+ // crane.fork 의 reach 가 row 마다 도달 여부 다름 → *(col, row) 단위 정밀
661
+ // 매칭*. 통과한 (col, row) 의 *모든 shelves slot* 만 adjacent.
662
+ const bayKeyOf = (slotId) => {
663
+ const i = slotId.lastIndexOf('-');
664
+ return i > 0 ? slotId.substring(0, i) : slotId;
665
+ };
666
+ // Reach 박스 — *crane mesh 의 실 visual 방향* 따라 검사 (2D state.rotation 무관).
667
+ // rail axis (X) = crane.width / 2 — carriage 도달 범위.
668
+ // perpendicular (Z) = forkLength — fork tip extend.
669
+ // matrixWorld 의 local X axis vector 로 projection — Crane.moveTo 와 동일 식.
670
+ const xHalf = craneW / 2;
671
+ const zHalf = forkLen;
672
+ obj3d.updateMatrixWorld(true);
673
+ const _mc = obj3d.matrixWorld.elements;
674
+ const _axisX = _mc[0], _axisZ = _mc[2];
675
+ const _len = Math.hypot(_axisX, _axisZ);
676
+ const ux = _len > 1e-9 ? _axisX / _len : 1;
677
+ const uz = _len > 1e-9 ? _axisZ / _len : 0;
678
+ // rail-perpendicular axis (= fork 방향) = rail axis 90° rotated (XZ 평면).
679
+ const px = -uz;
680
+ const pz = ux;
681
+ const root = this.root;
682
+ if (!root)
683
+ return [];
684
+ const result = [];
685
+ const byHolder = new Map();
686
+ // *binding scope* — boundHolders 명시 시 그 holder 만 traverse (broad scan 0).
687
+ // 미명시 시 scene 전체 traverse fallback (BC, 기존 모델 호환).
688
+ const bound = this.boundHolderInstances();
689
+ const candidates = bound.length > 0 ? bound : [];
690
+ const visit = (component) => {
691
+ if (!component)
692
+ return;
693
+ if ((opts.includeSelf || component !== this) && isSlottedHolder(component)) {
694
+ const holder = component;
695
+ const ids = holder.slotIds?.();
696
+ if (ids && ids.length > 0) {
697
+ // col 별: 모든 row 의 *대표 (shelf=0) slot* 추출 + col 안 모든 id 목록.
698
+ // bay 매칭 시 *어느 row 의 대표 anchor 라도 reach 안* 이면 그 col 전체
699
+ // 매칭 (row 별 z 가 달라도 *crane 의 fork 가 ±Z extend 로 모두 도달*).
700
+ const repsByBay = new Map();
701
+ const allByBay = new Map();
702
+ for (const id of ids) {
703
+ const bay = bayKeyOf(id);
704
+ const lastIdx = id.lastIndexOf('-');
705
+ const lastSeg = lastIdx >= 0 ? id.substring(lastIdx + 1) : id;
706
+ let allList = allByBay.get(bay);
707
+ if (!allList) {
708
+ allList = [];
709
+ allByBay.set(bay, allList);
710
+ }
711
+ allList.push(id);
712
+ // shelf=0 (또는 level=0) 인 것만 대표
713
+ if (lastSeg === '0') {
714
+ let reps = repsByBay.get(bay);
715
+ if (!reps) {
716
+ reps = [];
717
+ repsByBay.set(bay, reps);
718
+ }
719
+ reps.push(id);
720
+ }
721
+ }
722
+ // bay 가 *대표 없음* (shelf=0 미존재) — fallback: 첫 id 사용
723
+ for (const [bay, allList] of allByBay) {
724
+ if (!repsByBay.has(bay))
725
+ repsByBay.set(bay, [allList[0]]);
726
+ }
727
+ const adjSet = new Set();
728
+ // *holder.slotWorldPosition* 지원 시 *anchor 생성 0* — match 전 단계의
729
+ // 직접 좌표 계산 path. 미지원 holder 는 fallback 으로 getSlotAttachObject3d.
730
+ const getPos = holder.slotWorldPosition?.bind(holder);
731
+ // X 매칭 정책:
732
+ // - bound holder 존재 시: *X 무관* (bound 자체가 reach scope — rail 의
733
+ // 실 X 영역 = bound rack 의 X 영역. carriage 가 그 전체 X 까지 도달).
734
+ // Z (fork) 만 검사 — fork tip 도달 여부.
735
+ // - 미bound 시: state.width 기반 axis-aligned 박스 (fallback).
736
+ const useBoundScope = boundCandidates.length > 0;
737
+ for (const [bay, reps] of repsByBay) {
738
+ let matched = false;
739
+ let matchedX = 0, matchedY = 0, matchedZ = 0;
740
+ for (const repId of reps) {
741
+ let pos;
742
+ if (getPos) {
743
+ pos = getPos(repId);
744
+ }
745
+ else {
746
+ const anchor = holder.getSlotAttachObject3d(repId);
747
+ if (anchor) {
748
+ const w = new THREE.Vector3();
749
+ anchor.getWorldPosition(w);
750
+ pos = { x: w.x, y: w.y, z: w.z };
751
+ }
752
+ }
753
+ if (!pos)
754
+ continue;
755
+ // *visual rail 의 실제 world 방향* 따라 projection — 2D rotation 매핑 무관.
756
+ // rail-local X = (pos - crane) · rail axis. rail-local Z = (pos - crane) · perp axis.
757
+ const ddx = pos.x - craneWorld.x;
758
+ const ddz = pos.z - craneWorld.z;
759
+ const railLocal = ddx * ux + ddz * uz;
760
+ const perpLocal = ddx * px + ddz * pz;
761
+ const xOk = Math.abs(railLocal) <= xHalf;
762
+ const zOk = Math.abs(perpLocal) <= zHalf;
763
+ if (xOk && zOk) {
764
+ matched = true;
765
+ matchedX = pos.x;
766
+ matchedY = pos.y;
767
+ matchedZ = pos.z;
768
+ break;
769
+ }
770
+ }
771
+ if (matched) {
772
+ const all = allByBay.get(bay) ?? [];
773
+ const sharedWorld = new THREE.Vector3(matchedX, matchedY, matchedZ);
774
+ // result 의 anchor field 는 *lazy*. 사용처가 *실제 attach 필요* 시
775
+ // holder.getSlotAttachObject3d(slotId) 별도 호출 (그때 lazy 생성).
776
+ // match 검사 자체에선 anchor 0 생성.
777
+ for (const id of all) {
778
+ adjSet.add(id);
779
+ result.push({ holder, slotId: id, anchor: undefined, world: sharedWorld });
780
+ }
781
+ }
782
+ }
783
+ if (adjSet.size > 0)
784
+ byHolder.set(holder, adjSet);
785
+ }
786
+ }
787
+ const children = component.components;
788
+ if (Array.isArray(children)) {
789
+ for (const c of children)
790
+ visit(c);
791
+ }
792
+ };
793
+ // binding scope 우선 — bound 시 그 list 만 visit (broad scan 0). 미명시
794
+ // 시 scene root 부터 fallback traverse.
795
+ if (candidates.length > 0) {
796
+ for (const c of candidates)
797
+ visit(c);
798
+ }
799
+ else {
800
+ visit(root);
801
+ }
802
+ // 매 cycle 협의 — cache 없음. _oneCycle 가 *바로 *byHolder 사용 후 폐기*.
803
+ this._adjacentByHolder = byHolder;
804
+ return result;
805
+ }
418
806
  // ── 2D rendering ─────────────────────────────────────────────────────────
419
807
  /**
420
808
  * 2D top-down 표현 — 크레인으로 명확히 인식되도록 핵심 부품 그림:
@@ -587,11 +975,13 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
587
975
  }
588
976
  if (Object.keys(init).length > 0)
589
977
  this.setState(init);
590
- // state.simulate === true 명시 시만 자동 시작. mode 연동은 board view layer
591
- // 책임 컴포넌트 자체는 simulate state 본다 (sorter/conveyor 다른
592
- // 시뮬 컴포넌트와 동일 패턴).
978
+ // state.simulate === true + *view mode* 모두 만족 시만 자동 시작. modeler
979
+ // 모드에서 simulate 진행 *_oneCycle obtainCarrier RackGrid 자식 추가
980
+ // + state.data 변경* — 모델 영구 변형 위험. mode 검사로 차단.
593
981
  if (this.state.simulate !== true)
594
982
  return;
983
+ if (!this.app?.isViewMode)
984
+ return;
595
985
  this._startAutoSimulate();
596
986
  }
597
987
  /**
@@ -622,6 +1012,9 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
622
1012
  this._simStarted = true;
623
1013
  // 초기 지연 800~2800ms 사이 random — 여러 crane 이 동기적으로 시작하지 않도록.
624
1014
  const initialDelay = 800 + Math.random() * 2000;
1015
+ // prefetch 제거 — 너무 일찍 (rack 의 _realObject 미빌드) 호출 시 0 매칭이
1016
+ // cache 됐던 회귀. 0 결과 cache 안 함으로 fix됐지만, prefetch 자체도 불필요
1017
+ // (_oneCycle 첫 호출에 자동 cache).
625
1018
  setTimeout(() => {
626
1019
  if (this._simAbort || this.state.simulate !== true)
627
1020
  return;
@@ -645,14 +1038,30 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
645
1038
  ;
646
1039
  this.invalidate?.();
647
1040
  }
1041
+ // crane 또는 rack 의 위치/회전/차원 변경 시 adjacent slot cache + bound
1042
+ // holder cache 둘 다 invalidate. boundHolders 명시 변경 시도 bound cache
1043
+ // 무관 (명시 우선) — but invalidate 안전.
1044
+ if ('left' in after ||
1045
+ 'top' in after ||
1046
+ 'rotation' in after ||
1047
+ 'width' in after ||
1048
+ 'height' in after ||
1049
+ 'depth' in after ||
1050
+ 'forkLength' in after ||
1051
+ 'boundHolders' in after) {
1052
+ this.invalidateAdjacentSlots();
1053
+ this.invalidateBoundCache();
1054
+ }
648
1055
  if ('simulate' in after) {
649
1056
  if (after.simulate === true) {
650
- // 명시 true 자동 시작 (이미 시작 중이면 noop). mode gating 은
651
- // frameClock (animatesimTime) 자동 처리 — modeler 면 simTime
652
- // 흐름, _tween step 호출 됨.
1057
+ // simulate=true 명시 *view mode 때만 자동 시작*. modeler 모드
1058
+ // 시작 _oneCycle obtainCarrier RackGrid 자식/state.data 변형
1059
+ // 위험. _oneCycle 매번 isViewMode 재검사 (mode 전환 대응).
653
1060
  this._simAbort = false;
654
1061
  this._simStarted = false;
655
- this._startAutoSimulate();
1062
+ if (this.app?.isViewMode) {
1063
+ this._startAutoSimulate();
1064
+ }
656
1065
  }
657
1066
  else {
658
1067
  this._simAbort = true;
@@ -676,7 +1085,16 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
676
1085
  _railMin = NaN; // local-X 축 rail offset min
677
1086
  _railMax = NaN; // local-X 축 rail offset max
678
1087
  _targetSide = 1; // +Z (fork +Z 로 뻗어야 하나) / -Z
679
- /** Continuous random pick → transport → place cycles. Visual smoke test. */
1088
+ /**
1089
+ * Main loop — 통합 처리.
1090
+ *
1091
+ * 1. dispatcher 의 명시 작업 take → execute (priority 큰 작업 우선)
1092
+ * 2. dispatcher 작업 없음 + state.simulate=true → 기존 random fallback (_oneCycle)
1093
+ * 3. 둘 다 없음 → longer idle (dispatcher 작업 enqueue 대기)
1094
+ *
1095
+ * Phase Z 통합 — 사용자 application 의 `holder.putaway / picking` 호출이 즉시 자연
1096
+ * 처리. simulate=true 시 큐 비면 random visual smoke 도 유지. 작업 사이 자연 idle.
1097
+ */
680
1098
  async simulate() {
681
1099
  if (this._simRunning)
682
1100
  return;
@@ -685,13 +1103,85 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
685
1103
  try {
686
1104
  this._initRailRange();
687
1105
  while (!this._simAbort) {
688
- await this._oneCycle();
1106
+ // Modeling 모드 진입 시 즉시 중단
1107
+ if (!this.app?.isViewMode) {
1108
+ this._simAbort = true;
1109
+ break;
1110
+ }
1111
+ // 1. Dispatcher 의 명시 작업 우선 take
1112
+ const dispatcher = findDispatcher(this);
1113
+ const ticket = dispatcher?.takeNextFor(this) ?? null;
1114
+ if (ticket) {
1115
+ await this._executeTicket(ticket, dispatcher);
1116
+ // 작업 사이 짧은 idle — 명시 작업 중심 처리 → 다음 작업 빠르게
1117
+ await new Promise(r => setTimeout(r, 200 + Math.random() * 600));
1118
+ continue;
1119
+ }
1120
+ // 2. Dispatcher 작업 없음 — state.simulate 켜져 있으면 random fallback
1121
+ if (this.state.simulate === true) {
1122
+ await this._oneCycle();
1123
+ continue;
1124
+ }
1125
+ // 3. simulate OFF + 큐 비어있음 — 작업 들어오기 기다림. 긴 idle.
1126
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 2500));
689
1127
  }
690
1128
  }
691
1129
  finally {
692
1130
  this._simRunning = false;
693
1131
  }
694
1132
  }
1133
+ /**
1134
+ * Dispatcher 가 할당한 ticket 처리. lifecycle:
1135
+ * assigned (이미 takeNextFor 가 set) → in_progress → completed / failed
1136
+ *
1137
+ * 흐름:
1138
+ * 1. request.from / to 를 resolve (string id → SlotTarget, SlotTarget 그대로 등)
1139
+ * 2. carrier obtain — request.carrier 명시 시 그것, 미명시 시 from.holder.obtainCarrier
1140
+ * 3. pickAndPlace(carrier, dest)
1141
+ * 4. ticket status notify
1142
+ *
1143
+ * 실패 처리:
1144
+ * - target resolve 실패 → failed (resolve-target)
1145
+ * - carrier obtain 실패 → failed (no-carrier)
1146
+ * - pickAndPlace throw → failed (그 error)
1147
+ */
1148
+ async _executeTicket(ticket, dispatcher) {
1149
+ const req = ticket.request;
1150
+ try {
1151
+ dispatcher.notifyInProgress?.(ticket) ?? ticket._setStatus('in_progress');
1152
+ // resolve source / dest
1153
+ const source = resolveTransferTarget(req.from, dispatcher);
1154
+ const dest = resolveTransferTarget(req.to, dispatcher);
1155
+ if (!source || !dest) {
1156
+ const err = new Error('resolve-target');
1157
+ dispatcher.notifyFailed?.(ticket, err) ?? ticket._setStatus('failed', { error: err });
1158
+ return;
1159
+ }
1160
+ // carrier obtain — 명시 carrier 우선
1161
+ let carrier = req.carrier ?? null;
1162
+ if (!carrier) {
1163
+ // source 가 SlotTarget — holder.obtainCarrier(slotId)
1164
+ const holder = source.holder;
1165
+ const slotId = source.slotId;
1166
+ if (holder && slotId) {
1167
+ carrier = holder.obtainCarrier(slotId) ?? null;
1168
+ }
1169
+ }
1170
+ if (!carrier) {
1171
+ const err = new Error('no-carrier');
1172
+ dispatcher.notifyFailed?.(ticket, err) ?? ticket._setStatus('failed', { error: err });
1173
+ return;
1174
+ }
1175
+ // pickAndPlace
1176
+ await this.pickAndPlace(carrier, dest);
1177
+ // 성공
1178
+ dispatcher.notifyCompleted?.(ticket) ?? ticket._setStatus('completed');
1179
+ }
1180
+ catch (e) {
1181
+ const err = e instanceof Error ? e : new Error(String(e));
1182
+ dispatcher.notifyFailed?.(ticket, err) ?? ticket._setStatus('failed', { error: err });
1183
+ }
1184
+ }
695
1185
  /**
696
1186
  * state.target bbox 를 crane 의 *로컬 X 축* 으로 projection 해서 rail range 1회 계산.
697
1187
  * Rotation 적용된 crane 의 local X (= rail) 방향으로 움직이도록 cos/sin 캐시.
@@ -777,38 +1267,78 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
777
1267
  this.stopSimulate();
778
1268
  super.dispose?.();
779
1269
  }
1270
+ /**
1271
+ * 1 cycle = *capability 기반 random transfer*.
1272
+ *
1273
+ * 1. findAdjacentSlots() — scene 의 인접 SlottedHolder 의 slot 들
1274
+ * 2. occupied / empty 분리 — hasCarrierAt / canReceiveAt
1275
+ * 3. random source / dest 선택 — 같은 slot 자가 transfer 회피
1276
+ * 4. obtainCarrier + pickAndPlace — Mover.pickAndPlace 가 pick + place 진행
1277
+ *
1278
+ * 인접 slot / occupied / empty 어느 하나라도 비어있으면 짧은 delay 후 next.
1279
+ * 진짜 carrier 가 fork 위 visible 로 이동 — 시각 simulate 가 *동시에 *진짜
1280
+ * 데이타 전환* 도 수행 (Plan A: 데이타 → 실물 → 데이타 atomic 전환).
1281
+ */
780
1282
  async _oneCycle() {
781
- const W = numOr(this.state.width, 100);
782
- const H = numOr(this.state.height, 100);
783
- const D = numOr(this.state.depth, W * 4);
784
- const forkLen = numOr(this.state.forkLength, H * 0.6);
785
- const cw = numOr(this.state.carriageWidth, W * 0.1);
786
- // carriagePosition 의 valid range — rail 안 [cw/2, W - cw/2].
787
- // crane 본체 (rail) 는 안 움직임 — *carriage assembly 만* X 슬라이드.
788
- const minPos = cw / 2;
789
- const maxPos = Math.max(minPos, W - cw / 2);
790
- const pickPos = () => minPos + Math.random() * (maxPos - minPos);
791
- const sourcePos = pickPos();
792
- const destPos = pickPos();
793
- const sourceCH = Math.random() * D * 0.75;
794
- const destCH = Math.random() * D * 0.75;
795
- const liftH = Math.max(20, D * 0.02);
796
- // 양쪽 rack 모두 서비스 source/dest 각각 ±Z random (cycle 별 다름).
797
- const sideA = Math.random() < 0.5 ? -1 : +1;
798
- const sideB = Math.random() < 0.5 ? -1 : +1;
799
- // Tween duration 은 base 의 70~130% 사이 random — 여러 crane 이 같은 타이밍으로 안 보이도록.
800
- const jitter = (base) => base * (0.7 + Math.random() * 0.6);
801
- // 이동: carriagePosition 변경 (crane.left/top 안 건드림).
802
- await this._tween({ status: 'moving', carriagePosition: sourcePos, carriageHeight: sourceCH }, jitter(1500));
803
- await this._tween({ status: 'loading', forkExtension: sideA * forkLen }, jitter(700));
804
- await this._tween({ forkLiftRT: liftH }, jitter(400));
805
- await this._tween({ forkExtension: 0 }, jitter(700));
806
- await this._tween({ status: 'moving', carriagePosition: destPos, carriageHeight: destCH }, jitter(1500));
807
- await this._tween({ status: 'unloading', forkExtension: sideB * forkLen }, jitter(700));
808
- await this._tween({ forkLiftRT: 0 }, jitter(400));
809
- await this._tween({ status: 'idle', forkExtension: 0 }, jitter(700));
810
- // 사이클 사이 짧은 idle (200~1000ms) — 자연스러운 phase 분산
811
- await new Promise(r => setTimeout(r, 200 + Math.random() * 800));
1283
+ // *Modeling 모드 진입 시 즉시 정지* — runtime 에서 mode 전환 (view → modeler)
1284
+ // 대응. obtainCarrier / pickAndPlace 가 modeler 에서 발동하면 *RackGrid 자식
1285
+ // 추가 + state.data 변경* = 모델 영구 변형. 매 cycle 검사 안전.
1286
+ if (!this.app?.isViewMode) {
1287
+ this._simAbort = true;
1288
+ return;
1289
+ }
1290
+ this.findAdjacentSlots(); // cache 채우기 (already-cached 면 no-op)
1291
+ const byHolder = this._adjacentByHolder;
1292
+ if (!byHolder || byHolder.size === 0) {
1293
+ await new Promise(r => setTimeout(r, 800 + Math.random() * 800));
1294
+ return;
1295
+ }
1296
+ // holder 단위로 *occupiedSlotIds / emptySlotIds* 한 번에 조회 + adjacent set
1297
+ // 교집합. holder 내부 sweep (records + child) 이라 *slot.length × records.
1298
+ // length* iteration 없이 *records + children* 1 sweep.
1299
+ // holder.occupiedSlotIds / emptySlotIds *adjacent set 멤버 predicate* 전달
1300
+ // holder sweep 중 즉시 reject. crane 측은 *holder 결과 그대로 사용*.
1301
+ const occupied = [];
1302
+ const empty = [];
1303
+ for (const [holder, adjSet] of byHolder) {
1304
+ const inAdj = (id) => adjSet.has(id);
1305
+ for (const id of holder.occupiedSlotIds(inAdj))
1306
+ occupied.push({ holder, slotId: id });
1307
+ for (const id of holder.emptySlotIds(inAdj))
1308
+ empty.push({ holder, slotId: id });
1309
+ }
1310
+ if (occupied.length === 0 || empty.length === 0) {
1311
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 800));
1312
+ return;
1313
+ }
1314
+ const source = occupied[Math.floor(Math.random() * occupied.length)];
1315
+ // dest 선택 — source 와 같은 (holder, slotId) 회피. attempts 제한.
1316
+ let dest = empty[Math.floor(Math.random() * empty.length)];
1317
+ let attempts = 0;
1318
+ while (dest.holder === source.holder && dest.slotId === source.slotId && attempts < 10) {
1319
+ dest = empty[Math.floor(Math.random() * empty.length)];
1320
+ attempts++;
1321
+ }
1322
+ if (dest.holder === source.holder && dest.slotId === source.slotId) {
1323
+ await new Promise(r => setTimeout(r, 500));
1324
+ return;
1325
+ }
1326
+ try {
1327
+ const carrier = source.holder.obtainCarrier(source.slotId);
1328
+ if (!carrier) {
1329
+ await new Promise(r => setTimeout(r, 400));
1330
+ return;
1331
+ }
1332
+ const destTarget = dest.holder.slotTargetAt(dest.slotId);
1333
+ await this.pickAndPlace(carrier, destTarget);
1334
+ }
1335
+ catch (e) {
1336
+ // 한 cycle 실패해도 simulate 루프는 계속. 다음 cycle 에서 다른 source/dest 시도.
1337
+ // (Mover.pickAndPlace 내부 rollback 이 carrier 복귀 시도함.)
1338
+ }
1339
+ // 사이클 사이 idle — 여러 crane 의 phase 자연 분산 (= 동시 동기화 회귀 차단).
1340
+ // 범위 확장 (500~3500ms) — duration 거리 비례와 결합되어 cycle 총 시간 다양.
1341
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 3000));
812
1342
  }
813
1343
  /**
814
1344
  * frameClock 기반 tween — things-scene 의 표준 `Component.animate()` 사용.