@operato/scene-storage 10.0.0-beta.44 → 10.0.0-beta.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/crane-3d.d.ts +10 -0
  3. package/dist/crane-3d.js +34 -5
  4. package/dist/crane-3d.js.map +1 -1
  5. package/dist/crane.d.ts +136 -6
  6. package/dist/crane.js +578 -48
  7. package/dist/crane.js.map +1 -1
  8. package/dist/parcel-3d.d.ts +1 -0
  9. package/dist/parcel-3d.js +18 -1
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.js +26 -8
  12. package/dist/rack-grid-3d.js.map +1 -1
  13. package/dist/rack-grid.d.ts +103 -10
  14. package/dist/rack-grid.js +484 -86
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/storage-rack-3d.js +1 -1
  17. package/dist/storage-rack-3d.js.map +1 -1
  18. package/dist/storage-rack.d.ts +40 -6
  19. package/dist/storage-rack.js +111 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +4 -4
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +625 -57
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +504 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +111 -14
  29. package/test/test-coord-alignment.ts +2 -2
  30. package/test/test-crane-bay-match.ts +130 -0
  31. package/test/test-crane-binding-resolve.ts +168 -0
  32. package/test/test-crane-duration.ts +90 -0
  33. package/test/test-crane-rotation-reach.ts +218 -0
  34. package/test/test-rack-grid-3d-alignment.ts +235 -0
  35. package/test/test-rack-grid-3d-attach-real.ts +375 -0
  36. package/test/test-rack-grid-cell.ts +2 -2
  37. package/test/test-rack-grid-location.ts +2 -2
  38. package/test/test-rack-grid-occupied-slots.ts +165 -0
  39. package/test/test-rack-grid-picking-position.ts +154 -0
  40. package/test/test-rack-grid-slot-api.ts +483 -0
  41. package/test/test-slot-ids-enumeration.ts +137 -0
  42. package/test/things-scene-loader-impl.mjs +37 -0
  43. package/test/things-scene-loader.mjs +24 -0
  44. package/translations/en.json +2 -0
  45. package/translations/ja.json +2 -0
  46. package/translations/ko.json +2 -0
  47. package/translations/ms.json +2 -0
  48. package/translations/zh.json +2 -0
  49. package/tsconfig.tsbuildinfo +1 -1
package/src/crane.ts CHANGED
@@ -8,16 +8,34 @@ import {
8
8
  Legendable,
9
9
  Mover,
10
10
  Placeable,
11
+ isSlottedHolder,
12
+ findDispatcher,
13
+ resolveTransferTarget,
11
14
  type AttachFrame,
12
15
  type Alignment,
16
+ type Dispatcher,
13
17
  type Heights,
14
18
  type LegendBinding,
15
19
  type MoveOptions,
16
- type PlacementArchetype
20
+ type PlacementArchetype,
21
+ type SlottedHolder,
22
+ type TransferTicket
17
23
  } from '@operato/scene-base'
24
+ import * as THREE from 'three'
18
25
 
19
26
  import { Crane3D } from './crane-3d.js'
20
27
 
28
+ /**
29
+ * AdjacentSlot — crane 의 작업 reach 영역 안에 있는 slot 의 정보. simulate /
30
+ * 자동 transfer 의 source/dest 후보.
31
+ */
32
+ export interface AdjacentSlot {
33
+ holder: SlottedHolder & { state?: any }
34
+ slotId: string
35
+ anchor: THREE.Object3D
36
+ world: THREE.Vector3
37
+ }
38
+
21
39
  /**
22
40
  * Crane status — the operating state of a stacker crane.
23
41
  *
@@ -70,13 +88,25 @@ export interface CraneState extends State {
70
88
  forkLiftRT?: number
71
89
 
72
90
  /**
73
- * 자동 random simulate (visual smoke test) 시작 여부.
74
- * - `undefined` 또는 `false` (default): 자동 시작 안 함. application 이 crane.
75
- * pickAndPlace 직접 제어 사용.
76
- * - `true`: added()자동 시작.
91
+ * 자동 random simulate 시작 여부. capability 기반 — crane.findAdjacentSlots()
92
+ * 으로 인접 SlottedHolder 발견 random source/dest transfer.
93
+ * - `undefined` 또는 `false` (default): 자동 시작 안 함. application 이 crane.
94
+ * pickAndPlace 직접 제어사용.
95
+ * - `true`: added() 시 자동 시작 + state runtime toggle 가능.
77
96
  */
78
97
  simulate?: boolean
79
98
 
99
+ /**
100
+ * 1:N binding — 이 crane 이 *작업 대상으로 인지할 holder id 들*. 모델링 시
101
+ * 사용자가 명시. 명시 시 *findAdjacentSlots 가 그 list 의 holder 만 traverse*
102
+ * → broad scene scan 회피. 미명시 시 *scene 전체 SlottedHolder fallback*
103
+ * (BC, 기존 모델 호환).
104
+ *
105
+ * 입력 형식 — comma-separated id string ("rack-1, rack-2") 또는 array.
106
+ * editor 의 *id-list-input* widget 부재 시 string 으로.
107
+ */
108
+ boundHolders?: string | string[]
109
+
80
110
  /**
81
111
  * Carriage 의 rail-local X 위치 (0 ~ crane.width). Crane.moveTo / simulate 가
82
112
  * lerp. crane 본체 (rail) 는 안 움직임 — carriage assembly (masts + carriage +
@@ -90,6 +120,16 @@ export interface CraneState extends State {
90
120
  */
91
121
  carriageWidth?: number
92
122
 
123
+ /**
124
+ * Carriage 운동 속도 (scene units / sec). 각 crane 별로 다르게 설정 가능 — 같은
125
+ * 거리도 crane 마다 다른 시간. 미명시 시 default 250 u/s.
126
+ *
127
+ * Crane.moveTo 가 *carriage 의 X 및 Y 운동 거리* / speed 로 duration 계산 → 한 칸
128
+ * 이동 vs rail 끝~끝 이동 의 시간 자연 차이. 여러 crane 의 cycle 시간 분산 → 동시
129
+ * 동기화 회귀 차단.
130
+ */
131
+ speed?: number
132
+
93
133
  // ── 3D 재질 ──
