@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
@@ -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
@@ -173,6 +173,21 @@ export default class Rack
173
173
  static align: Alignment = 'bottom'
174
174
  static defaultDepth = (h: Heights) => h.ceiling - h.floor
175
175
 
176
+ // Phase Auto-Nav (AN-PR-2) — Obstacle 자격.
177
+ get isObstacle(): boolean {
178
+ return (this.state as any)?.isObstacle !== false
179
+ }
180
+ obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
181
+ const s: any = this.state
182
+ if (typeof s?.left !== 'number') return null
183
+ return {
184
+ left: s.left, top: s.top,
185
+ width: s.width, height: s.height,
186
+ y: typeof s.zPos === 'number' ? s.zPos : 0,
187
+ zHeight: typeof s.depth === 'number' ? s.depth : 0
188
+ }
189
+ }
190
+
176
191
  get nature() {
177
192
  return NATURE
178
193
  }
@@ -183,7 +198,7 @@ export default class Rack
183
198
 
184
199
  /**
185
200
  * Runtime — bays / levels 변경 시 anchor 캐시 무효화. cell 위치가 바뀌므로 다음
186
- * `_ensureCellAttachObject3d` 호출이 새 좌표로 갱신.
201
+ * `_ensureSlotAttachObject3d` 호출이 새 좌표로 갱신.
187
202
  */
188
203
  onchange(after: Record<string, unknown>, _before: Record<string, unknown>): void {
189
204
  super.onchange?.(after, _before)
@@ -195,7 +210,7 @@ export default class Rack
195
210
  'height' in after ||
196
211
  'depth' in after
197
212
  ) {
198
- this._attachAnchorByCell.clear()
213
+ this._attachAnchorBySlot.clear()
199
214
  }
