@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.
- package/CHANGELOG.md +37 -0
- package/dist/box-3d.d.ts +2 -0
- package/dist/box-3d.js +103 -64
- package/dist/box-3d.js.map +1 -1
- package/dist/crane-3d.d.ts +10 -0
- package/dist/crane-3d.js +34 -5
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +136 -6
- package/dist/crane.js +567 -46
- package/dist/crane.js.map +1 -1
- package/dist/pallet-3d.d.ts +2 -0
- package/dist/pallet-3d.js +103 -53
- package/dist/pallet-3d.js.map +1 -1
- package/dist/parcel-3d.d.ts +1 -0
- package/dist/parcel-3d.js +18 -1
- package/dist/parcel-3d.js.map +1 -1
- package/dist/rack-grid-3d.js +26 -8
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid.d.ts +94 -10
- package/dist/rack-grid.js +468 -86
- package/dist/rack-grid.js.map +1 -1
- package/dist/storage-rack-3d.js +1 -1
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +31 -6
- package/dist/storage-rack.js +96 -14
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box-3d.ts +121 -68
- package/src/crane-3d.ts +34 -4
- package/src/crane.ts +615 -55
- package/src/pallet-3d.ts +122 -55
- package/src/parcel-3d.ts +19 -1
- package/src/rack-grid-3d.ts +31 -8
- package/src/rack-grid.ts +488 -82
- package/src/storage-rack-3d.ts +1 -1
- package/src/storage-rack.ts +96 -14
- package/test/test-coord-alignment.ts +2 -2
- package/test/test-crane-bay-match.ts +130 -0
- package/test/test-crane-binding-resolve.ts +168 -0
- package/test/test-crane-duration.ts +90 -0
- package/test/test-crane-rotation-reach.ts +218 -0
- package/test/test-rack-grid-3d-alignment.ts +235 -0
- package/test/test-rack-grid-3d-attach-real.ts +375 -0
- package/test/test-rack-grid-cell.ts +2 -2
- package/test/test-rack-grid-location.ts +2 -2
- package/test/test-rack-grid-occupied-slots.ts +165 -0
- package/test/test-rack-grid-picking-position.ts +154 -0
- package/test/test-rack-grid-slot-api.ts +483 -0
- package/test/test-slot-ids-enumeration.ts +137 -0
- 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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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',
|
|
@@ -381,13 +433,36 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
381
433
|
: craneCenterLocal
|
|
382
434
|
const ccx = craneCenterAbs.x
|
|
383
435
|
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
436
|
|
|
388
437
|
const dx = tcx - ccx
|
|
389
438
|
const dy = tcy - ccy
|
|
390
|
-
|
|
439
|
+
|
|
440
|
+
// Rail axis 결정 — *2D state.rotation 무관*. 실제 3D scene 안의 crane mesh 가
|
|
441
|
+
// *어떤 방향* 에 놓였든 *그 visual rail 방향* 따라 carriage 운동. crane.object3d
|
|
442
|
+
// 의 matrixWorld 에서 local X axis (= rail mesh 의 long axis = BoxGeometry(width,..)
|
|
443
|
+
// 의 X 방향) 의 world 방향 벡터 추출. 2D rotation→3D 매핑 컨벤션 (.rotation.y vs
|
|
444
|
+
// rotation.z, 부호) 영향 0 — 실 visual 방향 그대로 사용.
|
|
445
|
+
const ro: any = this._realObject
|
|
446
|
+
const obj3d: THREE.Object3D | undefined = ro?.object3d
|
|
447
|
+
let railLocalX: number
|
|
448
|
+
if (obj3d) {
|
|
449
|
+
obj3d.updateMatrixWorld(true)
|
|
450
|
+
const m = obj3d.matrixWorld.elements
|
|
451
|
+
// 3D world frame: local X axis = (m[0], m[1], m[2]). 2D Y == 3D Z 매핑.
|
|
452
|
+
// 사용자 (dx, dy) = 2D scene 좌표 차이 = 3D (X, Z) 차이.
|
|
453
|
+
const axisX = m[0]
|
|
454
|
+
const axisZ = m[2]
|
|
455
|
+
const len = Math.hypot(axisX, axisZ)
|
|
456
|
+
if (len > 1e-9) {
|
|
457
|
+
const ux = axisX / len
|
|
458
|
+
const uz = axisZ / len
|
|
459
|
+
railLocalX = dx * ux + dy * uz
|
|
460
|
+
} else {
|
|
461
|
+
railLocalX = dx
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
railLocalX = dx
|
|
465
|
+
}
|
|
391
466
|
|
|
392
467
|
const cw = (this.state.carriageWidth as number) ?? W * 0.1
|
|
393
468
|
const minPos = cw / 2
|
|
@@ -416,9 +491,38 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
416
491
|
} else {
|
|
417
492
|
tween.carriageHeight = approachWorldY
|
|
418
493
|
}
|
|
494
|
+
// Mast 높이 한계 (= crane.state.depth) 초과 시 carriageHeight 가 *clamp* 되어
|
|
495
|
+
// fork 가 *상한까지만* 도달 → 더 높은 cell 은 *한 층 아래에서 포킹*. 데이터
|
|
496
|
+
// 결함 (rack 보다 짧은 crane) 을 silent 무시 안 하도록 명시 경고.
|
|
497
|
+
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)
|
|
498
|
+
if (typeof tween.carriageHeight === 'number' && tween.carriageHeight > mastMax) {
|
|
499
|
+
console.warn(
|
|
500
|
+
`[crane] carriageHeight=${tween.carriageHeight.toFixed(1)} > mast max=${mastMax.toFixed(1)} (state.depth)` +
|
|
501
|
+
` — fork 가 mast 상한에서 멈춰 target 한 층 아래에서 포킹 가능. crane.state.depth 증가 필요.`
|
|
502
|
+
)
|
|
503
|
+
}
|
|
419
504
|
}
|
|
420
505
|
|
|
421
|
-
|
|
506
|
+
// Duration = *carriage 실 운동 거리 / speed*. 한 cycle 이 이동 거리 비례 시간.
|
|
507
|
+
// - X 운동 거리 = |newCarriagePos - 현재 carriagePosition|
|
|
508
|
+
// - Y 운동 거리 = |tween.carriageHeight - 현재 carriageHeight| (carrier 있는 경우)
|
|
509
|
+
// - 두 운동은 *동시 lerp* → max 거리 만큼 시간 소요.
|
|
510
|
+
// options.duration 명시 시 그 값 우선. 미명시 시 state.speed 또는 default.
|
|
511
|
+
let duration: number
|
|
512
|
+
if (typeof options.duration === 'number') {
|
|
513
|
+
duration = options.duration
|
|
514
|
+
} else {
|
|
515
|
+
const curCP = numOr((this.state as any).carriagePosition, this._canonicalDefault('carriagePosition'))
|
|
516
|
+
const curCH = numOr((this.state as any).carriageHeight, this._canonicalDefault('carriageHeight'))
|
|
517
|
+
const dxRail = Math.abs((tween.carriagePosition as number) - curCP)
|
|
518
|
+
const dyMast = typeof tween.carriageHeight === 'number' ? Math.abs(tween.carriageHeight - curCH) : 0
|
|
519
|
+
const dist = Math.max(dxRail, dyMast)
|
|
520
|
+
const speed =
|
|
521
|
+
(typeof options.speed === 'number' ? options.speed : undefined) ??
|
|
522
|
+
(typeof (this.state as any).speed === 'number' ? (this.state as any).speed : undefined) ??
|
|
523
|
+
250 // DEFAULT_SPEED — scene units/sec (이전 500 → 사용자 체감 빠름 → 절반)
|
|
524
|
+
duration = speed > 0 ? Math.max(200, (dist / speed) * 1000) : 1500
|
|
525
|
+
}
|
|
422
526
|
this.setState({ status: 'moving' as CraneStatus })
|
|
423
527
|
await this._tween(tween, duration)
|
|
424
528
|
this.setState({ status: 'idle' as CraneStatus })
|
|
@@ -542,6 +646,315 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
542
646
|
return this.place(carrier, cell, options)
|
|
543
647
|
}
|
|
544
648
|
|
|
649
|
+
// ── Capability-based adjacency discovery ──────────────────────────────────
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* scene 의 모든 SlottedHolder 의 *slot 중에서 crane 의 작업 reach 안에 들어
|
|
653
|
+
* 오는 slot 들* 을 반환. 자동 simulate / WCS-less 시나리오의 enumeration entry.
|
|
654
|
+
*
|
|
655
|
+
* 매칭 알고리즘 (공간 reach 박스):
|
|
656
|
+
* - crane.world center (axis-aligned 가정 — rotation 무시)
|
|
657
|
+
* - 각 slot.anchor.world position 이
|
|
658
|
+
* |x - cx| ≤ width/2 + reachXZ AND |z - cz| ≤ height/2 + reachXZ
|
|
659
|
+
* AND |y - cy| ≤ depth/2 + reachY
|
|
660
|
+
* 범위 안인가
|
|
661
|
+
* - reachXZ = `crane.state.height` (fork forward 폭). reachY = `crane.state.depth`/2.
|
|
662
|
+
*
|
|
663
|
+
* RackGrid 의 *isEmpty aisle 통로 자리에 crane 이 배치* 된 케이스도 자동 cover —
|
|
664
|
+
* 통로 양옆 bay 의 slot anchor world 가 reach 안에 자연히 들어옴.
|
|
665
|
+
*
|
|
666
|
+
* @param opts.includeSelf 자기 자신을 holder 로 포함할지 (default false — crane
|
|
667
|
+
* 은 SlottedHolder 아니지만 안전 차원).
|
|
668
|
+
*/
|
|
669
|
+
/**
|
|
670
|
+
* Cycle 단위 cache 없음 — **매 cycle 협의** (capability negotiation 의 본질).
|
|
671
|
+
* crane.carriagePosition 변경 / rack 추가-이동 / 동적 forkLength 등 *runtime
|
|
672
|
+
* 변화 대응*. cost 는 *slotWorldPosition (anchor 0 생성) + canReach (cheap
|
|
673
|
+
* 좌표 비교)* — 매 cycle 호출 부담 작음.
|
|
674
|
+
*
|
|
675
|
+
* 이 field 는 *_oneCycle 사용을 위한 *호출 당 임시 holder grouping** —
|
|
676
|
+
* findAdjacentSlots 결과의 byHolder mapping. caller (_oneCycle) 가 사용 후
|
|
677
|
+
* 폐기.
|
|
678
|
+
*/
|
|
679
|
+
private _adjacentByHolder?: Map<SlottedHolder & { state?: any }, Set<string>>
|
|
680
|
+
|
|
681
|
+
/** Cache 없음 — invalidate 호출도 no-op (호환 차원 keep). */
|
|
682
|
+
invalidateAdjacentSlots(): void {
|
|
683
|
+
this._adjacentByHolder = undefined
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* 자동 detect 결과 cache — *boundHolders 미명시 시* 사용. crane / rack 위치가
|
|
688
|
+
* 정적이라 *1회 detect 후 영구 cache 안전*. 위치/회전/차원 변경 시 invalidate.
|
|
689
|
+
*/
|
|
690
|
+
private _autoBoundCache?: Array<SlottedHolder & { state?: any }>
|
|
691
|
+
|
|
692
|
+
/** Auto bound cache invalidate — crane state 변경 시 호출. */
|
|
693
|
+
invalidateBoundCache(): void {
|
|
694
|
+
this._autoBoundCache = undefined
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Scene traverse + reach 검사 — 어느 slot 1개라도 reach 안인 holder 들 list.
|
|
699
|
+
* boundHolders 미명시 시 *runtime auto-detect* 결과. crane/rack 정적 위치
|
|
700
|
+
* 가정 — 1회 호출 후 cache.
|
|
701
|
+
*/
|
|
702
|
+
detectBoundHolders(): Array<SlottedHolder & { state?: any }> {
|
|
703
|
+
const ro = this._realObject as any
|
|
704
|
+
const obj3d: THREE.Object3D | undefined = ro?.object3d
|
|
705
|
+
if (!obj3d) return []
|
|
706
|
+
const craneWorld = new THREE.Vector3()
|
|
707
|
+
obj3d.getWorldPosition(craneWorld)
|
|
708
|
+
|
|
709
|
+
const s: any = this.state
|
|
710
|
+
const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000
|
|
711
|
+
const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600
|
|
712
|
+
const forkLen: number = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
|
|
713
|
+
? s.forkLength : craneH * 0.6
|
|
714
|
+
const xHalf = craneW / 2
|
|
715
|
+
const zHalf = forkLen
|
|
716
|
+
|
|
717
|
+
const root: any = (this as any).root
|
|
718
|
+
if (!root) return []
|
|
719
|
+
const result: Array<SlottedHolder & { state?: any }> = []
|
|
720
|
+
const seen = new Set<any>()
|
|
721
|
+
const visit = (component: any) => {
|
|
722
|
+
if (!component || seen.has(component)) return
|
|
723
|
+
seen.add(component)
|
|
724
|
+
if (component !== this && isSlottedHolder(component)) {
|
|
725
|
+
const holder = component as SlottedHolder & { state?: any }
|
|
726
|
+
const slots = holder.slotIds?.()
|
|
727
|
+
if (slots && slots.length > 0) {
|
|
728
|
+
const getPos = (holder as any).slotWorldPosition?.bind(holder) as
|
|
729
|
+
((id: string) => { x: number; y: number; z: number } | undefined) | undefined
|
|
730
|
+
// 랙 자동 인식 — *fork 가 닿는 Z 거리* 만 검사. X 는 무관 — 사용자 의도:
|
|
731
|
+
// 크레인 옆 (포크가 닿는 거리) 에 있는 랙은 *X 방향 어디에 있든* 담당.
|
|
732
|
+
// rail 의 실 X 길이 = 그 랙의 X 길이 (자동 정렬 모델링 가정).
|
|
733
|
+
let matched = false
|
|
734
|
+
for (const slotId of slots) {
|
|
735
|
+
let pos: { x: number; y: number; z: number } | undefined
|
|
736
|
+
if (getPos) {
|
|
737
|
+
pos = getPos(slotId)
|
|
738
|
+
} else {
|
|
739
|
+
const anchor = holder.getSlotAttachObject3d(slotId)
|
|
740
|
+
if (anchor) {
|
|
741
|
+
const w = new THREE.Vector3()
|
|
742
|
+
anchor.getWorldPosition(w)
|
|
743
|
+
pos = { x: w.x, y: w.y, z: w.z }
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (!pos) continue
|
|
747
|
+
if (Math.abs(pos.z - craneWorld.z) <= zHalf) {
|
|
748
|
+
matched = true
|
|
749
|
+
break
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (matched) result.push(holder)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const children = (component as any).components
|
|
756
|
+
if (Array.isArray(children)) for (const c of children) visit(c)
|
|
757
|
+
}
|
|
758
|
+
visit(root)
|
|
759
|
+
return result
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Bound holder instance resolve.
|
|
764
|
+
* 1. boundHolders state 명시 → 그 id list resolve (explicit binding)
|
|
765
|
+
* 2. 미명시 → detectBoundHolders() 1회 + auto-cache (implicit binding,
|
|
766
|
+
* crane / rack 정적 위치 가정)
|
|
767
|
+
*
|
|
768
|
+
* 둘 다 *binding scope* — findAdjacentSlots 가 그 list 만 traverse.
|
|
769
|
+
*/
|
|
770
|
+
boundHolderInstances(): Array<SlottedHolder & { state?: any }> {
|
|
771
|
+
const raw = (this.state as any)?.boundHolders
|
|
772
|
+
let ids: string[] = []
|
|
773
|
+
if (typeof raw === 'string') {
|
|
774
|
+
ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
775
|
+
} else if (Array.isArray(raw)) {
|
|
776
|
+
ids = raw.filter((x: any) => typeof x === 'string' && x.length > 0)
|
|
777
|
+
}
|
|
778
|
+
if (ids.length > 0) {
|
|
779
|
+
// 명시 binding — explicit list resolve
|
|
780
|
+
const root: any = (this as any).root
|
|
781
|
+
if (!root) return []
|
|
782
|
+
const result: Array<SlottedHolder & { state?: any }> = []
|
|
783
|
+
for (const id of ids) {
|
|
784
|
+
const c = typeof root.findById === 'function' ? root.findById(id) : undefined
|
|
785
|
+
if (c && isSlottedHolder(c)) result.push(c as any)
|
|
786
|
+
}
|
|
787
|
+
return result
|
|
788
|
+
}
|
|
789
|
+
// 미명시 — auto-detect cache
|
|
790
|
+
if (this._autoBoundCache === undefined) {
|
|
791
|
+
this._autoBoundCache = this.detectBoundHolders()
|
|
792
|
+
}
|
|
793
|
+
return this._autoBoundCache
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
findAdjacentSlots(opts: { includeSelf?: boolean } = {}): AdjacentSlot[] {
|
|
797
|
+
const ro = this._realObject as any
|
|
798
|
+
const obj3d: THREE.Object3D | undefined = ro?.object3d
|
|
799
|
+
if (!obj3d) return []
|
|
800
|
+
|
|
801
|
+
// crane 은 고정 rail 따라 움직임 (회전 없음) — axis-aligned 박스 비교.
|
|
802
|
+
const craneWorld = new THREE.Vector3()
|
|
803
|
+
obj3d.getWorldPosition(craneWorld)
|
|
804
|
+
|
|
805
|
+
const s: any = this.state
|
|
806
|
+
const craneW = typeof s?.width === 'number' && Number.isFinite(s.width) ? s.width : 1000
|
|
807
|
+
const craneH = typeof s?.height === 'number' && Number.isFinite(s.height) ? s.height : 600
|
|
808
|
+
const forkLen: number = typeof s?.forkLength === 'number' && Number.isFinite(s.forkLength)
|
|
809
|
+
? s.forkLength : craneH * 0.6
|
|
810
|
+
|
|
811
|
+
// X reach 정책:
|
|
812
|
+
// - bound holder 명시/auto-detect 시 → *X 무관*. bound 자체가 scope 정의 —
|
|
813
|
+
// bound rack 의 *X 영역 전체* 가 rail 의 실 X 영역. carriage 가 그 X 안
|
|
814
|
+
// 의 모든 bay 까지 자동 도달.
|
|
815
|
+
// - 미bound 시 → state.width 기반 axis-aligned (fallback).
|
|
816
|
+
const boundCandidates = this.boundHolderInstances()
|
|
817
|
+
|
|
818
|
+
// slotId format = "{col-or-bay}-{row}-{shelf-or-level}" (양쪽 holder 동일).
|
|
819
|
+
// 매칭 단위 = (col, row) pair = *last '-' 앞 부분*. row 별로 Z 좌표 다르고
|
|
820
|
+
// crane.fork 의 reach 가 row 마다 도달 여부 다름 → *(col, row) 단위 정밀
|
|
821
|
+
// 매칭*. 통과한 (col, row) 의 *모든 shelves slot* 만 adjacent.
|
|
822
|
+
const bayKeyOf = (slotId: string): string => {
|
|
823
|
+
const i = slotId.lastIndexOf('-')
|
|
824
|
+
return i > 0 ? slotId.substring(0, i) : slotId
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Reach 박스 — *crane mesh 의 실 visual 방향* 따라 검사 (2D state.rotation 무관).
|
|
828
|
+
// rail axis (X) = crane.width / 2 — carriage 도달 범위.
|
|
829
|
+
// perpendicular (Z) = forkLength — fork tip extend.
|
|
830
|
+
// matrixWorld 의 local X axis vector 로 projection — Crane.moveTo 와 동일 식.
|
|
831
|
+
const xHalf = craneW / 2
|
|
832
|
+
const zHalf = forkLen
|
|
833
|
+
obj3d.updateMatrixWorld(true)
|
|
834
|
+
const _mc = obj3d.matrixWorld.elements
|
|
835
|
+
const _axisX = _mc[0], _axisZ = _mc[2]
|
|
836
|
+
const _len = Math.hypot(_axisX, _axisZ)
|
|
837
|
+
const ux = _len > 1e-9 ? _axisX / _len : 1
|
|
838
|
+
const uz = _len > 1e-9 ? _axisZ / _len : 0
|
|
839
|
+
// rail-perpendicular axis (= fork 방향) = rail axis 90° rotated (XZ 평면).
|
|
840
|
+
const px = -uz
|
|
841
|
+
const pz = ux
|
|
842
|
+
|
|
843
|
+
const root: any = (this as any).root
|
|
844
|
+
if (!root) return []
|
|
845
|
+
|
|
846
|
+
const result: AdjacentSlot[] = []
|
|
847
|
+
const byHolder = new Map<SlottedHolder & { state?: any }, Set<string>>()
|
|
848
|
+
|
|
849
|
+
// *binding scope* — boundHolders 명시 시 그 holder 만 traverse (broad scan 0).
|
|
850
|
+
// 미명시 시 scene 전체 traverse fallback (BC, 기존 모델 호환).
|
|
851
|
+
const bound = this.boundHolderInstances()
|
|
852
|
+
const candidates: any[] = bound.length > 0 ? bound : []
|
|
853
|
+
|
|
854
|
+
const visit = (component: any) => {
|
|
855
|
+
if (!component) return
|
|
856
|
+
if ((opts.includeSelf || component !== this) && isSlottedHolder(component)) {
|
|
857
|
+
const holder = component as SlottedHolder & { state?: any }
|
|
858
|
+
const ids = holder.slotIds?.()
|
|
859
|
+
if (ids && ids.length > 0) {
|
|
860
|
+
// col 별: 모든 row 의 *대표 (shelf=0) slot* 추출 + col 안 모든 id 목록.
|
|
861
|
+
// bay 매칭 시 *어느 row 의 대표 anchor 라도 reach 안* 이면 그 col 전체
|
|
862
|
+
// 매칭 (row 별 z 가 달라도 *crane 의 fork 가 ±Z extend 로 모두 도달*).
|
|
863
|
+
const repsByBay = new Map<string, string[]>()
|
|
864
|
+
const allByBay = new Map<string, string[]>()
|
|
865
|
+
for (const id of ids) {
|
|
866
|
+
const bay = bayKeyOf(id)
|
|
867
|
+
const lastIdx = id.lastIndexOf('-')
|
|
868
|
+
const lastSeg = lastIdx >= 0 ? id.substring(lastIdx + 1) : id
|
|
869
|
+
let allList = allByBay.get(bay)
|
|
870
|
+
if (!allList) { allList = []; allByBay.set(bay, allList) }
|
|
871
|
+
allList.push(id)
|
|
872
|
+
// shelf=0 (또는 level=0) 인 것만 대표
|
|
873
|
+
if (lastSeg === '0') {
|
|
874
|
+
let reps = repsByBay.get(bay)
|
|
875
|
+
if (!reps) { reps = []; repsByBay.set(bay, reps) }
|
|
876
|
+
reps.push(id)
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// bay 가 *대표 없음* (shelf=0 미존재) — fallback: 첫 id 사용
|
|
880
|
+
for (const [bay, allList] of allByBay) {
|
|
881
|
+
if (!repsByBay.has(bay)) repsByBay.set(bay, [allList[0]])
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const adjSet = new Set<string>()
|
|
885
|
+
// *holder.slotWorldPosition* 지원 시 *anchor 생성 0* — match 전 단계의
|
|
886
|
+
// 직접 좌표 계산 path. 미지원 holder 는 fallback 으로 getSlotAttachObject3d.
|
|
887
|
+
const getPos = (holder as any).slotWorldPosition?.bind(holder) as
|
|
888
|
+
((id: string) => { x: number; y: number; z: number } | undefined) | undefined
|
|
889
|
+
|
|
890
|
+
// X 매칭 정책:
|
|
891
|
+
// - bound holder 존재 시: *X 무관* (bound 자체가 reach scope — rail 의
|
|
892
|
+
// 실 X 영역 = bound rack 의 X 영역. carriage 가 그 전체 X 까지 도달).
|
|
893
|
+
// Z (fork) 만 검사 — fork tip 도달 여부.
|
|
894
|
+
// - 미bound 시: state.width 기반 axis-aligned 박스 (fallback).
|
|
895
|
+
const useBoundScope = boundCandidates.length > 0
|
|
896
|
+
for (const [bay, reps] of repsByBay) {
|
|
897
|
+
let matched = false
|
|
898
|
+
let matchedX = 0, matchedY = 0, matchedZ = 0
|
|
899
|
+
for (const repId of reps) {
|
|
900
|
+
let pos: { x: number; y: number; z: number } | undefined
|
|
901
|
+
if (getPos) {
|
|
902
|
+
pos = getPos(repId)
|
|
903
|
+
} else {
|
|
904
|
+
const anchor = holder.getSlotAttachObject3d(repId)
|
|
905
|
+
if (anchor) {
|
|
906
|
+
const w = new THREE.Vector3()
|
|
907
|
+
anchor.getWorldPosition(w)
|
|
908
|
+
pos = { x: w.x, y: w.y, z: w.z }
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (!pos) continue
|
|
912
|
+
// *visual rail 의 실제 world 방향* 따라 projection — 2D rotation 매핑 무관.
|
|
913
|
+
// rail-local X = (pos - crane) · rail axis. rail-local Z = (pos - crane) · perp axis.
|
|
914
|
+
const ddx = pos.x - craneWorld.x
|
|
915
|
+
const ddz = pos.z - craneWorld.z
|
|
916
|
+
const railLocal = ddx * ux + ddz * uz
|
|
917
|
+
const perpLocal = ddx * px + ddz * pz
|
|
918
|
+
const xOk = Math.abs(railLocal) <= xHalf
|
|
919
|
+
const zOk = Math.abs(perpLocal) <= zHalf
|
|
920
|
+
if (xOk && zOk) {
|
|
921
|
+
matched = true
|
|
922
|
+
matchedX = pos.x; matchedY = pos.y; matchedZ = pos.z
|
|
923
|
+
break
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (matched) {
|
|
927
|
+
const all = allByBay.get(bay) ?? []
|
|
928
|
+
const sharedWorld = new THREE.Vector3(matchedX, matchedY, matchedZ)
|
|
929
|
+
// result 의 anchor field 는 *lazy*. 사용처가 *실제 attach 필요* 시
|
|
930
|
+
// holder.getSlotAttachObject3d(slotId) 별도 호출 (그때 lazy 생성).
|
|
931
|
+
// match 검사 자체에선 anchor 0 생성.
|
|
932
|
+
for (const id of all) {
|
|
933
|
+
adjSet.add(id)
|
|
934
|
+
result.push({ holder, slotId: id, anchor: undefined as any, world: sharedWorld })
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (adjSet.size > 0) byHolder.set(holder, adjSet)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const children = (component as any).components
|
|
942
|
+
if (Array.isArray(children)) {
|
|
943
|
+
for (const c of children) visit(c)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// binding scope 우선 — bound 시 그 list 만 visit (broad scan 0). 미명시
|
|
947
|
+
// 시 scene root 부터 fallback traverse.
|
|
948
|
+
if (candidates.length > 0) {
|
|
949
|
+
for (const c of candidates) visit(c)
|
|
950
|
+
} else {
|
|
951
|
+
visit(root)
|
|
952
|
+
}
|
|
953
|
+
// 매 cycle 협의 — cache 없음. _oneCycle 가 *바로 *byHolder 사용 후 폐기*.
|
|
954
|
+
this._adjacentByHolder = byHolder
|
|
955
|
+
return result
|
|
956
|
+
}
|
|
957
|
+
|
|
545
958
|
// ── 2D rendering ─────────────────────────────────────────────────────────
|
|
546
959
|
|
|
547
960
|
/**
|
|
@@ -735,10 +1148,11 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
735
1148
|
}
|
|
736
1149
|
if (Object.keys(init).length > 0) this.setState(init)
|
|
737
1150
|
|
|
738
|
-
// state.simulate === true
|
|
739
|
-
//
|
|
740
|
-
//
|
|
1151
|
+
// state.simulate === true + *view mode* 모두 만족 시만 자동 시작. modeler
|
|
1152
|
+
// 모드에서 simulate 진행 시 *_oneCycle 의 obtainCarrier 가 RackGrid 자식 추가
|
|
1153
|
+
// + state.data 변경* — 모델 영구 변형 위험. mode 검사로 차단.
|
|
741
1154
|
if ((this.state as CraneState).simulate !== true) return
|
|
1155
|
+
if (!(this as any).app?.isViewMode) return
|
|
742
1156
|
this._startAutoSimulate()
|
|
743
1157
|
}
|
|
744
1158
|
|
|
@@ -770,6 +1184,9 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
770
1184
|
this._simStarted = true
|
|
771
1185
|
// 초기 지연 800~2800ms 사이 random — 여러 crane 이 동기적으로 시작하지 않도록.
|
|
772
1186
|
const initialDelay = 800 + Math.random() * 2000
|
|
1187
|
+
// prefetch 제거 — 너무 일찍 (rack 의 _realObject 미빌드) 호출 시 0 매칭이
|
|
1188
|
+
// cache 됐던 회귀. 0 결과 cache 안 함으로 fix됐지만, prefetch 자체도 불필요
|
|
1189
|
+
// (_oneCycle 첫 호출에 자동 cache).
|
|
773
1190
|
setTimeout(() => {
|
|
774
1191
|
if (this._simAbort || (this.state as CraneState).simulate !== true) return
|
|
775
1192
|
this.simulate().catch(e => console.error('[Crane] simulate', e))
|
|
@@ -794,14 +1211,32 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
794
1211
|
) {
|
|
795
1212
|
;(this as any).invalidate?.()
|
|
796
1213
|
}
|
|
1214
|
+
// crane 또는 rack 의 위치/회전/차원 변경 시 adjacent slot cache + bound
|
|
1215
|
+
// holder cache 둘 다 invalidate. boundHolders 명시 변경 시도 bound cache
|
|
1216
|
+
// 무관 (명시 우선) — but invalidate 안전.
|
|
1217
|
+
if (
|
|
1218
|
+
'left' in after ||
|
|
1219
|
+
'top' in after ||
|
|
1220
|
+
'rotation' in after ||
|
|
1221
|
+
'width' in after ||
|
|
1222
|
+
'height' in after ||
|
|
1223
|
+
'depth' in after ||
|
|
1224
|
+
'forkLength' in after ||
|
|
1225
|
+
'boundHolders' in after
|
|
1226
|
+
) {
|
|
1227
|
+
this.invalidateAdjacentSlots()
|
|
1228
|
+
this.invalidateBoundCache()
|
|
1229
|
+
}
|
|
797
1230
|
if ('simulate' in after) {
|
|
798
1231
|
if (after.simulate === true) {
|
|
799
|
-
// 명시
|
|
800
|
-
//
|
|
801
|
-
//
|
|
1232
|
+
// simulate=true 명시 — *view mode 일 때만 자동 시작*. modeler 모드 시
|
|
1233
|
+
// 시작 시 _oneCycle 의 obtainCarrier 가 RackGrid 자식/state.data 변형
|
|
1234
|
+
// 위험. _oneCycle 도 매번 isViewMode 재검사 (mode 전환 대응).
|
|
802
1235
|
this._simAbort = false
|
|
803
1236
|
this._simStarted = false
|
|
804
|
-
this.
|
|
1237
|
+
if ((this as any).app?.isViewMode) {
|
|
1238
|
+
this._startAutoSimulate()
|
|
1239
|
+
}
|
|
805
1240
|
} else {
|
|
806
1241
|
this._simAbort = true
|
|
807
1242
|
}
|
|
@@ -829,7 +1264,16 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
829
1264
|
private _railMax = NaN // local-X 축 rail offset max
|
|
830
1265
|
private _targetSide = 1 // +Z (fork +Z 로 뻗어야 하나) / -Z
|
|
831
1266
|
|
|
832
|
-
/**
|
|
1267
|
+
/**
|
|
1268
|
+
* Main loop — 통합 처리.
|
|
1269
|
+
*
|
|
1270
|
+
* 1. dispatcher 의 명시 작업 take → execute (priority 큰 작업 우선)
|
|
1271
|
+
* 2. dispatcher 작업 없음 + state.simulate=true → 기존 random fallback (_oneCycle)
|
|
1272
|
+
* 3. 둘 다 없음 → longer idle (dispatcher 작업 enqueue 대기)
|
|
1273
|
+
*
|
|
1274
|
+
* Phase Z 통합 — 사용자 application 의 `holder.putaway / picking` 호출이 즉시 자연
|
|
1275
|
+
* 처리. simulate=true 시 큐 비면 random visual smoke 도 유지. 작업 사이 자연 idle.
|
|
1276
|
+
*/
|
|
833
1277
|
async simulate(): Promise<void> {
|
|
834
1278
|
if (this._simRunning) return
|
|
835
1279
|
this._simRunning = true
|
|
@@ -837,13 +1281,92 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
837
1281
|
try {
|
|
838
1282
|
this._initRailRange()
|
|
839
1283
|
while (!this._simAbort) {
|
|
840
|
-
|
|
1284
|
+
// Modeling 모드 진입 시 즉시 중단
|
|
1285
|
+
if (!(this as any).app?.isViewMode) {
|
|
1286
|
+
this._simAbort = true
|
|
1287
|
+
break
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// 1. Dispatcher 의 명시 작업 우선 take
|
|
1291
|
+
const dispatcher = findDispatcher(this as any)
|
|
1292
|
+
const ticket = dispatcher?.takeNextFor(this as any) ?? null
|
|
1293
|
+
if (ticket) {
|
|
1294
|
+
await this._executeTicket(ticket, dispatcher!)
|
|
1295
|
+
// 작업 사이 짧은 idle — 명시 작업 중심 처리 → 다음 작업 빠르게
|
|
1296
|
+
await new Promise(r => setTimeout(r, 200 + Math.random() * 600))
|
|
1297
|
+
continue
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// 2. Dispatcher 작업 없음 — state.simulate 켜져 있으면 random fallback
|
|
1301
|
+
if ((this.state as CraneState).simulate === true) {
|
|
1302
|
+
await this._oneCycle()
|
|
1303
|
+
continue
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// 3. simulate OFF + 큐 비어있음 — 작업 들어오기 기다림. 긴 idle.
|
|
1307
|
+
await new Promise(r => setTimeout(r, 500 + Math.random() * 2500))
|
|
841
1308
|
}
|
|
842
1309
|
} finally {
|
|
843
1310
|
this._simRunning = false
|
|
844
1311
|
}
|
|
845
1312
|
}
|
|
846
1313
|
|
|
1314
|
+
/**
|
|
1315
|
+
* Dispatcher 가 할당한 ticket 처리. lifecycle:
|
|
1316
|
+
* assigned (이미 takeNextFor 가 set) → in_progress → completed / failed
|
|
1317
|
+
*
|
|
1318
|
+
* 흐름:
|
|
1319
|
+
* 1. request.from / to 를 resolve (string id → SlotTarget, SlotTarget 그대로 등)
|
|
1320
|
+
* 2. carrier obtain — request.carrier 명시 시 그것, 미명시 시 from.holder.obtainCarrier
|
|
1321
|
+
* 3. pickAndPlace(carrier, dest)
|
|
1322
|
+
* 4. ticket status notify
|
|
1323
|
+
*
|
|
1324
|
+
* 실패 처리:
|
|
1325
|
+
* - target resolve 실패 → failed (resolve-target)
|
|
1326
|
+
* - carrier obtain 실패 → failed (no-carrier)
|
|
1327
|
+
* - pickAndPlace throw → failed (그 error)
|
|
1328
|
+
*/
|
|
1329
|
+
private async _executeTicket(ticket: TransferTicket, dispatcher: Dispatcher): Promise<void> {
|
|
1330
|
+
const req = ticket.request
|
|
1331
|
+
try {
|
|
1332
|
+
dispatcher.notifyInProgress?.(ticket as any) ?? (ticket as any)._setStatus('in_progress')
|
|
1333
|
+
|
|
1334
|
+
// resolve source / dest
|
|
1335
|
+
const source = resolveTransferTarget(req.from, dispatcher)
|
|
1336
|
+
const dest = resolveTransferTarget(req.to, dispatcher)
|
|
1337
|
+
if (!source || !dest) {
|
|
1338
|
+
const err = new Error('resolve-target')
|
|
1339
|
+
dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
|
|
1340
|
+
return
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// carrier obtain — 명시 carrier 우선
|
|
1344
|
+
let carrier: Component | null = req.carrier ?? null
|
|
1345
|
+
if (!carrier) {
|
|
1346
|
+
// source 가 SlotTarget — holder.obtainCarrier(slotId)
|
|
1347
|
+
const holder = (source as any).holder as SlottedHolder | undefined
|
|
1348
|
+
const slotId = (source as any).slotId as string | undefined
|
|
1349
|
+
if (holder && slotId) {
|
|
1350
|
+
carrier = holder.obtainCarrier(slotId) ?? null
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (!carrier) {
|
|
1354
|
+
const err = new Error('no-carrier')
|
|
1355
|
+
dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// pickAndPlace
|
|
1360
|
+
await this.pickAndPlace(carrier, dest as Component)
|
|
1361
|
+
|
|
1362
|
+
// 성공
|
|
1363
|
+
dispatcher.notifyCompleted?.(ticket as any) ?? (ticket as any)._setStatus('completed')
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
const err = e instanceof Error ? e : new Error(String(e))
|
|
1366
|
+
dispatcher.notifyFailed?.(ticket as any, err) ?? (ticket as any)._setStatus('failed', { error: err })
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
847
1370
|
/**
|
|
848
1371
|
* state.target bbox 를 crane 의 *로컬 X 축* 으로 projection 해서 rail range 1회 계산.
|
|
849
1372
|
* Rotation 적용된 crane 의 local X (= rail) 방향으로 움직이도록 cos/sin 캐시.
|
|
@@ -930,44 +1453,81 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
|
|
|
930
1453
|
super.dispose?.()
|
|
931
1454
|
}
|
|
932
1455
|
|
|
1456
|
+
/**
|
|
1457
|
+
* 1 cycle = *capability 기반 random transfer*.
|
|
1458
|
+
*
|
|
1459
|
+
* 1. findAdjacentSlots() — scene 의 인접 SlottedHolder 의 slot 들
|
|
1460
|
+
* 2. occupied / empty 분리 — hasCarrierAt / canReceiveAt
|
|
1461
|
+
* 3. random source / dest 선택 — 같은 slot 자가 transfer 회피
|
|
1462
|
+
* 4. obtainCarrier + pickAndPlace — Mover.pickAndPlace 가 pick + place 진행
|
|
1463
|
+
*
|
|
1464
|
+
* 인접 slot / occupied / empty 어느 하나라도 비어있으면 짧은 delay 후 next.
|
|
1465
|
+
* 진짜 carrier 가 fork 위 visible 로 이동 — 시각 simulate 가 *동시에 *진짜
|
|
1466
|
+
* 데이타 전환* 도 수행 (Plan A: 데이타 → 실물 → 데이타 atomic 전환).
|
|
1467
|
+
*/
|
|
933
1468
|
private async _oneCycle(): Promise<void> {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1469
|
+
// *Modeling 모드 진입 시 즉시 정지* — runtime 에서 mode 전환 (view → modeler)
|
|
1470
|
+
// 대응. obtainCarrier / pickAndPlace 가 modeler 에서 발동하면 *RackGrid 자식
|
|
1471
|
+
// 추가 + state.data 변경* = 모델 영구 변형. 매 cycle 검사 안전.
|
|
1472
|
+
if (!(this as any).app?.isViewMode) {
|
|
1473
|
+
this._simAbort = true
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
this.findAdjacentSlots() // cache 채우기 (already-cached 면 no-op)
|
|
1477
|
+
const byHolder = this._adjacentByHolder
|
|
1478
|
+
if (!byHolder || byHolder.size === 0) {
|
|
1479
|
+
await new Promise(r => setTimeout(r, 800 + Math.random() * 800))
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
939
1482
|
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1483
|
+
// holder 단위로 *occupiedSlotIds / emptySlotIds* 한 번에 조회 + adjacent set
|
|
1484
|
+
// 와 교집합. holder 내부 sweep (records + child) 이라 *slot.length × records.
|
|
1485
|
+
// length* iteration 없이 *records + children* 1 회 sweep.
|
|
1486
|
+
// holder.occupiedSlotIds / emptySlotIds 에 *adjacent set 멤버 predicate* 전달
|
|
1487
|
+
// — holder 가 sweep 중 즉시 reject. crane 측은 *holder 결과 그대로 사용*.
|
|
1488
|
+
const occupied: { holder: SlottedHolder & { state?: any }; slotId: string }[] = []
|
|
1489
|
+
const empty: { holder: SlottedHolder & { state?: any }; slotId: string }[] = []
|
|
1490
|
+
for (const [holder, adjSet] of byHolder) {
|
|
1491
|
+
const inAdj = (id: string) => adjSet.has(id)
|
|
1492
|
+
for (const id of holder.occupiedSlotIds(inAdj)) occupied.push({ holder, slotId: id })
|
|
1493
|
+
for (const id of holder.emptySlotIds(inAdj)) empty.push({ holder, slotId: id })
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
if (occupied.length === 0 || empty.length === 0) {
|
|
1497
|
+
await new Promise(r => setTimeout(r, 500 + Math.random() * 800))
|
|
1498
|
+
return
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
const source = occupied[Math.floor(Math.random() * occupied.length)]
|
|
1502
|
+
|
|
1503
|
+
// dest 선택 — source 와 같은 (holder, slotId) 회피. attempts 제한.
|
|
1504
|
+
let dest = empty[Math.floor(Math.random() * empty.length)]
|
|
1505
|
+
let attempts = 0
|
|
1506
|
+
while (dest.holder === source.holder && dest.slotId === source.slotId && attempts < 10) {
|
|
1507
|
+
dest = empty[Math.floor(Math.random() * empty.length)]
|
|
1508
|
+
attempts++
|
|
1509
|
+
}
|
|
1510
|
+
if (dest.holder === source.holder && dest.slotId === source.slotId) {
|
|
1511
|
+
await new Promise(r => setTimeout(r, 500))
|
|
1512
|
+
return
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
const carrier = source.holder.obtainCarrier(source.slotId)
|
|
1517
|
+
if (!carrier) {
|
|
1518
|
+
await new Promise(r => setTimeout(r, 400))
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
const destTarget = dest.holder.slotTargetAt(dest.slotId)
|
|
1522
|
+
await this.pickAndPlace(carrier, destTarget as unknown as Component)
|
|
1523
|
+
} catch (e) {
|
|
1524
|
+
// 한 cycle 실패해도 simulate 루프는 계속. 다음 cycle 에서 다른 source/dest 시도.
|
|
1525
|
+
// (Mover.pickAndPlace 내부 rollback 이 carrier 복귀 시도함.)
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// 사이클 사이 idle — 여러 crane 의 phase 자연 분산 (= 동시 동기화 회귀 차단).
|
|
1529
|
+
// 범위 확장 (500~3500ms) — duration 거리 비례와 결합되어 cycle 총 시간 다양.
|
|
1530
|
+
await new Promise(r => setTimeout(r, 500 + Math.random() * 3000))
|
|
971
1531
|
}
|
|
972
1532
|
|
|
973
1533
|
/**
|