94
134
  material3d?: Material3D
95
135
  }
@@ -136,6 +176,12 @@ const NATURE: ComponentNature = {
136
176
  label: 'simulate',
137
177
  name: 'simulate'
138
178
  },
179
+ {
180
+ type: 'string',
181
+ label: 'bound-holders',
182
+ name: 'boundHolders',
183
+ placeholder: 'SlottedHolder id (rack-1, rack-2, ...). 미명시 시 scene 전체 fallback.'
184
+ },
139
185
  {
140
186
  type: 'number',
141
187
  label: 'carriage-position',
@@ -160,6 +206,12 @@ const NATURE: ComponentNature = {
160
206
  name: 'forkLength',
161
207
  placeholder: 'mm — fork prong 최대 신축 길이 (default 600)'
162
208
  },
209
+ {
210
+ type: 'number',
211
+ label: 'speed',
212
+ name: 'speed',
213
+ placeholder: 'scene units/sec — carriage 운동 속도 (default 250)'
214
+ },
163
215
  {
164
216
  type: 'number',
165
217
  label: 'fork-extension',
@@ -258,6 +310,11 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
258
310
  * 미설정 = state-driven) 에 carrier 가 *지하* 로 가는 결함.
259
311
  */
260
312
  get slots(): SlotDef[] {
313
+ // Phase Z PR-4: 사용자가 state.forkSlots 명시 시 그대로 활용. 미명시 시 단일
314
+ // 'forks' default (backward compat). multi-fork crane (twin / quad) 모델링
315
+ // 가능 — [{ id: 'fork-L', ... }, { id: 'fork-R', ... }] 형태로.
316
+ const custom = (this.state as any)?.forkSlots
317
+ if (Array.isArray(custom) && custom.length > 0) return custom as SlotDef[]
261
318
  return [{ id: 'forks', maxCount: 1, localPosition: { x: 0, y: 0, z: 0 } }]
262
319
  }
263
320
 
@@ -381,13 +438,36 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
381
438
  : craneCenterLocal
382
439
  const ccx = craneCenterAbs.x
383
440
  const ccy = craneCenterAbs.y
384
- const rotation = (this.state.rotation as number) ?? 0
385
- const cos = Math.cos(rotation)
386
- const sin = Math.sin(rotation)
387
441
 
388
442
  const dx = tcx - ccx
389
443
  const dy = tcy - ccy
390
- const railLocalX = dx * cos + dy * sin
444
+
445
+ // Rail axis 결정 — *2D state.rotation 무관*. 실제 3D scene 안의 crane mesh 가
446
+ // *어떤 방향* 에 놓였든 *그 visual rail 방향* 따라 carriage 운동. crane.object3d
447
+ // 의 matrixWorld 에서 local X axis (= rail mesh 의 long axis = BoxGeometry(width,..)
448
+ // 의 X 방향) 의 world 방향 벡터 추출. 2D rotation→3D 매핑 컨벤션 (.rotation.y vs
449
+ // rotation.z, 부호) 영향 0 — 실 visual 방향 그대로 사용.
450
+ const ro: any = this._realObject
451
+ const obj3d: THREE.Object3D | undefined = ro?.object3d
452
+ let railLocalX: number
453
+ if (obj3d) {
454
+ obj3d.updateMatrixWorld(true)
455
+ const m = obj3d.matrixWorld.elements
456
+ // 3D world frame: local X axis = (m[0], m[1], m[2]). 2D Y == 3D Z 매핑.
457
+ // 사용자 (dx, dy) = 2D scene 좌표 차이 = 3D (X, Z) 차이.
458
+ const axisX = m[0]
459
+ const axisZ = m[2]
460
+ const len = Math.hypot(axisX, axisZ)
461
+ if (len > 1e-9) {
462
+ const ux = axisX / len
463
+ const uz = axisZ / len
464
+ railLocalX = dx * ux + dy * uz
465
+ } else {
466
+ railLocalX = dx
467
+ }
468
+ } else {
469
+ railLocalX = dx
470
+ }
391
471
 
392
472
  const cw = (this.state.carriageWidth as number) ?? W * 0.1
393
473
  const minPos = cw / 2
@@ -402,8 +482,10 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
402
482
  const tween: Record<string, number> = { carriagePosition: newCarriagePos }
403
483
  const targetBottomWorldY = resolveCarrierBottomY(target)
404
484
  if (targetBottomWorldY !== null) {
485
+ // Phase Z PR-4: 임의 slot 의 carrier 검사 (multi-fork 대비). 단일 'forks'
486
+ // 가정 제거 — c._transferSlotId 가 set 됐으면 어떤 slot 이든 holding.
405
487
  const isHoldingCarrier = ((this as any).components as any[] | undefined)?.some?.(
406
- (c: any) => c?._transferSlotId === 'forks'
488
+ (c: any) => c?._transferSlotId != null
407
489
  ) ?? false
408
490
  const liftH = numOr((this.state as any).forkLift, 30)
409
491
  const approachWorldY = targetBottomWorldY + (isHoldingCarrier ? liftH : 0)
@@ -416,9 +498,38 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
416
498
  } else {
417
499
  tween.carriageHeight = approachWorldY
418
500
  }
501
+ // Mast 높이 한계 (= crane.state.depth) 초과 시 carriageHeight 가 *clamp* 되어
502
+ // fork 가 *상한까지만* 도달 → 더 높은 cell 은 *한 층 아래에서 포킹*. 데이터
503
+ // 결함 (rack 보다 짧은 crane) 을 silent 무시 안 하도록 명시 경고.
504
+ const mastMax = numOr((this.state as any).depth, Math.max(numOr((this.state as any).width, 200), numOr((this.state as any).height, 200)) * 4)
505
+ if (typeof tween.carriageHeight === 'number' && tween.carriageHeight > mastMax) {
506
+ console.warn(
507
+ `[crane] carriageHeight=${tween.carriageHeight.toFixed(1)} > mast max=${mastMax.toFixed(1)} (state.depth)` +
508
+ ` — fork 가 mast 상한에서 멈춰 target 한 층 아래에서 포킹 가능. crane.state.depth 증가 필요.`
509
+ )
510
+ }
419
511
  }
