@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.
- package/CHANGELOG.md +44 -0
- 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 +578 -48
- package/dist/crane.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 +103 -10
- package/dist/rack-grid.js +484 -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 +40 -6
- package/dist/storage-rack.js +111 -14
- package/dist/storage-rack.js.map +1 -1
- package/package.json +4 -4
- package/src/crane-3d.ts +34 -4
- package/src/crane.ts +625 -57
- package/src/parcel-3d.ts +19 -1
- package/src/rack-grid-3d.ts +31 -8
- package/src/rack-grid.ts +504 -82
- package/src/storage-rack-3d.ts +1 -1
- package/src/storage-rack.ts +111 -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/test/things-scene-loader-impl.mjs +37 -0
- package/test/things-scene-loader.mjs +24 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/storage-rack-3d.ts
CHANGED
|
@@ -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 /
|
|
201
|
+
// rack 의 기하 파라미터 — cellMap / _ensureSlotAttachObject3d 의 levelHeight
|
|
202
202
|
// 계산과 *반드시* 일치해야 함 (shelfBaseHeight > 0 일 때 stock 시각 위치와
|
|
203
203
|
// crane fork target 이 어긋나는 회귀 차단).
|
|
204
204
|
const rs: any = this.component.state
|
package/src/storage-rack.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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.
|
|
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.
|
|
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.
|
|
306
|
-
* rack.
|
|
320
|
+
* rack.slotIdOf(1, 1, 6) → '0-0-5'
|
|
321
|
+
* rack.slotIdOf(3, 1, 4) → '2-0-3'
|
|
307
322
|
*/
|
|
308
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
})
|