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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +29 -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 +567 -46
  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 +94 -10
  14. package/dist/rack-grid.js +468 -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 +31 -6
  19. package/dist/storage-rack.js +96 -14
  20. package/dist/storage-rack.js.map +1 -1
  21. package/package.json +3 -3
  22. package/src/crane-3d.ts +34 -4
  23. package/src/crane.ts +615 -55
  24. package/src/parcel-3d.ts +19 -1
  25. package/src/rack-grid-3d.ts +31 -8
  26. package/src/rack-grid.ts +488 -82
  27. package/src/storage-rack-3d.ts +1 -1
  28. package/src/storage-rack.ts +96 -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/tsconfig.tsbuildinfo +1 -1
@@ -198,7 +198,7 @@ export class StorageRack3D extends RealObjectGroup {
198
198
  const cellMap = rack.cellMap
199
199
  if (!cellMap) return
200
200
 
201
- // rack 의 기하 파라미터 — cellMap / _ensureCellAttachObject3d 의 levelHeight
201
+ // rack 의 기하 파라미터 — cellMap / _ensureSlotAttachObject3d 의 levelHeight
202
202
  // 계산과 *반드시* 일치해야 함 (shelfBaseHeight > 0 일 때 stock 시각 위치와
203
203
  // crane fork target 이 어긋나는 회귀 차단).
204
204
  const rs: any = this.component.state
@@ -183,7 +183,7 @@ export default class Rack
183
183
 
184
184
  /**
185
185
  * Runtime — bays / levels 변경 시 anchor 캐시 무효화. cell 위치가 바뀌므로 다음
186
- * `_ensureCellAttachObject3d` 호출이 새 좌표로 갱신.
186
+ * `_ensureSlotAttachObject3d` 호출이 새 좌표로 갱신.
187
187
  */
188
188
  onchange(after: Record<string, unknown>, _before: Record<string, unknown>): void {
189
189
  super.onchange?.(after, _before)
@@ -195,7 +195,7 @@ export default class Rack
195
195
  'height' in after ||
196
196
  'depth' in after
197
197
  ) {
198
- this._attachAnchorByCell.clear()
198
+ this._attachAnchorBySlot.clear()
199
199
  }
200
200
  if ('hideHorizontalFrame' in after) {
201
201
  ;(this._realObject as any)?.applyFrameVisibility?.()
@@ -269,7 +269,7 @@ export default class Rack
269
269
  // 명시 시 (Carriable + CarrierHolder.reparent 모두) 그대로 anchor origin 에 snap.
270
270
  const cellId = (carrier as any)?.state?.cellId as string | undefined
271
271
  if (cellId) {
272
- const obj = this._ensureCellAttachObject3d(cellId)
272
+ const obj = this._ensureSlotAttachObject3d(cellId)
273
273
  if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
274
274
  }
275
275
  // Fallback — cellId 없는 (legacy) 호출 시 rack root.
@@ -302,13 +302,95 @@ export default class Rack
302
302
  /**
303
303
  * 1-based (bay, row, level) → 0-based cellId 문자열.
304
304
  *
305
- * rack.cellIdOf(1, 1, 6) → '0-0-5'
306
- * rack.cellIdOf(3, 1, 4) → '2-0-3'
305
+ * rack.slotIdOf(1, 1, 6) → '0-0-5'
306
+ * rack.slotIdOf(3, 1, 4) → '2-0-3'
307
307
  */
308
- cellIdOf(bay: number, row: number = 1, level: number = 1): string {
308
+ slotIdOf(bay: number, row: number = 1, level: number = 1): string {
309
309
  return `${bay - 1}-${row - 1}-${level - 1}`
310
310
  }
311
311
 
312
+ /**
313
+ * 모든 slot id 의 목록 (Plan A 의 *cell* = slot 통일). bays × rows × levels 전 조합.
314
+ * storage-rack 의 rows 는 항상 1 (단일 row) 이므로 실제론 bays × levels.
315
+ *
316
+ * SlottedHolder.slotIds — capability 기반 enumeration entry point.
317
+ */
318
+ slotIds(): ReadonlyArray<string> {
319
+ const bays = Math.max(1, Math.floor((this.state as any)?.bays ?? 5))
320
+ const levels = Math.max(1, Math.floor((this.state as any)?.levels ?? 4))
321
+ const ids: string[] = []
322
+ for (let bay = 1; bay <= bays; bay++) {
323
+ for (let level = 1; level <= levels; level++) {
324
+ ids.push(this.slotIdOf(bay, 1, level))
325
+ }
326
+ }
327
+ return ids
328
+ }
329
+
330
+ /**
331
+ * 점유된 slot 의 id 목록 — state.data record + *carrier* children 둘 다.
332
+ * RackCell (시각 proxy) 같은 *carrier 아닌 자식* 은 제외 (isCarriable 검사).
333
+ *
334
+ * @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
335
+ */
336
+ occupiedSlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
337
+ const set = new Set<string>()
338
+ for (const r of this.records) {
339
+ const cid = r?.cellId
340
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
341
+ }
342
+ for (const c of (this as any).components ?? []) {
343
+ if (!(c as any)?.isCarriable) continue
344
+ const cid = (c as any)?.state?.cellId
345
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
346
+ }
347
+ return Array.from(set)
348
+ }
349
+
350
+ /** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). filter 도 동일. */
351
+ emptySlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
352
+ const occ = new Set(this.occupiedSlotIds())
353
+ const result: string[] = []
354
+ for (const id of this.slotIds()) {
355
+ if (occ.has(id)) continue
356
+ if (filter && !filter(id)) continue
357
+ result.push(id)
358
+ }
359
+ return result
360
+ }
361
+
362
+ /**
363
+ * slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
364
+ * reach 검사 등 *match 전 단계* 에서 *anchor 생성 0* 으로 사용.
365
+ */
366
+ slotWorldPosition(cellId: string): { x: number; y: number; z: number } | undefined {
367
+ const ro: any = (this as any)._realObject
368
+ if (!ro?.object3d) return undefined
369
+ const cell = this.cellMap?.findById(cellId)
370
+ if (!cell) return undefined
371
+
372
+ const rs: any = this.state
373
+ const rackWidth = rs?.width || 1000
374
+ const rackDepth = rs?.depth || 3000
375
+ const rackHeight = rs?.height || 600
376
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
377
+ const levels = Math.max(1, Math.floor(rs?.levels || 4))
378
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9))
379
+ const shelfZone = rackDepth - shelfBase
380
+ const bayWidth = rackWidth / bays
381
+ const levelHeight = shelfZone / levels
382
+ const stockD = levelHeight * 0.7
383
+ const rowDepth = rackHeight
384
+
385
+ const x = cell.localPosition.x + bayWidth / 2 - rackWidth / 2
386
+ const y = cell.localPosition.y - rackDepth / 2 + stockD / 2
387
+ const z = cell.localPosition.z + rowDepth / 2 - rackHeight / 2
388
+
389
+ const v = new THREE.Vector3(x, y, z)
390
+ ro.object3d.localToWorld(v)
391
+ return { x: v.x, y: v.y, z: v.z }
392
+ }
393
+
312
394
  /** cellId 에 carrier 가 있는가 — child carrier 또는 state.data record 어느 쪽이든. */