420
512
 
421
- const duration = (options.duration as number) ?? 1500
513
+ // Duration = *carriage 운동 거리 / speed*. 한 cycle 이 이동 거리 비례 시간.
514
+ // - X 운동 거리 = |newCarriagePos - 현재 carriagePosition|
515
+ // - Y 운동 거리 = |tween.carriageHeight - 현재 carriageHeight| (carrier 있는 경우)
516
+ // - 두 운동은 *동시 lerp* → max 거리 만큼 시간 소요.
517
+ // options.duration 명시 시 그 값 우선. 미명시 시 state.speed 또는 default.
518
+ let duration: number
519
+ if (typeof options.duration === 'number') {
520
+ duration = options.duration
521
+ } else {
522
+ const curCP = numOr((this.state as any).carriagePosition, this._canonicalDefault('carriagePosition'))
523
+ const curCH = numOr((this.state as any).carriageHeight, this._canonicalDefault('carriageHeight'))
524
+ const dxRail = Math.abs((tween.carriagePosition as number) - curCP)
525
+ const dyMast = typeof tween.carriageHeight === 'number' ? Math.abs(tween.carriageHeight - curCH) : 0
526
+ const dist = Math.max(dxRail, dyMast)
527
+ const speed =
528
+ (typeof options.speed === 'number' ? options.speed : undefined) ??
529
+ (typeof (this.state as any).speed === 'number' ? (this.state as any).speed : undefined) ??
530
+ 250 // DEFAULT_SPEED — scene units/sec (이전 500 → 사용자 체감 빠름 → 절반)
531
+ duration = speed > 0 ? Math.max(200, (dist / speed) * 1000) : 1500
532
+ }
422
533
  this.setState({ status: 'moving' as CraneStatus })
423
534
  await this._tween(tween, duration)
424
535
  this.setState({ status: 'idle' as CraneStatus })
@@ -509,8 +620,9 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
509
620
  await this._tween({ forkLiftRT: 0 }, D_LIFT)
510
621
 
511
622
  // 3. dispatch — carrier world 위치 = cell 내 attach 위치, jump 없음. Transfer 추적.
623
+ // Phase Z PR-4: 임의 slot 의 carrier 검사 (multi-fork 대비).
512
624
  const comps = (this as any).components as any[] | undefined
513
- const carrier = comps?.find?.((c: any) => c?._transferSlotId === 'forks')
625
+ const carrier = comps?.find?.((c: any) => c?._transferSlotId != null)
514
626
  ?? comps?.[0]
