@operato/scene-storage 10.0.0-beta.40 → 10.0.0-beta.41

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 (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/box.js +18 -0
  3. package/dist/box.js.map +1 -1
  4. package/dist/crane-3d.d.ts +47 -2
  5. package/dist/crane-3d.js +246 -89
  6. package/dist/crane-3d.js.map +1 -1
  7. package/dist/crane.d.ts +96 -12
  8. package/dist/crane.js +395 -100
  9. package/dist/crane.js.map +1 -1
  10. package/dist/pallet.d.ts +15 -0
  11. package/dist/pallet.js +38 -2
  12. package/dist/pallet.js.map +1 -1
  13. package/dist/parcel-3d.js +22 -18
  14. package/dist/parcel-3d.js.map +1 -1
  15. package/dist/parcel.d.ts +4 -3
  16. package/dist/parcel.js +24 -5
  17. package/dist/parcel.js.map +1 -1
  18. package/dist/storage-cell.d.ts +5 -2
  19. package/dist/storage-cell.js +21 -3
  20. package/dist/storage-cell.js.map +1 -1
  21. package/dist/storage-rack-3d.js +42 -7
  22. package/dist/storage-rack-3d.js.map +1 -1
  23. package/dist/storage-rack.d.ts +26 -2
  24. package/dist/storage-rack.js +92 -10
  25. package/dist/storage-rack.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/box.ts +18 -0
  28. package/src/crane-3d.ts +258 -93
  29. package/src/crane.ts +445 -110
  30. package/src/pallet.ts +50 -1
  31. package/src/parcel-3d.ts +23 -18
  32. package/src/parcel.ts +24 -5
  33. package/src/storage-cell.ts +23 -3
  34. package/src/storage-rack-3d.ts +47 -8
  35. package/src/storage-rack.ts +110 -10
  36. package/test/test-cell-position.ts +105 -0
  37. package/test/test-crane-geometry.ts +167 -0
  38. package/test/test-phase-h-carrier-pickable.ts +4 -3
  39. package/translations/en.json +5 -1
  40. package/translations/ja.json +5 -1
  41. package/translations/ko.json +5 -1
  42. package/translations/ms.json +5 -1
  43. package/translations/zh.json +5 -1
  44. package/tsconfig.tsbuildinfo +1 -1
package/src/crane.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
- import { Component, ComponentNature, ContainerAbstract, ContainerCapacity, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
- import type { SlotDef, State, Material3D } from '@hatiolab/things-scene'
4
+ import { Component, ComponentNature, ContainerAbstract, ContainerCapacity, RealObject, Transfer, getWorldPose, sceneComponent } from '@hatiolab/things-scene'
5
+ import type { SlotDef, State, Material3D, Transferable } from '@hatiolab/things-scene'
6
6
  import {
7
7
  CarrierHolder,
8
8
  Legendable,
@@ -35,14 +35,6 @@ export interface CraneState extends State {
35
35
  // ── 운영 상태 ──
36
36
  status?: CraneStatus
37
37
 
38
- /**
39
- * Crane 이 서비스 할 타깃 컴포넌트의 refid (또는 id).
40
- * 명시되면 그 컴포넌트 (또는 그 children) 의 위치를 stop point 로 사용 — heuristic
41
- * 으로 sibling 추정 안 함. 미명시면 sibling 안에서 추측.
42
- * 예: RackGrid 의 refid 를 지정하면 그 안의 cell 들 사이에서만 이동.
43
- */
44
- target?: string
45
-
46
38
  // ── 액추에이터 ──
47
39
  /** 마스트 따라 carriage 의 수직 위치 (mm). */
48
40
  carriageHeight?: number
@@ -62,11 +54,42 @@ export interface CraneState extends State {
62
54
  */
63
55
  forkExtension?: number
64
56
  /**
65
- * Fork *carrier 들어올림 높이* (mm). 0 = neutral (cell shelf 와 같은 면),
66
- * +값 = carrier *위로* 들어올림 (fork pocket 안으로 진입 후 위로 lifting).
57
+ * Fork carrier *cell shelf 에서 분리하기 위해 들어올리는 높이* (mm).
58
+ * 사용자가 board 에서 설정하는 *configured 진폭*. engage lift 단계 target.
59
+ * 미명시 시 default 30mm.
60
+ *
61
+ * Note: 시뮬 *진행 중 current 들림 값* 은 별도 internal transient — `forkLiftRT`
62
+ * state. 3D / 2D render 가 carriage Y 결정 시 그 값을 사용.
67
63
  */
68
64
  forkLift?: number
69
65
 
66
+ /**
67
+ * Fork lift *runtime current value* (mm). engage 가 시뮬 진행 중 매 frame
68
+ * setState 로 lerp. *사용자가 설정하는 값 아님* — internal transient.
69
+ */
70
+ forkLiftRT?: number
71
+
72
+ /**
73
+ * 자동 random simulate (visual smoke test) 시작 여부.
74
+ * - `undefined` 또는 `false` (default): 자동 시작 안 함. application 이 crane.
75
+ * pickAndPlace 등 직접 제어 시 사용.
76
+ * - `true`: added() 시 자동 시작.
77
+ */
78
+ simulate?: boolean
79
+
80
+ /**
81
+ * Carriage 의 rail-local X 위치 (0 ~ crane.width). Crane.moveTo / simulate 가
82
+ * lerp. crane 본체 (rail) 는 안 움직임 — carriage assembly (masts + carriage +
83
+ * forks) 만 rail 안 X 만 슬라이드.
84
+ */
85
+ carriagePosition?: number
86
+
87
+ /**
88
+ * Carriage 의 폭 (rail-local X 방향). 미명시 시 rail (crane.width) 의 ~10%.
89
+ * Target rack 의 cell 폭과 매칭 권유.
90
+ */
91
+ carriageWidth?: number
92
+
70
93
  // ── 3D 재질 ──
71
94
  material3d?: Material3D
72
95
  }
@@ -109,10 +132,21 @@ const NATURE: ComponentNature = {
109
132
  }
110
133
  },
111
134
  {
112
- type: 'string',
113
- label: 'target',
114
- name: 'target',
115
- placeholder: 'refid of target (e.g., a RackGrid) — crane serves only its cells'
135
+ type: 'checkbox',
136
+ label: 'simulate',
137
+ name: 'simulate'
138
+ },
139
+ {
140
+ type: 'number',
141
+ label: 'carriage-position',
142
+ name: 'carriagePosition',
143
+ placeholder: 'rail-local X (0 ~ rail width). Crane.moveTo 가 lerp.'
144
+ },
145
+ {
146
+ type: 'number',
147
+ label: 'carriage-width',
148
+ name: 'carriageWidth',
149
+ placeholder: 'rail-local 폭 (mm). 미명시 rail width × 10%.'
116
150
  },
117
151
  {
118
152
  type: 'number',
@@ -214,9 +248,59 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
214
248
 
215
249
  // ── ContainerCapacity ─────────────────────────────────────────────────────
216
250
 
217
- /** Stacker crane carries at most one load at a time on its forks. */
251
+ /**
252
+ * Stacker crane carries at most one load at a time on its forks.
253
+ *
254
+ * localPosition.z = 0 명시 — ContainerCapacity.receive 의 atomic setState 가
255
+ * `carrier.state.zPos = slot.localPosition.z + parentGeoOffset = 0` 강제.
256
+ * 미명시 시 zPos update skip → carrier 의 *이전 holder 또는 board model 의
257
+ * 임의 zPos (음수 가능)* 그대로 → _realObject 미빌드 시점 (transient placement
258
+ * 미설정 = state-driven) 에 carrier 가 *지하* 로 가는 결함.
259
+ */
218
260
  get slots(): SlotDef[] {
219
- return [{ id: 'forks', maxCount: 1 }]
261
+ return [{ id: 'forks', maxCount: 1, localPosition: { x: 0, y: 0, z: 0 } }]
262
+ }
263
+
264
+ /**
265
+ * Acceptance rotation — carrier 가 crane.fork 에 attach 시 *crane 의 local
266
+ * axis (전방)* 으로 정렬. identity quaternion 반환 → atomic setState 가
267
+ * carrier.state.rotation/X/Y = 0 강제. _realObject 미빌드 시점 (transient
268
+ * placement 미설정 = state-driven) fallback 에서도 자세 정렬.
269
+ *
270
+ * follow-holder carryPolicy 와 짝 — 두 path 모두 동일 결과 (local identity).
271
+ */
272
+ acceptanceRotation(_carrier: Component, _slot?: SlotDef): { x: number; y: number; z: number; w: number } {
273
+ return { x: 0, y: 0, z: 0, w: 1 }
274
+ }
275
+
276
+ /**
277
+ * Arrangement — carrier.state.left/top 의 *기준* 이 *crane 전체 footprint* 가
278
+ * 아닌 *carriage X (carriagePosition) + fork Z (bladeMidZ)*. carriage / fork
279
+ * 의 위치가 carrier 의 위치.
280
+ *
281
+ * left = carriagePosition − carrier.w/2 (carriage X 중심)
282
+ * top = crane.h/2 + bladeMidZ − carrier.h/2 (fork Z 중심)
283
+ */
284
+ get arrangementStrategy() {
285
+ const crane = this as any
286
+ return {
287
+ positionAt(_idx: number, _slot: any, _occ: number, component?: any) {
288
+ const cw = numOr(crane.state?.width, 100)
289
+ const ch = numOr(crane.state?.height, 100)
290
+ const carrierW = numOr(component?.state?.width, 0)
291
+ const carrierH = numOr(component?.state?.height, 0)
292
+ const carriagePos = numOr(crane.state?.carriagePosition, cw / 2)
293
+ const bladeMidZ = numOr(crane._realObject?.bladeMidZ, 0)
294
+ return {
295
+ x: carriagePos - carrierW / 2,
296
+ y: ch / 2 + bladeMidZ - carrierH / 2,
297
+ z: 0
298
+ }
299
+ },
300
+ capacity(slot: any) {
301
+ return slot.maxCount ?? Infinity
302
+ }
303
+ }
220
304
  }
221
305
 
222
306
  // ── CarrierHolder — attach frame (carriage fork position) ─────────────────
@@ -233,20 +317,17 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
233
317
  attachPointFor(carrier: Component): AttachFrame | null {
234
318
  const ro = this._realObject
235
319
  const frame = ro?.getCarriageFrame?.()
236
- if (frame) {
237
- const carrierDepth = resolveCarrierDepth(carrier)
238
- // fork blade 위 (top surface) 에 pallet pocket 끼우듯 얹힘. y = blade top + carrier/2.
239
- // z = blade 중심 (forkLength 의 반쪽 위치 — pallet center 가 blade 중간에 위치).
240
- const platformTopY = ro?.platformTopY ?? 0
241
- const bladeMidZ = ro?.bladeMidZ ?? 0
242
- return {
243
- attach: frame,
244
- localPosition: { x: 0, y: platformTopY + carrierDepth / 2, z: bladeMidZ }
245
- }
320
+ if (!frame) return null
321
+ const carrierDepth = resolveCarrierDepth(carrier)
322
+ const carrierBaseY = ro?.carrierBaseY ?? 0
323
+ const bladeMidZ = ro?.bladeMidZ ?? 0
324
+ // carrier 외부 bottom = fork blade bottom. carryPolicy: 'follow-holder' —
325
+ // carrier crane *local axis (전방)* 정렬.
326
+ return {
327
+ attach: frame,
328
+ localPosition: { x: 0, y: carrierBaseY + carrierDepth / 2, z: bladeMidZ },
329
+ carryPolicy: 'follow-holder'
246
330
  }
247
- const root = this._realObject?.object3d
248
- if (!root) return null
249
- return { attach: root }
250
331
  }
251
332
 
252
333
  // ── Mover overrides ───────────────────────────────────────────────────────
@@ -269,21 +350,184 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
269
350
  * binding sets 'moving' in monitoring mode; override pick()/place() to
270
351
  * set it in full simulation environments.
271
352
  */
353
+ /**
354
+ * Crane.moveTo — **Crane 본체 (rail) 은 안 움직임**. *내부 carriage 의 rail-
355
+ * local X 위치 (carriagePosition) 만 lerp*. 실제 gantry / AS/RS crane 의
356
+ * 자연스러운 운동 패턴.
357
+ *
358
+ * target.center 의 world 좌표를 *crane 의 local frame (rail-aligned)* 로
359
+ * 변환 → rail-local X 추출 → carriagePosition setState.
360
+ *
361
+ * Crane.state.rotation = rail 방향. carriage 가 rail 위 X 만 이동.
362
+ */
363
+ async moveTo(target: Component, options: MoveOptions = {}): Promise<void> {
364
+ // target.center 는 *parent-local* 좌표 — child component (cell) 의 경우
365
+ // *rack-local*. board-absolute 변환 필요 (= toScene).
366
+ const tcLocal = target?.center
367
+ if (!tcLocal || typeof tcLocal.x !== 'number' || typeof tcLocal.y !== 'number') {
368
+ return
369
+ }
370
+ const tcAbs = typeof (target as any).toScene === 'function'
371
+ ? (target as any).toScene(tcLocal.x, tcLocal.y)
372
+ : tcLocal
373
+ const tcx = tcAbs.x
374
+ const tcy = tcAbs.y
375
+
376
+ // Crane 도 자체 center 를 toScene 으로 (crane 이 다른 container 의 child 일 수도)
377
+ const W = (this.state.width as number) ?? 100
378
+ const craneCenterLocal = (this as any).center ?? { x: 0, y: 0 }
379
+ const craneCenterAbs = typeof (this as any).toScene === 'function'
380
+ ? (this as any).toScene(craneCenterLocal.x, craneCenterLocal.y)
381
+ : craneCenterLocal
382
+ const ccx = craneCenterAbs.x
383
+ 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
+
388
+ const dx = tcx - ccx
389
+ const dy = tcy - ccy
390
+ const railLocalX = dx * cos + dy * sin
391
+
392
+ const cw = (this.state.carriageWidth as number) ?? W * 0.1
393
+ const minPos = cw / 2
394
+ const maxPos = W - cw / 2
395
+ const newCarriagePos = Math.max(minPos, Math.min(maxPos, railLocalX + W / 2))
396
+
397
+ // X + Y 동시 lerp — carriage 의 횡 (rail) + 종 (mast) 운동 동기.
398
+ //
399
+ // 진입 위치 (= fork blade bottom world Y):
400
+ // pick (no holding): cellBottom
401
+ // place (holding): cellBottom + liftH (들린 carrier 와 함께 진입)
402
+ const tween: Record<string, number> = { carriagePosition: newCarriagePos }
403
+ const targetBottomWorldY = resolveCarrierBottomY(target)
404
+ if (targetBottomWorldY !== null) {
405
+ const isHoldingCarrier = ((this as any).components as any[] | undefined)?.some?.(
406
+ (c: any) => c?._transferSlotId === 'forks'
407
+ ) ?? false
408
+ const liftH = numOr((this.state as any).forkLift, 30)
409
+ const approachWorldY = targetBottomWorldY + (isHoldingCarrier ? liftH : 0)
410
+
411
+ const ro = this._realObject
412
+ const currentForkLift = numOr((this.state as any).forkLiftRT, 0)
413
+ const solver = ro?.solveCarriageHeightForCarrierBaseWorldY
414
+ if (typeof solver === 'function') {
415
+ tween.carriageHeight = solver.call(ro, approachWorldY, currentForkLift)
416
+ } else {
417
+ tween.carriageHeight = approachWorldY
418
+ }
419
+ }
420
+
421
+ const duration = (options.duration as number) ?? 1500
422
+ this.setState({ status: 'moving' as CraneStatus })
423
+ await this._tween(tween, duration)
424
+ this.setState({ status: 'idle' as CraneStatus })
425
+ }
426
+
272
427
  async engage(
273
428
  target: Component,
274
429
  kind: 'pick' | 'place',
275
430
  _options: MoveOptions = {}
276
431
  ): Promise<void> {
432
+ // carriageHeight 는 moveTo 안 X+Y 동시 lerp 에서 처리.
433
+ // engage 가 책임: fork extension → lift / drop → retract.
434
+ const forkLen = numOr((this.state as any).forkLength, 600)
435
+ // liftH — 사용자 설정 `forkLift` (= configured 진폭). 미설정 시 default 30mm.
436
+ // 시뮬 lerp 는 *forkLiftRT* (runtime current) state 에 적용 — `forkLift` 자체
437
+ // 는 *진폭 의미 보존* 위해 안 건드림.
438
+ const liftH = numOr((this.state as any).forkLift, 30)
439
+
440
+ // Fork side 결정 — target 이 crane 의 어느 *local Z 방향* (cross-rail, fork
441
+ // axis) 인지. things-scene 의 `getWorldPose` 로 양쪽 world pose 산출 후
442
+ // delta 를 crane local frame 으로 inverse-rotate → local Z 부호 = side.
443
+ // pick/place 동일하게 동작 (이전 코드는 carrier=cell.child 케이스에서
444
+ // rack-local vs world 좌표계 혼선으로 pick 시 방향 반대였음).
445
+ let side: 1 | -1 = +1
446
+ try {
447
+ const cranePose = getWorldPose(this)
448
+ const targetPose = getWorldPose(target)
449
+ const dv = targetPose.position.clone().sub(cranePose.position)
450
+ dv.applyQuaternion(cranePose.rotation.clone().invert())
451
+ side = dv.z >= 0 ? +1 : -1
452
+ } catch {
453
+ // fallback — pose 계산 실패 시 +1 (rotation 무관, 의미적으로 안전한 default).
454
+ }
455
+
456
+ // Animation duration — fork extend/retract 1000ms, lift/lower 600ms.
457
+ // 이전 (500ms / 300ms) 보다 2배 — 시각 자연스러움 강화.
458
+ const D_EXT = 1000
459
+ const D_LIFT = 600
460
+
461
+ // fork extension target — target 의 local Z 와 _bladeMidZ 매칭. helper 가
462
+ // inverse-solve. mismatch 시 carrier 가 *fork tip 위치 (= local Z 미일치)*
463
+ // 로 jump → 텔레포트 결함. helper 가 정확 일치 보장.
464
+ let extTarget = side * forkLen
465
+ try {
466
+ const cranePose = getWorldPose(this)
467
+ const targetPose = getWorldPose(target)
468
+ const dv = targetPose.position.clone().sub(cranePose.position)
469
+ dv.applyQuaternion(cranePose.rotation.clone().invert())
470
+ const localZ = dv.z
471
+ const solver = (this._realObject as any)?.solveForkExtensionForLocalZ
472
+ if (typeof solver === 'function') {
473
+ const ext = solver.call(this._realObject, localZ)
474
+ if (Number.isFinite(ext)) extTarget = ext
475
+ }
476
+ } catch {
477
+ // fallback — 기존 side * forkLen.
478
+ }
479
+
277
480
  if (kind === 'pick') {
278
481
  this.setState({ status: 'loading' as CraneStatus })
279
- const carrierY = resolveCarrierCenterY(target)
280
- if (carrierY !== null) {
281
- this.setState({ carriageHeight: carrierY })
482
+ await this._tween({ forkExtension: extTarget }, D_EXT)
483
+
484
+ // Mid-engage reparent — fork 가 cell 안 도달. carrier 를 fork 위로 *즉시
485
+ // snap* (animated:false). Transfer 객체 통과 → monitor panel 추적.
486
+ const source = (target as any).parent
487
+ if (source) {
488
+ try {
489
+ new Transfer({
490
+ source,
491
+ target: this as unknown as Transferable,
492
+ carrier: target,
493
+ options: { animated: false }
494
+ }).executeSync()
495
+ } catch (e) {
496
+ // Transfer 실패 — carrier 그대로. 후속 Mover.pick receive 가 처리.
497
+ }
282
498
  }
499
+
500
+ await this._tween({ forkLiftRT: liftH }, D_LIFT)
501
+ await this._tween({ forkExtension: 0 }, D_EXT)
283
502
  } else {
284
503
  this.setState({ status: 'unloading' as CraneStatus })
504
+ // 1. fork extend (cell 위에서 carrier 자리 위로 진입). extTarget = inverse-
505
+ // solved target.local.z 매칭 위치.
506
+ await this._tween({ forkExtension: extTarget }, D_EXT)
507
+
508
+ // 2. carrier lower — forkLiftRT liftH → 0. carrier 가 cell 바닥 안착.
509
+ await this._tween({ forkLiftRT: 0 }, D_LIFT)
510
+
511
+ // 3. dispatch — carrier world 위치 = cell 내 attach 위치, jump 없음. Transfer 추적.
512
+ const comps = (this as any).components as any[] | undefined
513
+ const carrier = comps?.find?.((c: any) => c?._transferSlotId === 'forks')
514
+ ?? comps?.find?.((c: any) => c?.state?.type !== 'storage-cell')
515
+ if (carrier) {
516
+ try {
517
+ new Transfer({
518
+ source: this as unknown as Transferable,
519
+ target: target as unknown as Transferable,
520
+ carrier,
521
+ options: { animated: false }
522
+ }).executeSync()
523
+ } catch (e) {
524
+ // dispatch 실패 — Mover.place 가 후속 처리.
525
+ }
526
+ }
527
+
528
+ // 4. fork retract — 빈 fork.
529
+ await this._tween({ forkExtension: 0 }, D_EXT)
285
530
  }
286
- // In a full simulation: await carriage-motion tween here.
287
531
  }
288
532
 
289
533
  // ── Domain aliases ────────────────────────────────────────────────────────
@@ -326,9 +570,14 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
326
570
  const depth = num(state.depth, Math.max(width, height) * 4)
327
571
  const carriageHeight = clamp(num(state.carriageHeight, 0), 0, depth)
328
572
  const forkExtension = num(state.forkExtension, 0) // ± (rail 양쪽 rack)
329
- const forkLift = num(state.forkLift, 0)
330
-
331
- const cx = left + width / 2
573
+ // forkLiftRT *시뮬 runtime current 들림*. *configured 진폭* (state.forkLift)
574
+ // 와 분리. 시뮬 미진행 시 0 (carriage 가 mast 위 carriageHeight 만큼).
575
+ const forkLift = num(state.forkLiftRT, 0)
576
+
577
+ // carriagePosition: rail-local X (0 ~ width). default = rail center.
578
+ // 사용자 의도 — Crane 본체 (rail) 안 움직이고 carriage assembly 만 X 슬라이드.
579
+ const carriagePosition = clamp(num(state.carriagePosition, width / 2), 0, width)
580
+ const cx = left + carriagePosition // masts/carriage/forks 의 중심 X
332
581
  const cy = top + height / 2
333
582
  // 적재 여부 — forkLift > 0 (시뮬 중 들어올린 상태) 또는 state.loaded 명시.
334
583
  // monitoring 모드는 외부 데이터로 state.loaded 바인딩.
@@ -348,16 +597,28 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
348
597
 
349
598
  ctx.save()
350
599
 
351
- // 1. Rail — 위/아래
352
- const railW = Math.max(1.5, height * 0.03)
600
+ // 0. Rail footprint 반투명 회색 fill + 외곽선. 사용자가 board 에서 crane 의
601
+ // *rail 영역* 명확히 인식 + 편집 핸들 가능.
602
+ ctx.fillStyle = '#aab0b8'
603
+ ctx.globalAlpha = 0.15
604
+ ctx.fillRect(left, top, width, height)
605
+ ctx.globalAlpha = 1
606
+ ctx.strokeStyle = DARK
607
+ ctx.lineWidth = 1
608
+ ctx.strokeRect(left, top, width, height)
609
+
610
+ // 1. Rail — 하단 한 줄 (얇은 가이드). 톤 절제 — outer footprint 가 이미 영역 표시.
611
+ const railW = Math.max(1, height * 0.02)
353
612
  ctx.fillStyle = DARK
354
- ctx.fillRect(left, top, width, railW)
613
+ ctx.globalAlpha = 0.6
355
614
  ctx.fillRect(left, top + height - railW, width, railW)
615
+ ctx.globalAlpha = 1
356
616
 
357
- // 2. Twin masts — orange 사각형. mast 길이가 *3D mast height (depth)* 를
358
- // 의미하므로, 위에 carriage 위치 indicator 매핑하면 1층/N층 인식 가능.
359
- const mastW = Math.max(2.5, width * 0.07)
360
- const mastSpacing = width * 0.75 // 넉넉히 — carrier 가 mast 잠식 안 하도록
617
+ // 2. Twin masts — *carriageWidth 기반* (crane.width 분리). rail (= crane.width)
618
+ // 넓어져도 carriage assembly *carriageWidth 만큼* 만.
619
+ const carriageW = num(state.carriageWidth, width * 0.1) // default rail 의 10%
620
+ const mastW = Math.max(2.5, carriageW * 0.15)
621
+ const mastSpacing = carriageW * 0.85
361
622
  const mastY = top + railW
362
623
  const mastH = height - railW * 2
363
624
  const mastX1 = cx - mastSpacing / 2 - mastW / 2
@@ -384,23 +645,22 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
384
645
  ctx.fillRect(mastX1 - 2, indY, indWidth, indLen)
385
646
  ctx.fillRect(mastX2 - 2, indY, indWidth, indLen)
386
647
 
387
- // 3. Carriage (= 적재 deck) — 팔레트 1200×800mm aspect 반영. *폭만* sizeMul 적용 (높이감),
388
- // 길이 (Y) 고정. carrier / fork 모두 이 carriage 기준으로 derive.
389
- const carriageW = (mastSpacing - mastW) * sizeMul
648
+ // 3. Carriage deck — 팔레트 1200×800mm aspect 반영. *폭만* sizeMul 적용 (높이감),
649
+ // 길이 (Y) 고정. carrier / fork 모두 이 deck 기준으로 derive.
650
+ const deckW = (mastSpacing - mastW) * sizeMul
390
651
  const carriageH = Math.min((mastSpacing - mastW) * 1.3, (height - railW * 2) * 0.55)
391
- const carriageX = cx - carriageW / 2
652
+ const carriageX = cx - deckW / 2
392
653
  const carriageY = cy - carriageH / 2
393
654
  ctx.fillStyle = CARRIAGE_C
394
- ctx.fillRect(carriageX, carriageY, carriageW, carriageH)
655
+ ctx.fillRect(carriageX, carriageY, deckW, carriageH)
395
656
 
396
657
  // 4. Fork — 좌우 균형:
397
658
  // - 양쪽 모두 *작은 stub* 항상 표시 (crane 이 양쪽 rack 사이 있는 인상)
398
659
  // - extension 부호 (+/-) 에 따라 active 쪽이 추가로 신축
399
660
  // - 신축 길이 = |forkExtension| (실제 reach 만큼)
400
661
  const stubLen = Math.max(2, carriageH * 0.5)
401
- // 포크 폭 — carriage 의 80% (carrier 90% 보다 약간 좁아 carrier 가 fork 양옆으로
402
- // 살짝 overhang). carriage sizeMul 적용된 폭이라 fork 도 자동 scale 반영.
403
- const forkW = carriageW * 0.8
662
+ // 포크 폭 — deck 의 80%.
663
+ const forkW = deckW * 0.8
404
664
  const forkX = cx - forkW / 2
405
665
  const extLen = Math.abs(forkExtension)
406
666
  const sign = forkExtension >= 0 ? 1 : -1
@@ -424,26 +684,20 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
424
684
  if (carrying) {
425
685
  // 팔레트 — 항상 carriage 의 90% (폭/길이 모두). carriage 가 sizeMul 적용된 폭이라
426
686
  // carrier 도 자동으로 sizeMul 반영. 길이는 carriage 길이 고정에 따라 고정.
427
- const carrW = carriageW * 0.9
687
+ const carrW = deckW * 0.9
428
688
  const carrH = carriageH * 0.9
429
- let carrCenterY: number
430
- if (extLen > 0.5) {
431
- // 포크의 *중간* 에 carrier 배치 — fork prong 이 pallet pocket 에 끼워진 자연스러운
432
- // 위치 (현실 AS/RS 에서 pallet 이 fork 끝이 아닌 fork 길이 중간 부근에 안착).
433
- const forkMid = (stubLen + extLen) / 2
434
- carrCenterY = sign > 0
435
- ? carriageY + carriageH + forkMid
436
- : carriageY - forkMid
437
- } else {
438
- carrCenterY = cy // transit
439
- }
689
+ // Carrier 2D 위치 — 3D _bladeMidZ 와 동일 공식 (sign * extLen).
690
+ // extLen=0 → carriage 정중앙. extLen 증가 cell 방향 으로 진출.
691
+ const offset = extLen
692
+ const carrCenterY = sign > 0
693
+ ? carriageY + carriageH / 2 + offset
694
+ : carriageY + carriageH / 2 - offset
440
695
  const carrX = cx - carrW / 2
441
696
  const carrY = carrCenterY - carrH / 2
442
697
 
443
698
  // 들린 상태 — drop shadow 로 *떠 있는* 인상 (forkLift > 0). offset 비율 = lift 강도.
444
699
  if (forkLift > 0) {
445
- const forkLengthRef = num(state.forkLength, height * 0.4)
446
- const liftMax = Math.max(forkLengthRef * 0.05, 20) // forkLift 정상 범위
700
+ const liftMax = Math.max(num(state.forkLift, 30), 20) // configured 진폭
447
701
  const liftRatio = clamp(forkLift / liftMax, 0.3, 1) // 최소 30% 표시 (있다는 것만 보이게)
448
702
  const shadowOff = Math.max(2, Math.min(carrW, carrH) * 0.14) * liftRatio
449
703
  ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'
@@ -470,17 +724,90 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
470
724
 
471
725
  added(parent: any): void {
472
726
  super.added?.(parent)
727
+
728
+ // state ↔ visual single source of truth. state 미설정 시 _canonicalDefault
729
+ // 로 명시 초기화 — build / _tween / 외부 read 모두 동일 값.
730
+ const init: Record<string, number> = {}
731
+ for (const k of ['carriagePosition', 'carriageHeight', 'forkExtension', 'forkLiftRT']) {
732
+ if (typeof (this.state as any)[k] !== 'number') {
733
+ init[k] = this._canonicalDefault(k)
734
+ }
735
+ }
736
+ if (Object.keys(init).length > 0) this.setState(init)
737
+
738
+ // state.simulate === true 명시 시만 자동 시작. mode 연동은 board view layer
739
+ // 책임 — 컴포넌트 자체는 simulate state 만 본다 (sorter/conveyor 등 다른
740
+ // 시뮬 컴포넌트와 동일 패턴).
741
+ if ((this.state as CraneState).simulate !== true) return
742
+ this._startAutoSimulate()
743
+ }
744
+
745
+ /**
746
+ * Actuator state 의 *canonical default*. state 미설정 시 모든 read 경로
747
+ * (build / _tween / 외부) 가 *동일 값* 보도록.
748
+ *
749
+ * single source 패턴 — visual fallback 과 state fallback 이 *분리* 되어
750
+ * 첫 _tween 시 start 가 visual current 와 어긋나는 jump 결함을 근본 차단.
751
+ */
752
+ _canonicalDefault(key: string): number {
753
+ const state = this.state as any
754
+ const W = numOr(state.width, 100)
755
+ const H = numOr(state.height, 100)
756
+ const D = numOr(state.depth, Math.max(W, H) * 4)
757
+ switch (key) {
758
+ case 'carriagePosition': return W / 2
759
+ case 'carriageHeight': return D * 0.4
760
+ case 'forkExtension':
761
+ case 'forkLiftRT':
762
+ case 'forkLift':
763
+ return 0
764
+ default: return 0
765
+ }
766
+ }
767
+
768
+ private _startAutoSimulate(): void {
473
769
  if (this._simStarted) return
474
770
  this._simStarted = true
475
771
  // 초기 지연 800~2800ms 사이 random — 여러 crane 이 동기적으로 시작하지 않도록.
476
- // added 에서 시작 → 2D/3D 모드 무관. buildRealObject 는 3D 진입 시만 호출돼 시점이
477
- // 늦거나 누락될 수 있으므로 부적합.
478
772
  const initialDelay = 800 + Math.random() * 2000
479
773
  setTimeout(() => {
774
+ if (this._simAbort || (this.state as CraneState).simulate !== true) return
480
775
  this.simulate().catch(e => console.error('[Crane] simulate', e))
481
776
  }, initialDelay)
482
777
  }
483
778
 
779
+ /**
780
+ * state.simulate 변경 시 자동 simulate 시작/중단. application 이 런타임 toggle
781
+ * 가능 (예: editor 에서 simulate off, view 에서 on).
782
+ */
783
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>): void {
784
+ super.onchange(after as any, before as any)
785
+ // carriage 관련 state 변경 시 2D render 명시 invalidate. things-scene 의
786
+ // setState → trigger change 가 render queue 에 자동 등록 못 하는 케이스 방어.
787
+ if (
788
+ 'carriagePosition' in after ||
789
+ 'carriageWidth' in after ||
790
+ 'carriageHeight' in after ||
791
+ 'forkExtension' in after ||
792
+ 'forkLift' in after ||
793
+ 'forkLiftRT' in after
794
+ ) {
795
+ ;(this as any).invalidate?.()
796
+ }
797
+ if ('simulate' in after) {
798
+ if (after.simulate === true) {
799
+ // 명시 true → 자동 시작 (이미 시작 중이면 noop). mode gating 은
800
+ // frameClock (animate 의 simTime) 이 자동 처리 — modeler 면 simTime
801
+ // 안 흐름, _tween step 호출 안 됨.
802
+ this._simAbort = false
803
+ this._simStarted = false
804
+ this._startAutoSimulate()
805
+ } else {
806
+ this._simAbort = true
807
+ }
808
+ }
809
+ }
810
+
484
811
  // ── 3D ───────────────────────────────────────────────────────────────────
485
812
 
486
813
  buildRealObject(): RealObject | undefined {
@@ -524,17 +851,14 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
524
851
  private _initRailRange(): void {
525
852
  this._railMin = this._railMax = NaN
526
853
 
527
- // 1순위: state.target refid 명시. 2순위: crane 의 parent 컴포넌트 (자연스러운
528
- // "이 컨테이너 안에서만 움직여" 기본값).
529
- const targetRefId = (this.state as any).target as string | undefined
854
+ // simulate cycle random pickAndPlace range crane 의 parent 컨테이너
855
+ // (= 보통 rack 또는 board) 의 bbox 를 기준으로. parent 가 top-level
856
+ // (root/model-layer) 이면 bbox 의미 없음 skip.
530
857
  const root = (this as any).root
531
- let target = targetRefId ? root?.findById?.(targetRefId) : null
532
- if (!target) {
533
- const parent = (this as any).parent
534
- // parent 가 root/model-layer 같은 top-level 이면 bbox 가 의미없음 → skip
535
- if (parent && parent !== root && (parent.state as any)?.width > 0) {
536
- target = parent
537
- }
858
+ const parent = (this as any).parent
859
+ let target: any = null
860
+ if (parent && parent !== root && (parent.state as any)?.width > 0) {
861
+ target = parent
538
862
  }
539
863
  if (!target) return
540
864
 
@@ -611,20 +935,16 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
611
935
  const H = numOr((this.state as any).height, 100)
612
936
  const D = numOr((this.state as any).depth, W * 4)
613
937
  const forkLen = numOr((this.state as any).forkLength, H * 0.6)
938
+ const cw = numOr((this.state as any).carriageWidth, W * 0.1)
614
939
 
615
- if (!Number.isFinite(this._railMin) || !Number.isFinite(this._railMax)) {
616
- await new Promise(r => setTimeout(r, 800))
617
- return
618
- }
619
-
620
- const pickRail = () => this._railMin + Math.random() * Math.max(0, this._railMax - this._railMin)
621
- const toLeftTop = (rail: number) => ({
622
- left: this._railOriginX + rail * this._railCos - W / 2,
623
- top: this._railOriginY + rail * this._railSin - H / 2
624
- })
940
+ // carriagePosition valid range — rail 안 [cw/2, W - cw/2].
941
+ // crane 본체 (rail) 움직임 — *carriage assembly 만* X 슬라이드.
942
+ const minPos = cw / 2
943
+ const maxPos = Math.max(minPos, W - cw / 2)
944
+ const pickPos = () => minPos + Math.random() * (maxPos - minPos)
625
945
 
626
- const source = toLeftTop(pickRail())
627
- const dest = toLeftTop(pickRail())
946
+ const sourcePos = pickPos()
947
+ const destPos = pickPos()
628
948
 
629
949
  const sourceCH = Math.random() * D * 0.75
630
950
  const destCH = Math.random() * D * 0.75
@@ -636,14 +956,14 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
636
956
  // Tween duration 은 base 의 70~130% 사이 random — 여러 crane 이 같은 타이밍으로 안 보이도록.
637
957
  const jitter = (base: number): number => base * (0.7 + Math.random() * 0.6)
638
958
 
639
- // 이동: crane local X 방향 → canvas (left, top) 다 동시 변경 (rotation 적용 시 diagonal)
640
- await this._tween({ status: 'moving', left: source.left, top: source.top, carriageHeight: sourceCH }, jitter(1500))
959
+ // 이동: carriagePosition 변경 (crane.left/top 건드림).
960
+ await this._tween({ status: 'moving', carriagePosition: sourcePos, carriageHeight: sourceCH }, jitter(1500))
641
961
  await this._tween({ status: 'loading', forkExtension: sideA * forkLen }, jitter(700))
642
- await this._tween({ forkLift: liftH }, jitter(400))
962
+ await this._tween({ forkLiftRT: liftH }, jitter(400))
643
963
  await this._tween({ forkExtension: 0 }, jitter(700))
644
- await this._tween({ status: 'moving', left: dest.left, top: dest.top, carriageHeight: destCH }, jitter(1500))
964
+ await this._tween({ status: 'moving', carriagePosition: destPos, carriageHeight: destCH }, jitter(1500))
645
965
  await this._tween({ status: 'unloading', forkExtension: sideB * forkLen }, jitter(700))
646
- await this._tween({ forkLift: 0 }, jitter(400))
966
+ await this._tween({ forkLiftRT: 0 }, jitter(400))
647
967
  await this._tween({ status: 'idle', forkExtension: 0 }, jitter(700))
648
968
 
649
969
  // 사이클 사이 짧은 idle (200~1000ms) — 자연스러운 phase 분산
@@ -651,15 +971,16 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
651
971
  }
652
972
 
653
973
  /**
654
- * things-scene 의 `component.animate()` 로 한 구간 tween. step 콜백에서 setState 호출.
655
- * frameClock.simTime 기반이라 scene pause / speed change / suppressAnimations 모두 자동 대응.
974
+ * frameClock 기반 tween — things-scene 의 표준 `Component.animate()` 사용.
975
+ * frameClock.simTime 진행 (view mode + scene 활성) 시에만 step 호출 modeler
976
+ * 또는 paused 시 자동 정지. scene.simulationSpeed 도 자동 대응.
656
977
  */
657
978
  private _tween(targets: Record<string, any>, duration: number): Promise<void> {
658
979
  const start: Record<string, number> = {}
659
980
  for (const k of Object.keys(targets)) {
660
981
  if (k === 'status') continue
661
982
  const v = (this.state as any)[k]
662
- start[k] = typeof v === 'number' && Number.isFinite(v) ? v : 0
983
+ start[k] = typeof v === 'number' && Number.isFinite(v) ? v : this._canonicalDefault(k)
663
984
  }
664
985
  if (typeof targets.status === 'string') {
665
986
  this.setState({ status: targets.status })
@@ -676,10 +997,6 @@ export default class Crane extends Mover(CarrierHolder(ContainerCapacity(Legenda
676
997
  ease: 'inout',
677
998
  delta: 'quad',
678
999
  step: (dx: number) => {
679
- if (this._simAbort) {
680
- controller.stop?.()
681
- return finish()
682
- }
683
1000
  const update: Record<string, number> = {}
684
1001
  for (const k of Object.keys(start)) {
685
1002
  const tgt = targets[k]
@@ -701,13 +1018,31 @@ function resolveCarrierDepth(c: Component): number {
701
1018
  return numOr((c as any)?.state?.depth, 0)
702
1019
  }
703
1020
 
704
- function resolveCarrierCenterY(c: Component): number | null {
705
- const pos = (c as any).state
706
- if (!pos) return null
707
- // zPos is the 3D Y center of a Placeable component in things-scene
708
- const zPos = numOr(pos.zPos, NaN)
709
- if (!Number.isNaN(zPos)) return zPos
710
- return null
1021
+
1022
+ /**
1023
+ * Target 의 *world bottom Y* — fork blade 가 위치해야 할 높이.
1024
+ *
1025
+ * `getWorldPose(c).position.y` 는 *center Y*. fork 는 carrier 의 *바닥* (pallet
1026
+ * pocket 진입 면) 위치해야 함 → center 에서 depth/2 만큼 빼서 bottom.
1027
+ *
1028
+ * `effectiveDepth` 는 RealObject 가 자동 계산한 실제 3D Y 키. 미가용 시 state.
1029
+ * depth fallback.
1030
+ */
1031
+ function resolveCarrierBottomY(c: Component): number | null {
1032
+ if (!c) return null
1033
+ try {
1034
+ const pose = getWorldPose(c)
1035
+ const centerY = pose?.position?.y
1036
+ if (typeof centerY !== 'number' || !Number.isFinite(centerY)) return null
1037
+ const ro = (c as any)._realObject
1038
+ const eff = ro?.effectiveDepth
1039
+ const depth = typeof eff === 'number' && Number.isFinite(eff)
1040
+ ? eff
1041
+ : numOr((c as any)?.state?.depth, 0)
1042
+ return centerY - depth / 2
1043
+ } catch {
1044
+ return null
1045
+ }
711
1046
  }
712
1047
 
713
1048
  function numOr(v: unknown, dflt: number): number {