313
395
  hasCarrierAt(cellId: string): boolean {
314
396
  if (this._carrierChildAt(cellId)) return true
@@ -337,7 +419,7 @@ export default class Rack
337
419
  obtainCarrier(idOrBay: string | number, row?: number, level?: number): Component | null {
338
420
  const cellId = typeof idOrBay === 'string'
339
421
  ? idOrBay
340
- : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
422
+ : this.slotIdOf(idOrBay, row ?? 1, level ?? 1)
341
423
  const existing = this._carrierChildAt(cellId)
342
424
  if (existing) return existing
343
425
 
@@ -528,7 +610,7 @@ export default class Rack
528
610
  * 이걸 attach frame 으로 사용 (transit 중 carrier 가 slot 위치에 정렬).
529
611
  */
530
612
  getSlotAttachObject3d(cellId: string): THREE.Object3D | undefined {
531
- return this._ensureCellAttachObject3d(cellId)
613
+ return this._ensureSlotAttachObject3d(cellId)
532
614
  }
533
615
 
534
616
  /**
@@ -546,7 +628,7 @@ export default class Rack
546
628
  getSlotSize(cellId: string): { width: number; height: number; depth: number } | undefined {
547
629
  const cell = this.cellMap?.findById(cellId)
548
630
  if (!cell) return undefined
549
- const stockD = cell.size.height * 0.7 // matches _ensureCellAttachObject3d + storage-rack-3d.rebuildStockMesh
631
+ const stockD = cell.size.height * 0.7 // matches _ensureSlotAttachObject3d + storage-rack-3d.rebuildStockMesh
550
632
  return {
551
633
  width: cell.size.width,
552
634
  height: cell.size.depth, // 2D height = Z extent (front-back)
@@ -564,7 +646,7 @@ export default class Rack
564
646
  slotTargetAt(idOrBay: string | number, row?: number, level?: number): SlotTarget {
565
647
  const cellId = typeof idOrBay === 'string'
566
648
  ? idOrBay
567
- : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
649
+ : this.slotIdOf(idOrBay, row ?? 1, level ?? 1)
568
650
  return new SlotTarget(this, cellId)
569
651
  }
570
652
 
@@ -609,7 +691,7 @@ export default class Rack
609
691
  }
610
692
 
611
693
  /** cellId 별 attach anchor object3d cache (rack.object3d 의 자식). */
612
- private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
694
+ private _attachAnchorBySlot: Map<string, THREE.Object3D> = new Map()
613
695
 
614
696
  /**
615
697
  * cellId 위치에 lightweight anchor object3d 를 *singleton 으로* 보장 + 갱신.
@@ -619,16 +701,16 @@ export default class Rack
619
701
  * - 두 용도가 *같은 object3d* 를 공유해 carrier 가 transient 동안 SlotTarget 의
620
702
  * pose 와 정확히 동기화.
621
703
  */
622
- private _ensureCellAttachObject3d(cellId: string): THREE.Object3D | undefined {
704
+ private _ensureSlotAttachObject3d(cellId: string): THREE.Object3D | undefined {
623
705
  const ro: any = (this as any)._realObject
624
706
  if (!ro?.object3d) return undefined
625
707
 
626
- let obj = this._attachAnchorByCell.get(cellId)
708
+ let obj = this._attachAnchorBySlot.get(cellId)
627
709
  if (!obj) {
628
710
  obj = new THREE.Object3D()
629
711
  obj.name = `rack-slot-anchor:${cellId}`
630
712
  ro.object3d.add(obj)
631
- this._attachAnchorByCell.set(cellId, obj)
713
+ this._attachAnchorBySlot.set(cellId, obj)
632
714
  }
633
715
 
634
716
  const cell = this.cellMap?.findById(cellId)
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 두 공식이 *동일 cellId 에 대해 동일 결과* 를 반환해야 함:
6
6
  * - storage-rack-3d.rebuildStockMesh: InstancedMesh instance 의 위치
7
- * - storage-rack._ensureCellAttachObject3d: anchor (SlotTarget._realObject.object3d) 위치
7
+ * - storage-rack._ensureSlotAttachObject3d: anchor (SlotTarget._realObject.object3d) 위치
8
8
  *
9
9
  * 불일치 = Crane fork 가 stock 시각 위치와 어긋남.
10
10
  *
@@ -47,7 +47,7 @@ function computeAnchorPosition(
47
47
  const stockD = levelHeight * 0.7
48
48
  const rowDepth = height
49
49
 
50
- // storage-rack.ts _ensureCellAttachObject3d 공식
50
+ // storage-rack.ts _ensureSlotAttachObject3d 공식
51
51
  const x = cell.localPosition.x + bayWidth / 2 - width / 2
52
52
  const y = cell.localPosition.y - depth / 2 + stockD / 2
53
53
  const z = cell.localPosition.z + rowDepth / 2 - height / 2
@@ -0,0 +1,130 @@
1
+ /*
2
+ * Crane.findAdjacentSlots — bound scope 매칭 검증.
3
+ *
4
+ * 사용자 핵심 의도:
5
+ * "rail 끝에서 끝까지가 carriage 도달 범위" — bound rack 의 X 영역 전체
6
+ * 가 reach. crane 자체 폭 (state.width) 무관. Z (fork tip) 만 검사.
7
+ *
8
+ * (col, row) 단위 정밀 매칭 — 같은 col 의 다른 row 는 Z 별도 검사. 매칭 통과
9
+ * 한 (col, row) 의 모든 shelves 가 adjacent.
10
+ */
11
+
12
+ import 'should'
13
+
14
+ // findAdjacentSlots 의 bay 단위 매칭 로직 격리.
15
+ function mockBayMatch(opts: {
16
+ bayKey: string
17
+ representativeSlotsWorld: Array<{ x: number; y: number; z: number }>
18
+ craneWorld: { x: number; z: number }
19
+ zHalf: number
20
+ useBoundScope: boolean
21
+ xHalf?: number
22
+ }): boolean {
23
+ for (const pos of opts.representativeSlotsWorld) {
24
+ const dz = Math.abs(pos.z - opts.craneWorld.z)
25
+ const xOk = opts.useBoundScope ? true : Math.abs(pos.x - opts.craneWorld.x) <= (opts.xHalf ?? 0)
26
+ if (xOk && dz <= opts.zHalf) return true
27
+ }
28
+ return false
29
+ }
30
+
31
+ describe('Crane.findAdjacentSlots — bound scope 시 X 무관, Z 만 검사', () => {
32
+ it('bound scope true — X 가 매우 멀어도 Z reach 안 이면 통과', () => {
33
+ const matched = mockBayMatch({
34
+ bayKey: '0-0',
35
+ representativeSlotsWorld: [{ x: 99999, y: 0, z: 100 }],
36
+ craneWorld: { x: 0, z: 0 },
37
+ zHalf: 500,
38
+ useBoundScope: true
39
+ })
40
+ matched.should.be.true()
41
+ })
42
+
43
+ it('bound scope true — Z 가 fork reach 밖 면 매칭 X', () => {
44
+ const matched = mockBayMatch({
45
+ bayKey: '0-0',
46
+ representativeSlotsWorld: [{ x: 100, y: 0, z: 800 }],
47
+ craneWorld: { x: 0, z: 0 },
48
+ zHalf: 500,
49
+ useBoundScope: true
50
+ })
51
+ matched.should.be.false()
52
+ })
53
+
54
+ it('bound scope false (fallback) — X + Z 둘 다 검사', () => {
55
+ const matched = mockBayMatch({
56
+ bayKey: '0-0',
57
+ representativeSlotsWorld: [{ x: 9999, y: 0, z: 100 }],
58
+ craneWorld: { x: 0, z: 0 },
59
+ zHalf: 500,
60
+ useBoundScope: false,
61
+ xHalf: 500 // 매우 좁은 fallback
62
+ })
63
+ matched.should.be.false() // X 9999 > 500 → fail
64
+ })
65
+
66
+ it('row 별 Z 다른 경우 — 어느 row 든 reach 안 이면 col 매칭', () => {
67
+ const matched = mockBayMatch({
68
+ bayKey: '0', // first segment = col only (사용자 의도 시 row 통합 매칭)
69
+ representativeSlotsWorld: [
70
+ { x: 100, y: 0, z: 800 }, // row=0, Z 밖
71
+ { x: 100, y: 0, z: 100 }, // row=1, Z 안 ← match
72
+ { x: 100, y: 0, z: -700 } // row=2, Z 밖
73
+ ],
74
+ craneWorld: { x: 0, z: 0 },
75
+ zHalf: 500,
76
+ useBoundScope: true
77
+ })
78
+ matched.should.be.true()
79
+ })
80
+
81
+ it('모든 row 의 Z 가 reach 밖 — col 매칭 X', () => {
82
+ const matched = mockBayMatch({
83
+ bayKey: '0',
84
+ representativeSlotsWorld: [
85
+ { x: 100, y: 0, z: 800 },
86
+ { x: 100, y: 0, z: -700 }
87
+ ],
88
+ craneWorld: { x: 0, z: 0 },
89
+ zHalf: 500,
90
+ useBoundScope: true
91
+ })
92
+ matched.should.be.false()
93
+ })
94
+ })
95
+
96
+ describe('rail 의 *전체 X 영역* 활용 — 사용자 의도 시뮬레이션', () => {
97
+ // RackGrid 의 여러 col + carrier 분포 — carriage 가 X 전체 visit.
98
+ // simulate 의 X variance 가 모델 의 *carrier 분포 range* 와 같은지.
99
+
100
+ it('5 col × records 분포 → carriage X variance 가 모든 col cover', () => {
101
+ // RackGrid 5 col, bayW=200, width=1000. col 별 X = -400, -200, 0, 200, 400.
102
+ const colX = [-400, -200, 0, 200, 400]
103
+ const records = colX.map(x => ({ cellId: `${(x + 400) / 200}-0-0`, worldX: x }))
104
+
105
+ // crane 의 random source pick — 충분한 횟수면 모든 col visit.
106
+ const visitedX = new Set<number>()
107
+ for (let i = 0; i < 100; i++) {
108
+ const r = records[Math.floor(Math.random() * records.length)]
109
+ visitedX.add(r.worldX)
110
+ }
111
+ // 100 회 random pick 에서 5 col 모두 적어도 1회씩 visit (high probability)
112
+ visitedX.size.should.be.greaterThanOrEqual(4)
113
+ })
114
+
115
+ it('한 col 만 record 있음 — carriage 가 그 col 만 visit (narrow 정상)', () => {
116
+ // 사용자 모델 가정: record 가 한 col 에만 분포 시 carriage X variance narrow.
117
+ // 이건 *모델 데이터 결과 — 코드 책임 X*.
118
+ const records = [
119
+ { cellId: '0-0-0', worldX: -400 },
120
+ { cellId: '0-0-1', worldX: -400 },
121
+ { cellId: '0-0-2', worldX: -400 }
122
+ ]
123
+ const visitedX = new Set<number>()
124
+ for (let i = 0; i < 50; i++) {
125
+ const r = records[Math.floor(Math.random() * records.length)]
126
+ visitedX.add(r.worldX)
127
+ }
128
+ visitedX.size.should.equal(1) // narrow — 한 col 만
129
+ })
130
+ })
@@ -0,0 +1,168 @@
1
+ /*
2
+ * Crane.boundHolderInstances() — binding scope 의 resolve 정확성 검증.
3
+ *
4
+ * 입력 형식 분기 + scene findById 호출 + isSlottedHolder duck-type 통과 검사.
5
+ * 명시 binding 시 그 list 만 사용, 미명시 시 auto-detect cache (별도 test).
6
+ */
7
+
8
+ import 'should'
9
+
10
+ // 핵심 로직만 격리 (실 Crane 인스턴스 build 의존 X).
11
+ function mockBoundHolderResolve(opts: {
12
+ rawState: string | string[] | undefined
13
+ findById: (id: string) => any
14
+ isSlottedHolder: (x: any) => boolean
15
+ }): any[] {
16
+ const raw = opts.rawState
17
+ let ids: string[] = []
18
+ if (typeof raw === 'string') {
19
+ ids = raw.split(',').map(s => s.trim()).filter(s => s.length > 0)
20
+ } else if (Array.isArray(raw)) {
21
+ ids = raw.filter((x: any) => typeof x === 'string' && x.length > 0)
22
+ }
23
+ if (ids.length === 0) return []
24
+ const result: any[] = []
25
+ for (const id of ids) {
26
+ const c = opts.findById(id)
27
+ if (c && opts.isSlottedHolder(c)) result.push(c)
28
+ }
29
+ return result
30
+ }
31
+
32
+ describe('Crane.boundHolderInstances — csv / array 입력 분기 + scene lookup', () => {
33
+ const fakeRack1 = { id: 'rack-1', isSlot: true }
34
+ const fakeRack2 = { id: 'rack-2', isSlot: true }
35
+ const fakeNonSlot = { id: 'wire-1', isSlot: false }
36
+
37
+ const scene = new Map<string, any>([
38
+ ['rack-1', fakeRack1],
39
+ ['rack-2', fakeRack2],
40
+ ['wire-1', fakeNonSlot]
41
+ ])
42
+ const findById = (id: string) => scene.get(id)
43
+ const isSlottedHolder = (x: any) => !!x?.isSlot
44
+
45
+ it('csv string — comma-separated id resolve', () => {
46
+ const r = mockBoundHolderResolve({
47
+ rawState: 'rack-1, rack-2',
48
+ findById, isSlottedHolder
49
+ })
50
+ r.length.should.equal(2)
51
+ r[0].should.equal(fakeRack1)
52
+ r[1].should.equal(fakeRack2)
53
+ })
54
+
55
+ it('array of string — id list resolve', () => {
56
+ const r = mockBoundHolderResolve({
57
+ rawState: ['rack-1', 'rack-2'],
58
+ findById, isSlottedHolder
59
+ })
60
+ r.length.should.equal(2)
61
+ })
62
+
63
+ it('미명시 (undefined) — empty list', () => {
64
+ const r = mockBoundHolderResolve({
65
+ rawState: undefined,
66
+ findById, isSlottedHolder
67
+ })
68
+ r.length.should.equal(0)
69
+ })
70
+
71
+ it('빈 csv ("") — empty list', () => {
72
+ const r = mockBoundHolderResolve({
73
+ rawState: '',
74
+ findById, isSlottedHolder
75
+ })
76
+ r.length.should.equal(0)
77
+ })
78
+
79
+ it('빈 array — empty list', () => {
80
+ const r = mockBoundHolderResolve({
81
+ rawState: [],
82
+ findById, isSlottedHolder
83
+ })
84
+ r.length.should.equal(0)
85
+ })
86
+
87
+ it('잘못된 id — scene findById 가 undefined 반환 시 skip', () => {
88
+ const r = mockBoundHolderResolve({
89
+ rawState: 'rack-1, nonexistent, rack-2',
90
+ findById, isSlottedHolder
91
+ })
92
+ r.length.should.equal(2)
93
+ })
94
+
95
+ it('isSlottedHolder 안 통과 — skip', () => {
96
+ const r = mockBoundHolderResolve({
97
+ rawState: 'rack-1, wire-1, rack-2',
98
+ findById, isSlottedHolder
99
+ })
100
+ r.length.should.equal(2)
101
+ r.should.not.containEql(fakeNonSlot)
102
+ })
103
+
104
+ it('csv 공백 처리 — trim', () => {
105
+ const r = mockBoundHolderResolve({
106
+ rawState: ' rack-1 , rack-2 ',
107
+ findById, isSlottedHolder
108
+ })
109
+ r.length.should.equal(2)
110
+ })
111
+
112
+ it('csv 빈 항목 — filter out', () => {
113
+ const r = mockBoundHolderResolve({
114
+ rawState: 'rack-1, , rack-2,',
115
+ findById, isSlottedHolder
116
+ })
117
+ r.length.should.equal(2)
118
+ })
119
+ })
120
+
121
+ describe('detectBoundHolders 의 Z (fork) 만 검사 — X 무관 매칭', () => {
122
+ // detectBoundHolders 의 핵심: holder.slotIds() 중 어느 한 slot 의 world.z
123
+ // 가 *crane.world.z ± forkLen* 안인지. X 무관.
124
+
125
+ function mockDetectMatch(opts: {
126
+ craneZ: number
127
+ forkLen: number
128
+ holderSlotsWorld: Array<{ x: number; y: number; z: number }>
129
+ }): boolean {
130
+ const zHalf = opts.forkLen
131
+ for (const pos of opts.holderSlotsWorld) {
132
+ if (Math.abs(pos.z - opts.craneZ) <= zHalf) return true
133
+ }
134
+ return false
135
+ }
136
+
137
+ it('어느 한 slot 이 *Z reach 안* — 매칭 통과', () => {
138
+ const matched = mockDetectMatch({
139
+ craneZ: 0, forkLen: 500,
140
+ holderSlotsWorld: [
141
+ { x: 9999, y: 0, z: 300 }, // X 무관, Z reach 안
142
+ { x: -9999, y: 0, z: -200 }
143
+ ]
144
+ })
145
+ matched.should.be.true()
146
+ })
147
+
148
+ it('모든 slot 의 Z 가 *reach 밖* — 매칭 X', () => {
149
+ const matched = mockDetectMatch({
150
+ craneZ: 0, forkLen: 500,
151
+ holderSlotsWorld: [
152
+ { x: 100, y: 0, z: 800 }, // |800| > 500
153
+ { x: 200, y: 0, z: -700 } // |700| > 500
154
+ ]
155
+ })
156
+ matched.should.be.false()
157
+ })
158
+
159
+ it('X 가 매우 멀어도 *Z reach* 면 통과 — X 무관 확인', () => {
160
+ const matched = mockDetectMatch({
161
+ craneZ: 0, forkLen: 100,
162
+ holderSlotsWorld: [
163
+ { x: 100000, y: 0, z: 50 }
164
+ ]
165
+ })
166
+ matched.should.be.true()
167
+ })
168
+ })
@@ -0,0 +1,90 @@
1
+ /*
2
+ * Crane.moveTo 의 duration 계산 — 거리 비례 + state.speed 기반.
3
+ *
4
+ * 회귀 시나리오:
5
+ * 1500ms 고정 → 모든 pickAndPlace 가 같은 시간. 거리 1 cell 이든 rail 끝~끝
6
+ * 이든 동일. 여러 crane 의 cycle 이 비현실적 동기. 사용자 보고:
7
+ * "왜 모든 크레인이 동시에 움직이는거지? 현실성 없이?"
8
+ *
9
+ * Fix 의도:
10
+ * - duration = max(X 운동 거리, Y 운동 거리) / speed * 1000.
11
+ * - speed = state.speed | options.speed | 500 (DEFAULT_SPEED).
12
+ * - duration 최소 200ms (teleport 느낌 방지).
13
+ * - options.duration 명시 시 override.
14
+ */
15
+
16
+ import 'should'
17
+
18
+ function calcDuration(opts: {
19
+ curCP: number; curCH: number
20
+ newCP: number; newCH?: number
21
+ stateSpeed?: number
22
+ optionDuration?: number
23
+ optionSpeed?: number
24
+ }): number {
25
+ if (typeof opts.optionDuration === 'number') return opts.optionDuration
26
+ const dxRail = Math.abs(opts.newCP - opts.curCP)
27
+ const dyMast = typeof opts.newCH === 'number' ? Math.abs(opts.newCH - opts.curCH) : 0
28
+ const dist = Math.max(dxRail, dyMast)
29
+ const speed = opts.optionSpeed ?? opts.stateSpeed ?? 250
30
+ return speed > 0 ? Math.max(200, (dist / speed) * 1000) : 1500
31
+ }
32
+
33
+ describe('Crane.moveTo duration — 거리 비례 + speed 기반', () => {
34
+ it('1 cell 이동 (50 units) at default speed 250 — duration = 200ms (min)', () => {
35
+ const d = calcDuration({ curCP: 100, curCH: 50, newCP: 150, newCH: 50 })
36
+ // (50 / 250) * 1000 = 200ms.
37
+ d.should.equal(200)
38
+ })
39
+
40
+ it('rail 끝~끝 이동 (500 units) at default speed 250 — duration = 2000ms', () => {
41
+ const d = calcDuration({ curCP: 0, curCH: 50, newCP: 500, newCH: 50 })
42
+ d.should.equal(2000)
43
+ })
44
+
45
+ it('Y 운동도 포함 — max(X, Y) 거리 사용', () => {
46
+ // X 이동 100 (400ms), Y 이동 400 (1600ms) → max = 1600ms.
47
+ const d = calcDuration({ curCP: 0, curCH: 0, newCP: 100, newCH: 400 })
48
+ d.should.equal(1600)
49
+ })
50
+
51
+ it('state.speed = 250 (느린 crane) — 같은 거리 2 배 시간', () => {
52
+ const d = calcDuration({ curCP: 0, curCH: 0, newCP: 500, newCH: 0, stateSpeed: 250 })
53
+ d.should.equal(2000)
54
+ })
55
+
56
+ it('state.speed = 1000 (빠른 crane) — 같은 거리 절반 시간', () => {
57
+ const d = calcDuration({ curCP: 0, curCH: 0, newCP: 500, newCH: 0, stateSpeed: 1000 })
58
+ d.should.equal(500)
59
+ })
60
+
61
+ it('options.duration 명시 시 override', () => {
62
+ const d = calcDuration({ curCP: 0, curCH: 0, newCP: 500, newCH: 0, optionDuration: 333 })
63
+ d.should.equal(333)
64
+ })
65
+
66
+ it('options.speed 가 state.speed 보다 우선', () => {
67
+ const d = calcDuration({ curCP: 0, curCH: 0, newCP: 500, stateSpeed: 250, optionSpeed: 1000 })
68
+ d.should.equal(500)
69
+ })
70
+ })
71
+
72
+ describe('여러 crane cycle 비동기 검증 — 거리 + speed + idle random 결합', () => {
73
+ it('★ 핵심 ★ 같은 cycle path 라도 *crane 별 speed + 거리* 다르면 총 시간 다양', () => {
74
+ // 시나리오: 3 crane, 각자 다른 source/dest 거리, 다른 speed.
75
+ const c1Duration = calcDuration({ curCP: 0, curCH: 0, newCP: 100, newCH: 50, stateSpeed: 500 }) // 100/500*1000 = 200ms (min)
76
+ const c2Duration = calcDuration({ curCP: 200, curCH: 0, newCP: 0, newCH: 200, stateSpeed: 300 }) // 200/300*1000 = 667ms
77
+ const c3Duration = calcDuration({ curCP: 0, curCH: 0, newCP: 500, newCH: 100, stateSpeed: 1000 }) // 500/1000*1000 = 500ms
78
+ // 셋 다 다름 → cycle 비동기.
79
+ const set = new Set([c1Duration, c2Duration, c3Duration])
80
+ set.size.should.equal(3)
81
+ })
82
+
83
+ it('★ 사용자 보고 1500ms 고정 회귀 ★ — 거리 무관 모두 같은 시간이면 동시 동기화', () => {
84
+ // legacy 식: const duration = options.duration ?? 1500
85
+ const legacy1 = 1500 // 1 cell 이동
86
+ const legacy2 = 1500 // rail 끝~끝
87
+ const legacy3 = 1500 // 다양한 거리
88
+ new Set([legacy1, legacy2, legacy3]).size.should.equal(1) // 모두 동일 → 동기화
89
+ })
90
+ })