515
627
  if (carrier) {
516
628
  try {
@@ -542,6 +654,315 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
542
654
  return this.place(carrier, cell, options)
543
655
  }
544
656
 
657
+ // ── Capability-based adjacency discovery ──────────────────────────────────
658
+
659
+ /**
660
+ * scene 의 모든 SlottedHolder 의 *slot 중에서 crane 의 작업 reach 안에 들어
661
+ * 오는 slot 들* 을 반환. 자동 simulate / WCS-less 시나리오의 enumeration entry.
662
+ *
663
+ * 매칭 알고리즘 (공간 reach 박스):
664
+ * - crane.world center (axis-aligned 가정 — rotation 무시)
665
+ * - 각 slot.anchor.world position 이
666
+ * |x - cx| ≤ width/2 + reachXZ AND |z - cz| ≤ height/2 + reachXZ
667
+ * AND |y - cy| ≤ depth/2 + reachY
668
+ * 범위 안인가
669
+ * - reachXZ = `crane.state.height` (fork forward 폭). reachY = `crane.state.depth`/2.
670
+ *
671
+ * RackGrid 의 *isEmpty aisle 통로 자리에 crane 이 배치* 된 케이스도 자동 cover —
672
+ * 통로 양옆 bay 의 slot anchor world 가 reach 안에 자연히 들어옴.
673
+ *
674
+ * @param opts.includeSelf 자기 자신을 holder 로 포함할지 (default false — crane
675
+ * 은 SlottedHolder 아니지만 안전 차원).
676
+ */
677
+ /**
678
+ * Cycle 단위 cache 없음 — **매 cycle 협의** (capability negotiation 의 본질).
679
+ * crane.carriagePosition 변경 / rack 추가-이동 / 동적 forkLength 등 *runtime
680
+ * 변화 대응*. cost 는 *slotWorldPosition (anchor 0 생성) + canReach (cheap
681
+ * 좌표 비교)* — 매 cycle 호출 부담 작음.
682
+ *
683
+ * 이 field 는 *_oneCycle 사용을 위한 *호출 당 임시 holder grouping** —
684
+ * findAdjacentSlots 결과의 byHolder mapping. caller (_oneCycle) 가 사용 후
685
+ * 폐기.
686
+ */
687
+ private _adjacentByHolder?: Map<SlottedHolder & { state?: any }, Set<string>>
688
+
689
+ /** Cache 없음 — invalidate 호출도 no-op (호환 차원 keep). */
690
+ invalidateAdjacentSlots(): void {
691
+ this._adjacentByHolder = undefined
692
+ }
693
+
694
+ /**
695
+ * 자동 detect 결과 cache — *boundHolders 미명시 시* 사용. crane / rack 위치가
696
+ * 정적이라 *1회 detect 후 영구 cache 안전*. 위치/회전/차원 변경 시 invalidate.
697
+ */
698
+ private _autoBoundCache?: Array<SlottedHolder & { state?: any }>
699
+
700
+ /** Auto bound cache invalidate — crane state 변경 시 호출. */
701
+ invalidateBoundCache(): void {
702
+ this._autoBoundCache = undefined
703
+ }
704
+
705
+ /**
706
+ * Scene traverse + reach 검사 — 어느 slot 1개라도 reach 안인 holder 들 list.
707
+ * boundHolders 미명시 시 *runtime auto-detect* 결과. crane/rack 정적 위치
708
+ * 가정 — 1회 호출 후 cache.
709
+ */
710
+ detectBoundHolders(): Array<SlottedHolder & { state?: any }> {
711
+ const ro = this._realObject as any
712
+ const obj3d: THREE.Object3D | undefined = ro?.object3d
713
+ if (!obj3d) return []
714
+ const craneWorld = new THREE.Vector3()
715
+ obj3d.getWorldPosition(craneWorld)
716
+
717
+ const s: any = this.state
718
+ const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000
719
+ const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600
720
+ const forkLen: number = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
721
+ ? s.forkLength : craneH * 0.6
722
+ const xHalf = craneW / 2
723
+ const zHalf = forkLen
724
+
725
+ const root: any = (this as any).root
726
+ if (!root) return []
727
+ const result: Array<SlottedHolder & { state?: any }> = []
728
+ const seen = new Set<any>()
729
+ const visit = (component: any) => {
730
+ if (!component || seen.has(component)) return
731
+ seen.add(component)
732
+ if (component !== this && isSlottedHolder(component)) {
733
+ const holder = component as SlottedHolder & { state?: any }
734
+ const slots = holder.slotIds?.()
735
+ if (slots && slots.length > 0) {
736
+ const getPos = (holder as any).slotWorldPosition?.bind(holder) as
737
+ ((id: string) => { x: number; y: number; z: number } | undefined) | undefined
738
+ // 랙 자동 인식 — *fork 가 닿는 Z 거리* 만 검사. X 는 무관 — 사용자 의도:
739
+ // 크레인 옆 (포크가 닿는 거리) 에 있는 랙은 *X 방향 어디에 있든* 담당.
740
+ // rail 의 실 X 길이 = 그 랙의 X 길이 (자동 정렬 모델링 가정).
741
+ let matched = false
742
+ for (const slotId of slots) {
743
+ let pos: { x: number; y: number; z: number } | undefined
744
+ if (getPos) {
745
+ pos = getPos(slotId)
746
+ } else {
747
+ const anchor = holder.getSlotAttachObject3d(slotId)
748
+ if (anchor) {
749
+ const w = new THREE.Vector3()
750
+ anchor.getWorldPosition(w)
751
+ pos = { x: w.x, y: w.y, z: w.z }
752
+ }
753
+ }
754
+ if (!pos) continue
755
+ if (Math.abs(pos.z - craneWorld.z) <= zHalf) {
756
+ matched = true
757
+ break
758
+ }
759
+ }
760
+ if (matched) result.push(holder)
761
+ }
762
+ }
763
+ const children = (component as any).components
764
+ if (Array.isArray(children)) for (const c of children) visit(c)
765
+ }
766
+ visit(root)
767
+ return result
768
+ }
769
+
770
+ /**
771
+ * Bound holder instance resolve.
772
+ * 1. boundHolders state 명시 → 그 id list resolve (explicit binding)
773
+ * 2. 미명시 → detectBoundHolders() 1회 + auto-cache (implicit binding,
774
+ * crane / rack 정적 위치 가정)
775
+ *
776
+ * 둘 다 *binding scope* — findAdjacentSlots 가 그 list 만 traverse.
777
+ */
778
+ boundHolderInstances(): Array<SlottedHolder & { state?: any }> {
779
+ const raw = (this.state as any)?.boundHolders
780
+ let ids: string[] = []
781
+ if (typeof raw === 'string') {
782
+ ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0)
783
+ } else if (Array.isArray(raw)) {
784
+ ids = raw.filter((x: any) => typeof x === 'string' && x.length > 0)
785
+ }
786
+ if (ids.length > 0) {
787
+ // 명시 binding — explicit list resolve
788
+ const root: any = (this as any).root
789
+ if (!root) return []
790
+ const result: Array<SlottedHolder & { state?: any }> = []
791
+ for (const id of ids) {
792
+ const c = typeof root.findById === 'function' ? root.findById(id) : undefined
793
+ if (c && isSlottedHolder(c)) result.push(c as any)
794
+ }
795
+ return result
796
+ }
797
+ // 미명시 — auto-detect cache
798
+ if (this._autoBoundCache === undefined) {
799
+ this._autoBoundCache = this.detectBoundHolders()
800
+ }
801
+ return this._autoBoundCache
802
+ }
803
+
804
+ findAdjacentSlots(opts: { includeSelf?: boolean } = {}): AdjacentSlot[] {
805
+ const ro = this._realObject as any
806
+ const obj3d: THREE.Object3D | undefined = ro?.object3d
807
+ if (!obj3d) return []
808
+
809
+ // crane 은 고정 rail 따라 움직임 (회전 없음) — axis-aligned 박스 비교.
810
+ const craneWorld = new THREE.Vector3()
811
+ obj3d.getWorldPosition(craneWorld)
812
+
813
+ const s: any = this.state
814
+ const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000
815
+ const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600
816
+ const forkLen: number = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
817
+ ? s.forkLength : craneH * 0.6
818
+
819
+ // X reach 정책:
820
+ // - bound holder 명시/auto-detect 시 → *X 무관*. bound 자체가 scope 정의 —
821
+ // bound rack 의 *X 영역 전체* 가 rail 의 실 X 영역. carriage 가 그 X 안
822
+ // 의 모든 bay 까지 자동 도달.
823
+ // - 미bound 시 → state.width 기반 axis-aligned (fallback).
824
+ const boundCandidates = this.boundHolderInstances()
825
+
826
+ // slotId format = "{col-or-bay}-{row}-{shelf-or-level}" (양쪽 holder 동일).
827
+ // 매칭 단위 = (col, row) pair = *last '-' 앞 부분*. row 별로 Z 좌표 다르고
828
+ // crane.fork 의 reach 가 row 마다 도달 여부 다름 → *(col, row) 단위 정밀
829
+ // 매칭*. 통과한 (col, row) 의 *모든 shelves slot* 만 adjacent.
830
+ const bayKeyOf = (slotId: string): string => {
831
+ const i = slotId.lastIndexOf('-')
832
+ return i > 0 ? slotId.substring(0, i) : slotId
833
+ }
834
+
835
+ // Reach 박스 — *crane mesh 의 실 visual 방향* 따라 검사 (2D state.rotation 무관).
836
+ // rail axis (X) = crane.width / 2 — carriage 도달 범위.
837
+ // perpendicular (Z) = forkLength — fork tip extend.
838
+ // matrixWorld 의 local X axis vector 로 projection — Crane.moveTo 와 동일 식.
839
+ const xHalf = craneW / 2
840
+ const zHalf = forkLen
841
+ obj3d.updateMatrixWorld(true)
842
+ const _mc = obj3d.matrixWorld.elements
843
+ const _axisX = _mc[0], _axisZ = _mc[2]
844
+ const _len = Math.hypot(_axisX, _axisZ)
845
+ const ux = _len > 1e-9 ? _axisX / _len : 1
846
+ const uz = _len > 1e-9 ? _axisZ / _len : 0
847
+ // rail-perpendicular axis (= fork 방향) = rail axis 90° rotated (XZ 평면).
848
+ const px = -uz
849
+ const pz = ux
850
+
851
+ const root: any = (this as any).root
852
+ if (!root) return []
853
+
854
+ const result: AdjacentSlot[] = []
855
+ const byHolder = new Map<SlottedHolder & { state?: any }, Set<string>>()
856
+
857
+ // *binding scope* — boundHolders 명시 시 그 holder 만 traverse (broad scan 0).
858
+ // 미명시 시 scene 전체 traverse fallback (BC, 기존 모델 호환).
859
+ const bound = this.boundHolderInstances()
860
+ const candidates: any[] = bound.length > 0 ? bound : []
861
+
862
+ const visit = (component: any) => {
863
+ if (!component) return
864
+ if ((opts.includeSelf || component !== this) && isSlottedHolder(component)) {
865
+ const holder = component as SlottedHolder & { state?: any }
866
+ const ids = holder.slotIds?.()
867
+ if (ids && ids.length > 0) {
868
+ // col 별: 모든 row 의 *대표 (shelf=0) slot* 추출 + col 안 모든 id 목록.
869
+ // bay 매칭 시 *어느 row 의 대표 anchor 라도 reach 안* 이면 그 col 전체
870
+ // 매칭 (row 별 z 가 달라도 *crane 의 fork 가 ±Z extend 로 모두 도달*).
871
+ const repsByBay = new Map<string, string[]>()
872
+ const allByBay = new Map<string, string[]>()
873
+ for (const id of ids) {
874
+ const bay = bayKeyOf(id)
875
+ const lastIdx = id.lastIndexOf('-')
876
+ const lastSeg = lastIdx >= 0 ? id.substring(lastIdx + 1) : id
877
+ let allList = allByBay.get(bay)
878
+ if (!allList) { allList = []; allByBay.set(bay, allList) }
879
+ allList.push(id)
880
+ // shelf=0 (또는 level=0) 인 것만 대표
881
+ if (lastSeg === '0') {
882
+ let reps = repsByBay.get(bay)
883
+ if (!reps) { reps = []; repsByBay.set(bay, reps) }
884
+ reps.push(id)
885
+ }
886
+ }
887
+ // bay 가 *대표 없음* (shelf=0 미존재) — fallback: 첫 id 사용
888
+ for (const [bay, allList] of allByBay) {
889
+ if (!repsByBay.has(bay)) repsByBay.set(bay, [allList[0]])
890
+ }
891
+
892
+ const adjSet = new Set<string>()
893
+ // *holder.slotWorldPosition* 지원 시 *anchor 생성 0* — match 전 단계의
894
+ // 직접 좌표 계산 path. 미지원 holder 는 fallback 으로 getSlotAttachObject3d.
895
+ const getPos = (holder as any).slotWorldPosition?.bind(holder) as
896
+ ((id: string) => { x: number; y: number; z: number } | undefined) | undefined
897
+
898
+ // X 매칭 정책:
899
+ // - bound holder 존재 시: *X 무관* (bound 자체가 reach scope — rail 의
900
+ // 실 X 영역 = bound rack 의 X 영역. carriage 가 그 전체 X 까지 도달).
901
+ // Z (fork) 만 검사 — fork tip 도달 여부.
902
+ // - 미bound 시: state.width 기반 axis-aligned 박스 (fallback).
903
+ const useBoundScope = boundCandidates.length > 0
904
+ for (const [bay, reps] of repsByBay) {
905
+ let matched = false
906
+ let matchedX = 0, matchedY = 0, matchedZ = 0
907
+ for (const repId of reps) {
908
+ let pos: { x: number; y: number; z: number } | undefined
909
+ if (getPos) {
910
+ pos = getPos(repId)
911
+ } else {
912
+ const anchor = holder.getSlotAttachObject3d(repId)
913
+ if (anchor) {
914
+ const w = new THREE.Vector3()
915
+ anchor.getWorldPosition(w)
916
+ pos = { x: w.x, y: w.y, z: w.z }
917
+ }
918
+ }
919
+ if (!pos) continue
920
+ // *visual rail 의 실제 world 방향* 따라 projection — 2D rotation 매핑 무관.
921
+ // rail-local X = (pos - crane) · rail axis. rail-local Z = (pos - crane) · perp axis.
922
+ const ddx = pos.x - craneWorld.x
923
+ const ddz = pos.z - craneWorld.z
924
+ const railLocal = ddx * ux + ddz * uz
925
+ const perpLocal = ddx * px + ddz * pz
926
+ const xOk = Math.abs(railLocal) <= xHalf
927
+ const zOk = Math.abs(perpLocal) <= zHalf
928
+ if (xOk && zOk) {
929
+ matched = true
930
+ matchedX = pos.x; matchedY = pos.y; matchedZ = pos.z
931
+ break
932
+ }
933
+ }
934
+ if (matched) {
935
+ const all = allByBay.get(bay) ?? []
936
+ const sharedWorld = new THREE.Vector3(matchedX, matchedY, matchedZ)
937
+ // result 의 anchor field 는 *lazy*. 사용처가 *실제 attach 필요* 시
938
+ // holder.getSlotAttachObject3d(slotId) 별도 호출 (그때 lazy 생성).
939
+ // match 검사 자체에선 anchor 0 생성.
940
+ for (const id of all) {
941
+ adjSet.add(id)
942
+ result.push({ holder, slotId: id, anchor: undefined as any, world: sharedWorld })
943
+ }
944
+ }
945
+ }
946
+ if (adjSet.size > 0) byHolder.set(holder, adjSet)
947
+ }
948
+ }
949
+ const children = (component as any).components
950
+ if (Array.isArray(children)) {
951
+ for (const c of children) visit(c)
952
+ }
953
+ }
954
+ // binding scope 우선 — bound 시 그 list 만 visit (broad scan 0). 미명시
955
+ // 시 scene root 부터 fallback traverse.
956
+ if (candidates.length > 0) {
957
+ for (const c of candidates) visit(c)
958
+ } else {
959
+ visit(root)
960
+ }
961
+ // 매 cycle 협의 — cache 없음. _oneCycle 가 *바로 *byHolder 사용 후 폐기*.
962
+ this._adjacentByHolder = byHolder
963
+ return result
964
+ }
965
+
545
966
  // ── 2D rendering ─────────────────────────────────────────────────────────