200
215
  if ('hideHorizontalFrame' in after) {
201
216
  ;(this._realObject as any)?.applyFrameVisibility?.()
@@ -269,7 +284,7 @@ export default class Rack
269
284
  // 명시 시 (Carriable + CarrierHolder.reparent 모두) 그대로 anchor origin 에 snap.
270
285
  const cellId = (carrier as any)?.state?.cellId as string | undefined
271
286
  if (cellId) {
272
- const obj = this._ensureCellAttachObject3d(cellId)
287
+ const obj = this._ensureSlotAttachObject3d(cellId)
273
288
  if (obj) return { attach: obj, localPosition: { x: 0, y: 0, z: 0 } }
274
289
  }
275
290
  // Fallback — cellId 없는 (legacy) 호출 시 rack root.
@@ -302,13 +317,95 @@ export default class Rack
302
317
  /**
303
318
  * 1-based (bay, row, level) → 0-based cellId 문자열.
304
319
  *
305
- * rack.cellIdOf(1, 1, 6) → '0-0-5'
306
- * rack.cellIdOf(3, 1, 4) → '2-0-3'
320
+ * rack.slotIdOf(1, 1, 6) → '0-0-5'
321
+ * rack.slotIdOf(3, 1, 4) → '2-0-3'
307
322
  */
308
- cellIdOf(bay: number, row: number = 1, level: number = 1): string {
323
+ slotIdOf(bay: number, row: number = 1, level: number = 1): string {
309
324
  return `${bay - 1}-${row - 1}-${level - 1}`
310
325
  }
311
326
 
327
+ /**
328
+ * 모든 slot id 의 목록 (Plan A 의 *cell* = slot 통일). bays × rows × levels 전 조합.
329
+ * storage-rack 의 rows 는 항상 1 (단일 row) 이므로 실제론 bays × levels.
330
+ *
331
+ * SlottedHolder.slotIds — capability 기반 enumeration entry point.
332
+ */
333
+ slotIds(): ReadonlyArray<string> {
334
+ const bays = Math.max(1, Math.floor((this.state as any)?.bays ?? 5))
335
+ const levels = Math.max(1, Math.floor((this.state as any)?.levels ?? 4))
336
+ const ids: string[] = []
337
+ for (let bay = 1; bay <= bays; bay++) {
338
+ for (let level = 1; level <= levels; level++) {
339
+ ids.push(this.slotIdOf(bay, 1, level))
340
+ }
341
+ }
342
+ return ids
343
+ }
344
+
345
+ /**
346
+ * 점유된 slot 의 id 목록 — state.data record + *carrier* children 둘 다.
347
+ * RackCell (시각 proxy) 같은 *carrier 아닌 자식* 은 제외 (isCarriable 검사).
348
+ *
349
+ * @param filter predicate — 통과하는 slotId 만. sweep 중 즉시 reject.
350
+ */
351
+ occupiedSlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
352
+ const set = new Set<string>()
353
+ for (const r of this.records) {
354
+ const cid = r?.cellId
355
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
356
+ }
357
+ for (const c of (this as any).components ?? []) {
358
+ if (!(c as any)?.isCarriable) continue
359
+ const cid = (c as any)?.state?.cellId
360
+ if (typeof cid === 'string' && (!filter || filter(cid))) set.add(cid)
361
+ }
362
+ return Array.from(set)
363
+ }
364
+
365
+ /** 비어있는 slot 의 id 목록 — slotIds() - occupiedSlotIds(). filter 도 동일. */
366
+ emptySlotIds(filter?: (slotId: string) => boolean): ReadonlyArray<string> {
367
+ const occ = new Set(this.occupiedSlotIds())
368
+ const result: string[] = []
369
+ for (const id of this.slotIds()) {
370
+ if (occ.has(id)) continue
371
+ if (filter && !filter(id)) continue
372
+ result.push(id)
373
+ }
374
+ return result
375
+ }
376
+
377
+ /**
378
+ * slot 의 *world position* — anchor object3d *생성 없이* 직접 계산. crane 의
379
+ * reach 검사 등 *match 전 단계* 에서 *anchor 생성 0* 으로 사용.
380
+ */
381
+ slotWorldPosition(cellId: string): { x: number; y: number; z: number } | undefined {
382
+ const ro: any = (this as any)._realObject
383
+ if (!ro?.object3d) return undefined
384
+ const cell = this.cellMap?.findById(cellId)
385
+ if (!cell) return undefined
386
+
387
+ const rs: any = this.state
388
+ const rackWidth = rs?.width || 1000
389
+ const rackDepth = rs?.depth || 3000
390
+ const rackHeight = rs?.height || 600
391
+ const bays = Math.max(1, Math.floor(rs?.bays || 5))
392
+ const levels = Math.max(1, Math.floor(rs?.levels || 4))
393
+ const shelfBase = Math.max(0, Math.min(rs?.shelfBaseHeight || 0, rackDepth * 0.9))
394
+ const shelfZone = rackDepth - shelfBase
395
+ const bayWidth = rackWidth / bays
396
+ const levelHeight = shelfZone / levels
397
+ const stockD = levelHeight * 0.7
398
+ const rowDepth = rackHeight
399
+
400
+ const x = cell.localPosition.x + bayWidth / 2 - rackWidth / 2
401
+ const y = cell.localPosition.y - rackDepth / 2 + stockD / 2
402
+ const z = cell.localPosition.z + rowDepth / 2 - rackHeight / 2
403
+
404
+ const v = new THREE.Vector3(x, y, z)
405
+ ro.object3d.localToWorld(v)
406
+ return { x: v.x, y: v.y, z: v.z }
407
+ }
408
+
312
409
  /** cellId 에 carrier 가 있는가 — child carrier 또는 state.data record 어느 쪽이든. */
313
410
  hasCarrierAt(cellId: string): boolean {
314
411
  if (this._carrierChildAt(cellId)) return true
@@ -337,7 +434,7 @@ export default class Rack
337
434
  obtainCarrier(idOrBay: string | number, row?: number, level?: number): Component | null {
338
435
  const cellId = typeof idOrBay === 'string'
339
436
  ? idOrBay
340
- : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
437
+ : this.slotIdOf(idOrBay, row ?? 1, level ?? 1)
341
438
  const existing = this._carrierChildAt(cellId)
342
439
  if (existing) return existing
343
440
 
@@ -528,7 +625,7 @@ export default class Rack
528
625
  * 이걸 attach frame 으로 사용 (transit 중 carrier 가 slot 위치에 정렬).
529
626
  */
530
627
  getSlotAttachObject3d(cellId: string): THREE.Object3D | undefined {
531
- return this._ensureCellAttachObject3d(cellId)
628
+ return this._ensureSlotAttachObject3d(cellId)
532
629
  }
533
630
 
534
631
  /**
@@ -546,7 +643,7 @@ export default class Rack
546
643
  getSlotSize(cellId: string): { width: number; height: number; depth: number } | undefined {
547
644
  const cell = this.cellMap?.findById(cellId)
548
645
  if (!cell) return undefined
549
- const stockD = cell.size.height * 0.7 // matches _ensureCellAttachObject3d + storage-rack-3d.rebuildStockMesh
646
+ const stockD = cell.size.height * 0.7 // matches _ensureSlotAttachObject3d + storage-rack-3d.rebuildStockMesh
550
647
  return {
551
648
  width: cell.size.width,
552
649
  height: cell.size.depth, // 2D height = Z extent (front-back)
@@ -564,7 +661,7 @@ export default class Rack
564
661
  slotTargetAt(idOrBay: string | number, row?: number, level?: number): SlotTarget {
565
662
  const cellId = typeof idOrBay === 'string'
566
663
  ? idOrBay
567
- : this.cellIdOf(idOrBay, row ?? 1, level ?? 1)
664
+ : this.slotIdOf(idOrBay, row ?? 1, level ?? 1)
568
665
  return new SlotTarget(this, cellId)
569
666
  }
570
667
 
@@ -609,7 +706,7 @@ export default class Rack
609
706
  }
610
707
 
611
708
  /** cellId 별 attach anchor object3d cache (rack.object3d 의 자식). */
612
- private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
709
+ private _attachAnchorBySlot: Map<string, THREE.Object3D> = new Map()
613
710
 
614
711
  /**
615
712
  * cellId 위치에 lightweight anchor object3d 를 *singleton 으로* 보장 + 갱신.
@@ -619,16 +716,16 @@ export default class Rack
619
716
  * - 두 용도가 *같은 object3d* 를 공유해 carrier 가 transient 동안 SlotTarget 의
620
717
  * pose 와 정확히 동기화.
621
718
  */
622
- private _ensureCellAttachObject3d(cellId: string): THREE.Object3D | undefined {
719
+ private _ensureSlotAttachObject3d(cellId: string): THREE.Object3D | undefined {
623
720
  const ro: any = (this as any)._realObject
624
721
  if (!ro?.object3d) return undefined
625
722
 
626
- let obj = this._attachAnchorByCell.get(cellId)
723
+ let obj = this._attachAnchorBySlot.get(cellId)
627
724
  if (!obj) {
628
725
  obj = new THREE.Object3D()
629
726
  obj.name = `rack-slot-anchor:${cellId}`
630
727
  ro.object3d.add(obj)
631
- this._attachAnchorByCell.set(cellId, obj)
728
+ this._attachAnchorBySlot.set(cellId, obj)
632
729
  }
633
730
 
634
731
  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
+ })