546
967
 
547
968
  /**
@@ -735,10 +1156,11 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
735
1156
  }
736
1157
  if (Object.keys(init).length > 0) this.setState(init)
737
1158
 
738
- // state.simulate === true 명시 시만 자동 시작. mode 연동은 board view layer
739
- // 책임 컴포넌트 자체는 simulate state 본다 (sorter/conveyor 다른
740
- // 시뮬 컴포넌트와 동일 패턴).
1159
+ // state.simulate === true + *view mode* 모두 만족 시만 자동 시작. modeler
1160
+ // 모드에서 simulate 진행 *_oneCycle obtainCarrier RackGrid 자식 추가
1161
+ // + state.data 변경* — 모델 영구 변형 위험. mode 검사로 차단.
741
1162
  if ((this.state as CraneState).simulate !== true) return
1163
+ if (!(this as any).app?.isViewMode) return
742
1164
  this._startAutoSimulate()
743
1165
  }
744
1166
 
@@ -770,6 +1192,9 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
770
1192
  this._simStarted = true
771
1193
  // 초기 지연 800~2800ms 사이 random — 여러 crane 이 동기적으로 시작하지 않도록.
772
1194
  const initialDelay = 800 + Math.random() * 2000
1195
+ // prefetch 제거 — 너무 일찍 (rack 의 _realObject 미빌드) 호출 시 0 매칭이
1196
+ // cache 됐던 회귀. 0 결과 cache 안 함으로 fix됐지만, prefetch 자체도 불필요
1197
+ // (_oneCycle 첫 호출에 자동 cache).
773
1198
  setTimeout(() => {
774
1199
  if (this._simAbort || (this.state as CraneState).simulate !== true) return
775
1200
  this.simulate().catch(e => console.error('[Crane] simulate', e))
@@ -794,14 +1219,32 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
794
1219
  ) {
795
1220
  ;(this as any).invalidate?.()
796
1221
  }
1222
+ // crane 또는 rack 의 위치/회전/차원 변경 시 adjacent slot cache + bound
1223
+ // holder cache 둘 다 invalidate. boundHolders 명시 변경 시도 bound cache
1224
+ // 무관 (명시 우선) — but invalidate 안전.
1225
+ if (
1226
+ 'left' in after ||
1227
+ 'top' in after ||
1228
+ 'rotation' in after ||
1229
+ 'width' in after ||
1230
+ 'height' in after ||
1231
+ 'depth' in after ||
1232
+ 'forkLength' in after ||
1233
+ 'boundHolders' in after
1234
+ ) {
1235
+ this.invalidateAdjacentSlots()
1236
+ this.invalidateBoundCache()
1237
+ }
797
1238
  if ('simulate' in after) {
798
1239
  if (after.simulate === true) {
799
- // 명시 true 자동 시작 (이미 시작 중이면 noop). mode gating 은
800
- // frameClock (animatesimTime) 자동 처리 — modeler 면 simTime
801
- // 흐름, _tween step 호출 됨.
1240
+ // simulate=true 명시 *view mode 때만 자동 시작*. modeler 모드
1241
+ // 시작 _oneCycle obtainCarrier RackGrid 자식/state.data 변형
1242
+ // 위험. _oneCycle 매번 isViewMode 재검사 (mode 전환 대응).
802
1243
  this._simAbort = false
803
1244
  this._simStarted = false
804
- this._startAutoSimulate()
1245
+ if ((this as any).app?.isViewMode) {
1246
+ this._startAutoSimulate()
1247
+ }
805
1248
  } else {
806
1249
  this._simAbort = true
807
1250
  }
@@ -829,7 +1272,16 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
829
1272
  private _railMax = NaN // local-X 축 rail offset max
830
1273
  private _targetSide = 1 // +Z (fork +Z 로 뻗어야 하나) / -Z
831
1274
 
832
- /** Continuous random pick → transport → place cycles. Visual smoke test. */
1275
+ /**
1276
+ * Main loop — 통합 처리.
1277
+ *
1278
+ * 1. dispatcher 의 명시 작업 take → execute (priority 큰 작업 우선)
1279
+ * 2. dispatcher 작업 없음 + state.simulate=true → 기존 random fallback (_oneCycle)
1280
+ * 3. 둘 다 없음 → longer idle (dispatcher 작업 enqueue 대기)
1281
+ *
1282
+ * Phase Z 통합 — 사용자 application 의 `holder.putaway / picking` 호출이 즉시 자연
1283
+ * 처리. simulate=true 시 큐 비면 random visual smoke 도 유지. 작업 사이 자연 idle.
1284
+ */
833
1285
  async simulate(): Promise<void> {
834
1286
  if (this._simRunning) return
835
1287
  this._simRunning = true
@@ -837,13 +1289,92 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
837
1289
  try {
838
1290
  this._initRailRange()
839
1291
  while (!this._simAbort) {
840
- await this._oneCycle()
1292
+ // Modeling 모드 진입 시 즉시 중단
1293
+ if (!(this as any).app?.isViewMode) {
1294
+ this._simAbort = true
1295
+ break
1296
+ }
1297
+
1298
+ // 1. Dispatcher 의 명시 작업 우선 take
1299
+ const dispatcher = findDispatcher(this as any)
1300
+ const ticket = dispatcher?.takeNextFor(this as any) ?? null
1301
+ if (ticket) {
1302
+ await this._executeTicket(ticket, dispatcher!)
1303
+ // 작업 사이 짧은 idle — 명시 작업 중심 처리 → 다음 작업 빠르게
1304
+ await new Promise(r => setTimeout(r, 200 + Math.random() * 600))
1305
+ continue
1306
+ }
1307
+
1308
+ // 2. Dispatcher 작업 없음 — state.simulate 켜져 있으면 random fallback
1309
+ if ((this.state as CraneState).simulate === true) {
1310
+ await this._oneCycle()
1311
+ continue
1312
+ }
1313
+
1314
+ // 3. simulate OFF + 큐 비어있음 — 작업 들어오기 기다림. 긴 idle.
1315
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 2500))
841
1316
  }
842
1317
  } finally {
843
1318
  this._simRunning = false
844
1319
  }
845
1320
  }
846
1321
 
1322
+ /**
1323
+ * Dispatcher 가 할당한 ticket 처리. lifecycle:
1324
+ * assigned (이미 takeNextFor 가 set) → in_progress → completed / failed
1325
+ *
1326
+ * 흐름:
1327
+ * 1. request.from / to 를 resolve (string id → SlotTarget, SlotTarget 그대로 등)
1328
+ * 2. carrier obtain — request.carrier 명시 시 그것, 미명시 시 from.holder.obtainCarrier
1329
+ * 3. pickAndPlace(carrier, dest)
1330
+ * 4. ticket status notify
1331
+ *
1332
+ * 실패 처리:
1333
+ * - target resolve 실패 → failed (resolve-target)
1334
+ * - carrier obtain 실패 → failed (no-carrier)
1335
+ * - pickAndPlace throw → failed (그 error)
1336
+ */
1337
+ private async _executeTicket(ticket: TransferTicket, dispatcher: Dispatcher): Promise<void> {
1338
+ const req = ticket.request
1339
+ try {
1340
+ dispatcher.notifyInProgress?.(ticket as any) ?? (ticket as any)._setStatus('in_progress')
1341
+
1342
+ // resolve source / dest
1343
+ const source = resolveTransferTarget(req.from, dispatcher)
1344
+ const dest = resolveTransferTarget(req.to, dispatcher)
1345
+ if (!source || !dest) {
1346
+ const err = new Error('resolve-target')
1347
+ dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
1348
+ return
1349
+ }
1350
+
1351
+ // carrier obtain — 명시 carrier 우선
1352
+ let carrier: Component | null = req.carrier ?? null
1353
+ if (!carrier) {
1354
+ // source 가 SlotTarget — holder.obtainCarrier(slotId)
1355
+ const holder = (source as any).holder as SlottedHolder | undefined
1356
+ const slotId = (source as any).slotId as string | undefined
1357
+ if (holder && slotId) {
1358
+ carrier = holder.obtainCarrier(slotId) ?? null
1359
+ }
1360
+ }
1361
+ if (!carrier) {
1362
+ const err = new Error('no-carrier')
1363
+ dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
1364
+ return
1365
+ }
1366
+
1367
+ // pickAndPlace
1368
+ await this.pickAndPlace(carrier, dest as Component)
1369
+
1370
+ // 성공
1371
+ dispatcher.notifyCompleted?.(ticket as any) ?? (ticket as any)._setStatus('completed')
1372
+ } catch (e) {
1373
+ const err = e instanceof Error ? e : new Error(String(e))
1374
+ dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
1375
+ }
1376
+ }
1377
+
847
1378
  /**
848
1379
  * state.target bbox 를 crane 의 *로컬 X 축* 으로 projection 해서 rail range 1회 계산.
849
1380
  * Rotation 적용된 crane 의 local X (= rail) 방향으로 움직이도록 cos/sin 캐시.
@@ -930,44 +1461,81 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
930
1461
  super.dispose?.()
931
1462
  }
932
1463
 
1464
+ /**
1465
+ * 1 cycle = *capability 기반 random transfer*.
1466
+ *
1467
+ * 1. findAdjacentSlots() — scene 의 인접 SlottedHolder 의 slot 들
1468
+ * 2. occupied / empty 분리 — hasCarrierAt / canReceiveAt
1469
+ * 3. random source / dest 선택 — 같은 slot 자가 transfer 회피
1470
+ * 4. obtainCarrier + pickAndPlace — Mover.pickAndPlace 가 pick + place 진행
1471
+ *
1472
+ * 인접 slot / occupied / empty 어느 하나라도 비어있으면 짧은 delay 후 next.
1473
+ * 진짜 carrier 가 fork 위 visible 로 이동 — 시각 simulate 가 *동시에 *진짜
1474
+ * 데이타 전환* 도 수행 (Plan A: 데이타 → 실물 → 데이타 atomic 전환).
1475
+ */
933
1476
  private async _oneCycle(): Promise<void> {
934
- const W = numOr((this.state as any).width, 100)
935
- const H = numOr((this.state as any).height, 100)
936
- const D = numOr((this.state as any).depth, W * 4)
937
- const forkLen = numOr((this.state as any).forkLength, H * 0.6)
938
- const cw = numOr((this.state as any).carriageWidth, W * 0.1)
1477
+ // *Modeling 모드 진입 시 즉시 정지* — runtime 에서 mode 전환 (view modeler)
1478
+ // 대응. obtainCarrier / pickAndPlace modeler 에서 발동하면 *RackGrid 자식
1479
+ // 추가 + state.data 변경* = 모델 영구 변형. 매 cycle 검사 안전.
1480
+ if (!(this as any).app?.isViewMode) {
1481
+ this._simAbort = true
1482
+ return
1483
+ }
1484
+ this.findAdjacentSlots() // cache 채우기 (already-cached 면 no-op)
1485
+ const byHolder = this._adjacentByHolder
1486
+ if (!byHolder || byHolder.size === 0) {
1487
+ await new Promise(r => setTimeout(r, 800 + Math.random() * 800))
1488
+ return
1489
+ }
939
1490
 
940
- // carriagePosition valid range rail [cw/2, W - cw/2].
941
- // crane 본체 (rail) 움직임 *carriage assembly 만* X 슬라이드.
942
- const minPos = cw / 2
943
- const maxPos = Math.max(minPos, W - cw / 2)
944
- const pickPos = () => minPos + Math.random() * (maxPos - minPos)
945
-
946
- const sourcePos = pickPos()
947
- const destPos = pickPos()
948
-
949
- const sourceCH = Math.random() * D * 0.75
950
- const destCH = Math.random() * D * 0.75
951
- const liftH = Math.max(20, D * 0.02)
952
- // 양쪽 rack 모두 서비스 — source/dest 각각 ±Z random (cycle 별 다름).
953
- const sideA = Math.random() < 0.5 ? -1 : +1
954
- const sideB = Math.random() < 0.5 ? -1 : +1
955
-
956
- // Tween duration 은 base 의 70~130% 사이 random — 여러 crane 이 같은 타이밍으로 안 보이도록.
957
- const jitter = (base: number): number => base * (0.7 + Math.random() * 0.6)
958
-
959
- // 이동: carriagePosition 만 변경 (crane.left/top 안 건드림).
960
- await this._tween({ status: 'moving', carriagePosition: sourcePos, carriageHeight: sourceCH }, jitter(1500))
961
- await this._tween({ status: 'loading', forkExtension: sideA * forkLen }, jitter(700))
962
- await this._tween({ forkLiftRT: liftH }, jitter(400))
963
- await this._tween({ forkExtension: 0 }, jitter(700))
964
- await this._tween({ status: 'moving', carriagePosition: destPos, carriageHeight: destCH }, jitter(1500))
965
- await this._tween({ status: 'unloading', forkExtension: sideB * forkLen }, jitter(700))
966
- await this._tween({ forkLiftRT: 0 }, jitter(400))
967
- await this._tween({ status: 'idle', forkExtension: 0 }, jitter(700))
968
-
969
- // 사이클 사이 짧은 idle (200~1000ms) — 자연스러운 phase 분산
970
- await new Promise(r => setTimeout(r, 200 + Math.random() * 800))
1491
+ // holder 단위로 *occupiedSlotIds / emptySlotIds* 번에 조회 + adjacent set
1492
+ // 교집합. holder 내부 sweep (records + child) 이라 *slot.length × records.
1493
+ // length* iteration 없이 *records + children* 1 회 sweep.
1494
+ // holder.occupiedSlotIds / emptySlotIds *adjacent set 멤버 predicate* 전달
1495
+ // holder sweep 즉시 reject. crane 측은 *holder 결과 그대로 사용*.
1496
+ const occupied: { holder: SlottedHolder & { state?: any }; slotId: string }[] = []
1497
+ const empty: { holder: SlottedHolder & { state?: any }; slotId: string }[] = []
1498
+ for (const [holder, adjSet] of byHolder) {
1499
+ const inAdj = (id: string) => adjSet.has(id)
1500
+ for (const id of holder.occupiedSlotIds(inAdj)) occupied.push({ holder, slotId: id })
1501
+ for (const id of holder.emptySlotIds(inAdj)) empty.push({ holder, slotId: id })
1502
+ }
1503
+
1504
+ if (occupied.length === 0 || empty.length === 0) {
1505
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 800))
1506
+ return
1507
+ }
1508
+
1509
+ const source = occupied[Math.floor(Math.random() * occupied.length)]
1510
+
1511
+ // dest 선택 source 와 같은 (holder, slotId) 회피. attempts 제한.
1512
+ let dest = empty[Math.floor(Math.random() * empty.length)]
1513
+ let attempts = 0
1514
+ while (dest.holder === source.holder && dest.slotId === source.slotId && attempts < 10) {
1515
+ dest = empty[Math.floor(Math.random() * empty.length)]
1516
+ attempts++
1517
+ }
1518
+ if (dest.holder === source.holder && dest.slotId === source.slotId) {
1519
+ await new Promise(r => setTimeout(r, 500))
1520
+ return
1521
+ }
1522
+
1523
+ try {
1524
+ const carrier = source.holder.obtainCarrier(source.slotId)
1525
+ if (!carrier) {
1526
+ await new Promise(r => setTimeout(r, 400))
1527
+ return
1528
+ }
1529
+ const destTarget = dest.holder.slotTargetAt(dest.slotId)
1530
+ await this.pickAndPlace(carrier, destTarget as unknown as Component)
1531
+ } catch (e) {
1532
+ // 한 cycle 실패해도 simulate 루프는 계속. 다음 cycle 에서 다른 source/dest 시도.
1533
+ // (Mover.pickAndPlace 내부 rollback 이 carrier 복귀 시도함.)
1534
+ }
1535
+
1536
+ // 사이클 사이 idle — 여러 crane 의 phase 자연 분산 (= 동시 동기화 회귀 차단).
1537
+ // 범위 확장 (500~3500ms) — duration 거리 비례와 결합되어 cycle 총 시간 다양.
1538
+ await new Promise(r => setTimeout(r, 500 + Math.random() * 3000))
971
1539
  }
972
1540
 
973
1541
  /**