@operato/scene-storage 10.0.0-beta.38 → 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 +25 -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/dist/crane.js CHANGED
@@ -2,7 +2,7 @@ import { __decorate } from "tslib";
2
2
  /*
3
3
  * Copyright © HatioLab Inc. All rights reserved.
4
4
  */
5
- import { ContainerAbstract, ContainerCapacity, sceneComponent } from '@hatiolab/things-scene';
5
+ import { ContainerAbstract, ContainerCapacity, Transfer, getWorldPose, sceneComponent } from '@hatiolab/things-scene';
6
6
  import { CarrierHolder, Legendable, Mover, Placeable } from '@operato/scene-base';
7
7
  import { Crane3D } from './crane-3d.js';
8
8
  const BODY_LEGEND = {
@@ -41,10 +41,21 @@ const NATURE = {
41
41
  }
42
42
  },
43
43
  {
44
- type: 'string',
45
- label: 'target',
46
- name: 'target',
47
- placeholder: 'refid of target (e.g., a RackGrid) — crane serves only its cells'
44
+ type: 'checkbox',
45
+ label: 'simulate',
46
+ name: 'simulate'
47
+ },
48
+ {
49
+ type: 'number',
50
+ label: 'carriage-position',
51
+ name: 'carriagePosition',
52
+ placeholder: 'rail-local X (0 ~ rail width). Crane.moveTo 가 lerp.'
53
+ },
54
+ {
55
+ type: 'number',
56
+ label: 'carriage-width',
57
+ name: 'carriageWidth',
58
+ placeholder: 'rail-local 폭 (mm). 미명시 rail width × 10%.'
48
59
  },
49
60
  {
50
61
  type: 'number',
@@ -134,9 +145,57 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
134
145
  return [];
135
146
  }
136
147
  // ── ContainerCapacity ─────────────────────────────────────────────────────
137
- /** Stacker crane carries at most one load at a time on its forks. */
148
+ /**
149
+ * Stacker crane carries at most one load at a time on its forks.
150
+ *
151
+ * localPosition.z = 0 명시 — ContainerCapacity.receive 의 atomic setState 가
152
+ * `carrier.state.zPos = slot.localPosition.z + parentGeoOffset = 0` 강제.
153
+ * 미명시 시 zPos update skip → carrier 의 *이전 holder 또는 board model 의
154
+ * 임의 zPos (음수 가능)* 그대로 → _realObject 미빌드 시점 (transient placement
155
+ * 미설정 = state-driven) 에 carrier 가 *지하* 로 가는 결함.
156
+ */
138
157
  get slots() {
139
- return [{ id: 'forks', maxCount: 1 }];
158
+ return [{ id: 'forks', maxCount: 1, localPosition: { x: 0, y: 0, z: 0 } }];
159
+ }
160
+ /**
161
+ * Acceptance rotation — carrier 가 crane.fork 에 attach 시 *crane 의 local
162
+ * axis (전방)* 으로 정렬. identity quaternion 반환 → atomic setState 가
163
+ * carrier.state.rotation/X/Y = 0 강제. _realObject 미빌드 시점 (transient
164
+ * placement 미설정 = state-driven) fallback 에서도 자세 정렬.
165
+ *
166
+ * follow-holder carryPolicy 와 짝 — 두 path 모두 동일 결과 (local identity).
167
+ */
168
+ acceptanceRotation(_carrier, _slot) {
169
+ return { x: 0, y: 0, z: 0, w: 1 };
170
+ }
171
+ /**
172
+ * Arrangement — carrier.state.left/top 의 *기준* 이 *crane 전체 footprint* 가
173
+ * 아닌 *carriage X (carriagePosition) + fork Z (bladeMidZ)*. carriage / fork
174
+ * 의 위치가 carrier 의 위치.
175
+ *
176
+ * left = carriagePosition − carrier.w/2 (carriage X 중심)
177
+ * top = crane.h/2 + bladeMidZ − carrier.h/2 (fork Z 중심)
178
+ */
179
+ get arrangementStrategy() {
180
+ const crane = this;
181
+ return {
182
+ positionAt(_idx, _slot, _occ, component) {
183
+ const cw = numOr(crane.state?.width, 100);
184
+ const ch = numOr(crane.state?.height, 100);
185
+ const carrierW = numOr(component?.state?.width, 0);
186
+ const carrierH = numOr(component?.state?.height, 0);
187
+ const carriagePos = numOr(crane.state?.carriagePosition, cw / 2);
188
+ const bladeMidZ = numOr(crane._realObject?.bladeMidZ, 0);
189
+ return {
190
+ x: carriagePos - carrierW / 2,
191
+ y: ch / 2 + bladeMidZ - carrierH / 2,
192
+ z: 0
193
+ };
194
+ },
195
+ capacity(slot) {
196
+ return slot.maxCount ?? Infinity;
197
+ }
198
+ };
140
199
  }
141
200
  // ── CarrierHolder — attach frame (carriage fork position) ─────────────────
142
201
  /**
@@ -151,21 +210,18 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
151
210
  attachPointFor(carrier) {
152
211
  const ro = this._realObject;
153
212
  const frame = ro?.getCarriageFrame?.();
154
- if (frame) {
155
- const carrierDepth = resolveCarrierDepth(carrier);
156
- // fork blade 위 (top surface) 에 pallet pocket 끼우듯 얹힘. y = blade top + carrier/2.
157
- // z = blade 중심 (forkLength 의 반쪽 위치 — pallet center 가 blade 중간에 위치).
158
- const platformTopY = ro?.platformTopY ?? 0;
159
- const bladeMidZ = ro?.bladeMidZ ?? 0;
160
- return {
161
- attach: frame,
162
- localPosition: { x: 0, y: platformTopY + carrierDepth / 2, z: bladeMidZ }
163
- };
164
- }
165
- const root = this._realObject?.object3d;
166
- if (!root)
213
+ if (!frame)
167
214
  return null;
168
- return { attach: root };
215
+ const carrierDepth = resolveCarrierDepth(carrier);
216
+ const carrierBaseY = ro?.carrierBaseY ?? 0;
217
+ const bladeMidZ = ro?.bladeMidZ ?? 0;
218
+ // carrier 외부 bottom = fork blade bottom. carryPolicy: 'follow-holder' —
219
+ // carrier 가 crane 의 *local axis (전방)* 정렬.
220
+ return {
221
+ attach: frame,
222
+ localPosition: { x: 0, y: carrierBaseY + carrierDepth / 2, z: bladeMidZ },
223
+ carryPolicy: 'follow-holder'
224
+ };
169
225
  }
170
226
  // ── Mover overrides ───────────────────────────────────────────────────────
171
227
  /**
@@ -186,18 +242,169 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
186
242
  * binding sets 'moving' in monitoring mode; override pick()/place() to
187
243
  * set it in full simulation environments.
188
244
  */
245
+ /**
246
+ * Crane.moveTo — **Crane 본체 (rail) 은 안 움직임**. *내부 carriage 의 rail-
247
+ * local X 위치 (carriagePosition) 만 lerp*. 실제 gantry / AS/RS crane 의
248
+ * 자연스러운 운동 패턴.
249
+ *
250
+ * target.center 의 world 좌표를 *crane 의 local frame (rail-aligned)* 로
251
+ * 변환 → rail-local X 추출 → carriagePosition setState.
252
+ *
253
+ * Crane.state.rotation = rail 방향. carriage 가 rail 위 X 만 이동.
254
+ */
255
+ async moveTo(target, options = {}) {
256
+ // target.center 는 *parent-local* 좌표 — child component (cell) 의 경우
257
+ // *rack-local*. board-absolute 변환 필요 (= toScene).
258
+ const tcLocal = target?.center;
259
+ if (!tcLocal || typeof tcLocal.x !== 'number' || typeof tcLocal.y !== 'number') {
260
+ return;
261
+ }
262
+ const tcAbs = typeof target.toScene === 'function'
263
+ ? target.toScene(tcLocal.x, tcLocal.y)
264
+ : tcLocal;
265
+ const tcx = tcAbs.x;
266
+ const tcy = tcAbs.y;
267
+ // Crane 도 자체 center 를 toScene 으로 (crane 이 다른 container 의 child 일 수도)
268
+ const W = this.state.width ?? 100;
269
+ const craneCenterLocal = this.center ?? { x: 0, y: 0 };
270
+ const craneCenterAbs = typeof this.toScene === 'function'
271
+ ? this.toScene(craneCenterLocal.x, craneCenterLocal.y)
272
+ : craneCenterLocal;
273
+ const ccx = craneCenterAbs.x;
274
+ const ccy = craneCenterAbs.y;
275
+ const rotation = this.state.rotation ?? 0;
276
+ const cos = Math.cos(rotation);
277
+ const sin = Math.sin(rotation);
278
+ const dx = tcx - ccx;
279
+ const dy = tcy - ccy;
280
+ const railLocalX = dx * cos + dy * sin;
281
+ const cw = this.state.carriageWidth ?? W * 0.1;
282
+ const minPos = cw / 2;
283
+ const maxPos = W - cw / 2;
284
+ const newCarriagePos = Math.max(minPos, Math.min(maxPos, railLocalX + W / 2));
285
+ // X + Y 동시 lerp — carriage 의 횡 (rail) + 종 (mast) 운동 동기.
286
+ //
287
+ // 진입 위치 (= fork blade bottom world Y):
288
+ // pick (no holding): cellBottom
289
+ // place (holding): cellBottom + liftH (들린 carrier 와 함께 진입)
290
+ const tween = { carriagePosition: newCarriagePos };
291
+ const targetBottomWorldY = resolveCarrierBottomY(target);
292
+ if (targetBottomWorldY !== null) {
293
+ const isHoldingCarrier = this.components?.some?.((c) => c?._transferSlotId === 'forks') ?? false;
294
+ const liftH = numOr(this.state.forkLift, 30);
295
+ const approachWorldY = targetBottomWorldY + (isHoldingCarrier ? liftH : 0);
296
+ const ro = this._realObject;
297
+ const currentForkLift = numOr(this.state.forkLiftRT, 0);
298
+ const solver = ro?.solveCarriageHeightForCarrierBaseWorldY;
299
+ if (typeof solver === 'function') {
300
+ tween.carriageHeight = solver.call(ro, approachWorldY, currentForkLift);
301
+ }
302
+ else {
303
+ tween.carriageHeight = approachWorldY;
304
+ }
305
+ }
306
+ const duration = options.duration ?? 1500;
307
+ this.setState({ status: 'moving' });
308
+ await this._tween(tween, duration);
309
+ this.setState({ status: 'idle' });
310
+ }
189
311
  async engage(target, kind, _options = {}) {
312
+ // carriageHeight 는 moveTo 안 X+Y 동시 lerp 에서 처리.
313
+ // engage 가 책임: fork extension → lift / drop → retract.
314
+ const forkLen = numOr(this.state.forkLength, 600);
315
+ // liftH — 사용자 설정 `forkLift` (= configured 진폭). 미설정 시 default 30mm.
316
+ // 시뮬 lerp 는 *forkLiftRT* (runtime current) state 에 적용 — `forkLift` 자체
317
+ // 는 *진폭 의미 보존* 위해 안 건드림.
318
+ const liftH = numOr(this.state.forkLift, 30);
319
+ // Fork side 결정 — target 이 crane 의 어느 *local Z 방향* (cross-rail, fork
320
+ // axis) 인지. things-scene 의 `getWorldPose` 로 양쪽 world pose 산출 후
321
+ // delta 를 crane local frame 으로 inverse-rotate → local Z 부호 = side.
322
+ // pick/place 동일하게 동작 (이전 코드는 carrier=cell.child 케이스에서
323
+ // rack-local vs world 좌표계 혼선으로 pick 시 방향 반대였음).
324
+ let side = +1;
325
+ try {
326
+ const cranePose = getWorldPose(this);
327
+ const targetPose = getWorldPose(target);
328
+ const dv = targetPose.position.clone().sub(cranePose.position);
329
+ dv.applyQuaternion(cranePose.rotation.clone().invert());
330
+ side = dv.z >= 0 ? +1 : -1;
331
+ }
332
+ catch {
333
+ // fallback — pose 계산 실패 시 +1 (rotation 무관, 의미적으로 안전한 default).
334
+ }
335
+ // Animation duration — fork extend/retract 1000ms, lift/lower 600ms.
336
+ // 이전 (500ms / 300ms) 보다 2배 — 시각 자연스러움 강화.
337
+ const D_EXT = 1000;
338
+ const D_LIFT = 600;
339
+ // fork extension target — target 의 local Z 와 _bladeMidZ 매칭. helper 가
340
+ // inverse-solve. mismatch 시 carrier 가 *fork tip 위치 (= local Z 미일치)*
341
+ // 로 jump → 텔레포트 결함. helper 가 정확 일치 보장.
342
+ let extTarget = side * forkLen;
343
+ try {
344
+ const cranePose = getWorldPose(this);
345
+ const targetPose = getWorldPose(target);
346
+ const dv = targetPose.position.clone().sub(cranePose.position);
347
+ dv.applyQuaternion(cranePose.rotation.clone().invert());
348
+ const localZ = dv.z;
349
+ const solver = this._realObject?.solveForkExtensionForLocalZ;
350
+ if (typeof solver === 'function') {
351
+ const ext = solver.call(this._realObject, localZ);
352
+ if (Number.isFinite(ext))
353
+ extTarget = ext;
354
+ }
355
+ }
356
+ catch {
357
+ // fallback — 기존 side * forkLen.
358
+ }
190
359
  if (kind === 'pick') {
191
360
  this.setState({ status: 'loading' });
192
- const carrierY = resolveCarrierCenterY(target);
193
- if (carrierY !== null) {
194
- this.setState({ carriageHeight: carrierY });
361
+ await this._tween({ forkExtension: extTarget }, D_EXT);
362
+ // Mid-engage reparent fork 가 cell 안 도달. carrier 를 fork 위로 *즉시
363
+ // snap* (animated:false). Transfer 객체 통과 → monitor panel 추적.
364
+ const source = target.parent;
365
+ if (source) {
366
+ try {
367
+ new Transfer({
368
+ source,
369
+ target: this,
370
+ carrier: target,
371
+ options: { animated: false }
372
+ }).executeSync();
373
+ }
374
+ catch (e) {
375
+ // Transfer 실패 — carrier 그대로. 후속 Mover.pick receive 가 처리.
376
+ }
195
377
  }
378
+ await this._tween({ forkLiftRT: liftH }, D_LIFT);
379
+ await this._tween({ forkExtension: 0 }, D_EXT);
196
380
  }
197
381
  else {
198
382
  this.setState({ status: 'unloading' });
383
+ // 1. fork extend (cell 위에서 carrier 자리 위로 진입). extTarget = inverse-
384
+ // solved target.local.z 매칭 위치.
385
+ await this._tween({ forkExtension: extTarget }, D_EXT);
386
+ // 2. carrier lower — forkLiftRT liftH → 0. carrier 가 cell 바닥 안착.
387
+ await this._tween({ forkLiftRT: 0 }, D_LIFT);
388
+ // 3. dispatch — carrier world 위치 = cell 내 attach 위치, jump 없음. Transfer 추적.
389
+ const comps = this.components;
390
+ const carrier = comps?.find?.((c) => c?._transferSlotId === 'forks')
391
+ ?? comps?.find?.((c) => c?.state?.type !== 'storage-cell');
392
+ if (carrier) {
393
+ try {
394
+ new Transfer({
395
+ source: this,
396
+ target: target,
397
+ carrier,
398
+ options: { animated: false }
399
+ }).executeSync();
400
+ }
401
+ catch (e) {
402
+ // dispatch 실패 — Mover.place 가 후속 처리.
403
+ }
404
+ }
405
+ // 4. fork retract — 빈 fork.
406
+ await this._tween({ forkExtension: 0 }, D_EXT);
199
407
  }
200
- // In a full simulation: await carriage-motion tween here.
201
408
  }
202
409
  // ── Domain aliases ────────────────────────────────────────────────────────
203
410
  /** Fetch a carrier from a rack cell (semantically = pick). */
@@ -234,8 +441,13 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
234
441
  const depth = num(state.depth, Math.max(width, height) * 4);
235
442
  const carriageHeight = clamp(num(state.carriageHeight, 0), 0, depth);
236
443
  const forkExtension = num(state.forkExtension, 0); // ± (rail 양쪽 rack)
237
- const forkLift = num(state.forkLift, 0);
238
- const cx = left + width / 2;
444
+ // forkLiftRT *시뮬 runtime current 들림*. *configured 진폭* (state.forkLift)
445
+ // 분리. 시뮬 미진행 0 (carriage 가 mast 위 carriageHeight 만큼).
446
+ const forkLift = num(state.forkLiftRT, 0);
447
+ // carriagePosition: rail-local X (0 ~ width). default = rail center.
448
+ // 사용자 의도 — Crane 본체 (rail) 안 움직이고 carriage assembly 만 X 슬라이드.
449
+ const carriagePosition = clamp(num(state.carriagePosition, width / 2), 0, width);
450
+ const cx = left + carriagePosition; // masts/carriage/forks 의 중심 X
239
451
  const cy = top + height / 2;
240
452
  // 적재 여부 — forkLift > 0 (시뮬 중 들어올린 상태) 또는 state.loaded 명시.
241
453
  // monitoring 모드는 외부 데이터로 state.loaded 바인딩.
@@ -251,15 +463,26 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
251
463
  const CARRIER = forkLift > 0 ? '#7d5530' : '#a08864';
252
464
  const CARRIER_LINE = '#3a2a18';
253
465
  ctx.save();
254
- // 1. Rail — 위/아래
255
- const railW = Math.max(1.5, height * 0.03);
466
+ // 0. Rail footprint 반투명 회색 fill + 외곽선. 사용자가 board 에서 crane 의
467
+ // *rail 영역* 명확히 인식 + 편집 핸들 가능.
468
+ ctx.fillStyle = '#aab0b8';
469
+ ctx.globalAlpha = 0.15;
470
+ ctx.fillRect(left, top, width, height);
471
+ ctx.globalAlpha = 1;
472
+ ctx.strokeStyle = DARK;
473
+ ctx.lineWidth = 1;
474
+ ctx.strokeRect(left, top, width, height);
475
+ // 1. Rail — 하단 한 줄 (얇은 가이드). 톤 절제 — outer footprint 가 이미 영역 표시.
476
+ const railW = Math.max(1, height * 0.02);
256
477
  ctx.fillStyle = DARK;
257
- ctx.fillRect(left, top, width, railW);
478
+ ctx.globalAlpha = 0.6;
258
479
  ctx.fillRect(left, top + height - railW, width, railW);
259
- // 2. Twin masts — 두 orange 사각형. mast 길이가 *3D 의 mast height (depth)* 를
260
- // 의미하므로, 위에 carriage 위치 indicator 매핑하면 1층/N층 인식 가능.
261
- const mastW = Math.max(2.5, width * 0.07);
262
- const mastSpacing = width * 0.75; // 넉넉히 carrier 가 mast 잠식 안 하도록
480
+ ctx.globalAlpha = 1;
481
+ // 2. Twin masts *carriageWidth 기반* (crane.width 분리). rail (= crane.width)
482
+ // 이 넓어져도 carriage assembly *carriageWidth 만큼* 만.
483
+ const carriageW = num(state.carriageWidth, width * 0.1); // default rail 10%
484
+ const mastW = Math.max(2.5, carriageW * 0.15);
485
+ const mastSpacing = carriageW * 0.85;
263
486
  const mastY = top + railW;
264
487
  const mastH = height - railW * 2;
265
488
  const mastX1 = cx - mastSpacing / 2 - mastW / 2;
@@ -283,22 +506,21 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
283
506
  ctx.fillStyle = '#5a3a00'; // 어두운 갈색 — orange mast 위에서 강한 대비
284
507
  ctx.fillRect(mastX1 - 2, indY, indWidth, indLen);
285
508
  ctx.fillRect(mastX2 - 2, indY, indWidth, indLen);
286
- // 3. Carriage (= 적재 deck) — 팔레트 1200×800mm aspect 반영. *폭만* sizeMul 적용 (높이감),
287
- // 길이 (Y) 고정. carrier / fork 모두 이 carriage 기준으로 derive.
288
- const carriageW = (mastSpacing - mastW) * sizeMul;
509
+ // 3. Carriage deck — 팔레트 1200×800mm aspect 반영. *폭만* sizeMul 적용 (높이감),
510
+ // 길이 (Y) 고정. carrier / fork 모두 이 deck 기준으로 derive.
511
+ const deckW = (mastSpacing - mastW) * sizeMul;
289
512
  const carriageH = Math.min((mastSpacing - mastW) * 1.3, (height - railW * 2) * 0.55);
290
- const carriageX = cx - carriageW / 2;
513
+ const carriageX = cx - deckW / 2;
291
514
  const carriageY = cy - carriageH / 2;
292
515
  ctx.fillStyle = CARRIAGE_C;
293
- ctx.fillRect(carriageX, carriageY, carriageW, carriageH);
516
+ ctx.fillRect(carriageX, carriageY, deckW, carriageH);
294
517
  // 4. Fork — 좌우 균형:
295
518
  // - 양쪽 모두 *작은 stub* 항상 표시 (crane 이 양쪽 rack 사이 있는 인상)
296
519
  // - extension 부호 (+/-) 에 따라 active 쪽이 추가로 신축
297
520
  // - 신축 길이 = |forkExtension| (실제 reach 만큼)
298
521
  const stubLen = Math.max(2, carriageH * 0.5);
299
- // 포크 폭 — carriage 의 80% (carrier 90% 보다 약간 좁아 carrier 가 fork 양옆으로
300
- // 살짝 overhang). carriage sizeMul 적용된 폭이라 fork 도 자동 scale 반영.
301
- const forkW = carriageW * 0.8;
522
+ // 포크 폭 — deck 의 80%.
523
+ const forkW = deckW * 0.8;
302
524
  const forkX = cx - forkW / 2;
303
525
  const extLen = Math.abs(forkExtension);
304
526
  const sign = forkExtension >= 0 ? 1 : -1;
@@ -322,26 +544,19 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
322
544
  if (carrying) {
323
545
  // 팔레트 — 항상 carriage 의 90% (폭/길이 모두). carriage 가 sizeMul 적용된 폭이라
324
546
  // carrier 도 자동으로 sizeMul 반영. 길이는 carriage 길이 고정에 따라 고정.
325
- const carrW = carriageW * 0.9;
547
+ const carrW = deckW * 0.9;
326
548
  const carrH = carriageH * 0.9;
327
- let carrCenterY;
328
- if (extLen > 0.5) {
329
- // 포크의 *중간* 에 carrier 배치 — fork prong 이 pallet pocket 에 끼워진 자연스러운
330
- // 위치 (현실 AS/RS 에서 pallet 이 fork 끝이 아닌 fork 길이 중간 부근에 안착).
331
- const forkMid = (stubLen + extLen) / 2;
332
- carrCenterY = sign > 0
333
- ? carriageY + carriageH + forkMid
334
- : carriageY - forkMid;
335
- }
336
- else {
337
- carrCenterY = cy; // transit
338
- }
549
+ // Carrier 2D 위치 — 3D _bladeMidZ 와 동일 공식 (sign * extLen).
550
+ // extLen=0 → carriage 정중앙. extLen 증가 cell 방향 으로 진출.
551
+ const offset = extLen;
552
+ const carrCenterY = sign > 0
553
+ ? carriageY + carriageH / 2 + offset
554
+ : carriageY + carriageH / 2 - offset;
339
555
  const carrX = cx - carrW / 2;
340
556
  const carrY = carrCenterY - carrH / 2;
341
557
  // 들린 상태 — drop shadow 로 *떠 있는* 인상 (forkLift > 0). offset 비율 = lift 강도.
342
558
  if (forkLift > 0) {
343
- const forkLengthRef = num(state.forkLength, height * 0.4);
344
- const liftMax = Math.max(forkLengthRef * 0.05, 20); // forkLift 정상 범위
559
+ const liftMax = Math.max(num(state.forkLift, 30), 20); // configured 진폭
345
560
  const liftRatio = clamp(forkLift / liftMax, 0.3, 1); // 최소 30% 표시 (있다는 것만 보이게)
346
561
  const shadowOff = Math.max(2, Math.min(carrW, carrH) * 0.14) * liftRatio;
347
562
  ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
@@ -362,17 +577,88 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
362
577
  // ── Lifecycle — simulate() 자동 시작 ────────────────────────────────────
363
578
  added(parent) {
364
579
  super.added?.(parent);
580
+ // state ↔ visual single source of truth. state 미설정 시 _canonicalDefault
581
+ // 로 명시 초기화 — build / _tween / 외부 read 모두 동일 값.
582
+ const init = {};
583
+ for (const k of ['carriagePosition', 'carriageHeight', 'forkExtension', 'forkLiftRT']) {
584
+ if (typeof this.state[k] !== 'number') {
585
+ init[k] = this._canonicalDefault(k);
586
+ }
587
+ }
588
+ if (Object.keys(init).length > 0)
589
+ this.setState(init);
590
+ // state.simulate === true 명시 시만 자동 시작. mode 연동은 board view layer
591
+ // 책임 — 컴포넌트 자체는 simulate state 만 본다 (sorter/conveyor 등 다른
592
+ // 시뮬 컴포넌트와 동일 패턴).
593
+ if (this.state.simulate !== true)
594
+ return;
595
+ this._startAutoSimulate();
596
+ }
597
+ /**
598
+ * Actuator state 의 *canonical default*. state 미설정 시 모든 read 경로
599
+ * (build / _tween / 외부) 가 *동일 값* 보도록.
600
+ *
601
+ * single source 패턴 — visual fallback 과 state fallback 이 *분리* 되어
602
+ * 첫 _tween 시 start 가 visual current 와 어긋나는 jump 결함을 근본 차단.
603
+ */
604
+ _canonicalDefault(key) {
605
+ const state = this.state;
606
+ const W = numOr(state.width, 100);
607
+ const H = numOr(state.height, 100);
608
+ const D = numOr(state.depth, Math.max(W, H) * 4);
609
+ switch (key) {
610
+ case 'carriagePosition': return W / 2;
611
+ case 'carriageHeight': return D * 0.4;
612
+ case 'forkExtension':
613
+ case 'forkLiftRT':
614
+ case 'forkLift':
615
+ return 0;
616
+ default: return 0;
617
+ }
618
+ }
619
+ _startAutoSimulate() {
365
620
  if (this._simStarted)
366
621
  return;
367
622
  this._simStarted = true;
368
623
  // 초기 지연 800~2800ms 사이 random — 여러 crane 이 동기적으로 시작하지 않도록.
369
- // added 에서 시작 → 2D/3D 모드 무관. buildRealObject 는 3D 진입 시만 호출돼 시점이
370
- // 늦거나 누락될 수 있으므로 부적합.
371
624
  const initialDelay = 800 + Math.random() * 2000;
372
625
  setTimeout(() => {
626
+ if (this._simAbort || this.state.simulate !== true)
627
+ return;
373
628
  this.simulate().catch(e => console.error('[Crane] simulate', e));
374
629
  }, initialDelay);
375
630
  }
631
+ /**
632
+ * state.simulate 변경 시 자동 simulate 시작/중단. application 이 런타임 toggle
633
+ * 가능 (예: editor 에서 simulate off, view 에서 on).
634
+ */
635
+ onchange(after, before) {
636
+ super.onchange(after, before);
637
+ // carriage 관련 state 변경 시 2D render 명시 invalidate. things-scene 의
638
+ // setState → trigger change 가 render queue 에 자동 등록 못 하는 케이스 방어.
639
+ if ('carriagePosition' in after ||
640
+ 'carriageWidth' in after ||
641
+ 'carriageHeight' in after ||
642
+ 'forkExtension' in after ||
643
+ 'forkLift' in after ||
644
+ 'forkLiftRT' in after) {
645
+ ;
646
+ this.invalidate?.();
647
+ }
648
+ if ('simulate' in after) {
649
+ if (after.simulate === true) {
650
+ // 명시 true → 자동 시작 (이미 시작 중이면 noop). mode gating 은
651
+ // frameClock (animate 의 simTime) 이 자동 처리 — modeler 면 simTime
652
+ // 안 흐름, _tween step 호출 안 됨.
653
+ this._simAbort = false;
654
+ this._simStarted = false;
655
+ this._startAutoSimulate();
656
+ }
657
+ else {
658
+ this._simAbort = true;
659
+ }
660
+ }
661
+ }
376
662
  // ── 3D ───────────────────────────────────────────────────────────────────
377
663
  buildRealObject() {
378
664
  return new Crane3D(this);
@@ -412,17 +698,14 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
412
698
  */
413
699
  _initRailRange() {
414
700
  this._railMin = this._railMax = NaN;
415
- // 1순위: state.target refid 명시. 2순위: crane 의 parent 컴포넌트 (자연스러운
416
- // "이 컨테이너 안에서만 움직여" 기본값).
417
- const targetRefId = this.state.target;
701
+ // simulate cycle random pickAndPlace range crane 의 parent 컨테이너
702
+ // (= 보통 rack 또는 board) 의 bbox 를 기준으로. parent 가 top-level
703
+ // (root/model-layer) 이면 bbox 의미 없음 → skip.
418
704
  const root = this.root;
419
- let target = targetRefId ? root?.findById?.(targetRefId) : null;
420
- if (!target) {
421
- const parent = this.parent;
422
- // parent 가 root/model-layer 같은 top-level 이면 bbox 가 의미없음 → skip
423
- if (parent && parent !== root && parent.state?.width > 0) {
424
- target = parent;
425
- }
705
+ const parent = this.parent;
706
+ let target = null;
707
+ if (parent && parent !== root && parent.state?.width > 0) {
708
+ target = parent;
426
709
  }
427
710
  if (!target)
428
711
  return;
@@ -499,17 +782,14 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
499
782
  const H = numOr(this.state.height, 100);
500
783
  const D = numOr(this.state.depth, W * 4);
501
784
  const forkLen = numOr(this.state.forkLength, H * 0.6);
502
- if (!Number.isFinite(this._railMin) || !Number.isFinite(this._railMax)) {
503
- await new Promise(r => setTimeout(r, 800));
504
- return;
505
- }
506
- const pickRail = () => this._railMin + Math.random() * Math.max(0, this._railMax - this._railMin);
507
- const toLeftTop = (rail) => ({
508
- left: this._railOriginX + rail * this._railCos - W / 2,
509
- top: this._railOriginY + rail * this._railSin - H / 2
510
- });
511
- const source = toLeftTop(pickRail());
512
- const dest = toLeftTop(pickRail());
785
+ const cw = numOr(this.state.carriageWidth, W * 0.1);
786
+ // carriagePosition valid range — rail 안 [cw/2, W - cw/2].
787
+ // crane 본체 (rail) 는 안 움직임 — *carriage assembly 만* X 슬라이드.
788
+ const minPos = cw / 2;
789
+ const maxPos = Math.max(minPos, W - cw / 2);
790
+ const pickPos = () => minPos + Math.random() * (maxPos - minPos);
791
+ const sourcePos = pickPos();
792
+ const destPos = pickPos();
513
793
  const sourceCH = Math.random() * D * 0.75;
514
794
  const destCH = Math.random() * D * 0.75;
515
795
  const liftH = Math.max(20, D * 0.02);
@@ -518,21 +798,22 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
518
798
  const sideB = Math.random() < 0.5 ? -1 : +1;
519
799
  // Tween duration 은 base 의 70~130% 사이 random — 여러 crane 이 같은 타이밍으로 안 보이도록.
520
800
  const jitter = (base) => base * (0.7 + Math.random() * 0.6);
521
- // 이동: crane local X 방향 → canvas (left, top) 다 동시 변경 (rotation 적용 시 diagonal)
522
- await this._tween({ status: 'moving', left: source.left, top: source.top, carriageHeight: sourceCH }, jitter(1500));
801
+ // 이동: carriagePosition 변경 (crane.left/top 건드림).
802
+ await this._tween({ status: 'moving', carriagePosition: sourcePos, carriageHeight: sourceCH }, jitter(1500));
523
803
  await this._tween({ status: 'loading', forkExtension: sideA * forkLen }, jitter(700));
524
- await this._tween({ forkLift: liftH }, jitter(400));
804
+ await this._tween({ forkLiftRT: liftH }, jitter(400));
525
805
  await this._tween({ forkExtension: 0 }, jitter(700));
526
- await this._tween({ status: 'moving', left: dest.left, top: dest.top, carriageHeight: destCH }, jitter(1500));
806
+ await this._tween({ status: 'moving', carriagePosition: destPos, carriageHeight: destCH }, jitter(1500));
527
807
  await this._tween({ status: 'unloading', forkExtension: sideB * forkLen }, jitter(700));
528
- await this._tween({ forkLift: 0 }, jitter(400));
808
+ await this._tween({ forkLiftRT: 0 }, jitter(400));
529
809
  await this._tween({ status: 'idle', forkExtension: 0 }, jitter(700));
530
810
  // 사이클 사이 짧은 idle (200~1000ms) — 자연스러운 phase 분산
531
811
  await new Promise(r => setTimeout(r, 200 + Math.random() * 800));
532
812
  }
533
813
  /**
534
- * things-scene 의 `component.animate()` 로 한 구간 tween. step 콜백에서 setState 호출.
535
- * frameClock.simTime 기반이라 scene pause / speed change / suppressAnimations 모두 자동 대응.
814
+ * frameClock 기반 tween — things-scene 의 표준 `Component.animate()` 사용.
815
+ * frameClock.simTime 진행 (view mode + scene 활성) 시에만 step 호출 modeler
816
+ * 또는 paused 시 자동 정지. scene.simulationSpeed 도 자동 대응.
536
817
  */
537
818
  _tween(targets, duration) {
538
819
  const start = {};
@@ -540,7 +821,7 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
540
821
  if (k === 'status')
541
822
  continue;
542
823
  const v = this.state[k];
543
- start[k] = typeof v === 'number' && Number.isFinite(v) ? v : 0;
824
+ start[k] = typeof v === 'number' && Number.isFinite(v) ? v : this._canonicalDefault(k);
544
825
  }
545
826
  if (typeof targets.status === 'string') {
546
827
  this.setState({ status: targets.status });
@@ -558,10 +839,6 @@ let Crane = class Crane extends Mover(CarrierHolder(ContainerCapacity(Legendable
558
839
  ease: 'inout',
559
840
  delta: 'quad',
560
841
  step: (dx) => {
561
- if (this._simAbort) {
562
- controller.stop?.();
563
- return finish();
564
- }
565
842
  const update = {};
566
843
  for (const k of Object.keys(start)) {
567
844
  const tgt = targets[k];
@@ -587,15 +864,33 @@ function resolveCarrierDepth(c) {
587
864
  return eff;
588
865
  return numOr(c?.state?.depth, 0);
589
866
  }
590
- function resolveCarrierCenterY(c) {
591
- const pos = c.state;
592
- if (!pos)
867
+ /**
868
+ * Target 의 *world bottom Y* — fork blade 가 위치해야 할 높이.
869
+ *
870
+ * `getWorldPose(c).position.y` 는 *center Y*. fork 는 carrier 의 *바닥* (pallet
871
+ * pocket 진입 면) 에 위치해야 함 → center 에서 depth/2 만큼 빼서 bottom.
872
+ *
873
+ * `effectiveDepth` 는 RealObject 가 자동 계산한 실제 3D Y 키. 미가용 시 state.
874
+ * depth fallback.
875
+ */
876
+ function resolveCarrierBottomY(c) {
877
+ if (!c)
593
878
  return null;
594
- // zPos is the 3D Y center of a Placeable component in things-scene
595
- const zPos = numOr(pos.zPos, NaN);
596
- if (!Number.isNaN(zPos))
597
- return zPos;
598
- return null;
879
+ try {
880
+ const pose = getWorldPose(c);
881
+ const centerY = pose?.position?.y;
882
+ if (typeof centerY !== 'number' || !Number.isFinite(centerY))
883
+ return null;
884
+ const ro = c._realObject;
885
+ const eff = ro?.effectiveDepth;
886
+ const depth = typeof eff === 'number' && Number.isFinite(eff)
887
+ ? eff
888
+ : numOr(c?.state?.depth, 0);
889
+ return centerY - depth / 2;
890
+ }
891
+ catch {
892
+ return null;
893
+ }
599
894
  }
600
895
  function numOr(v, dflt) {
601
896
  return typeof v === 'number' && Number.isFinite(v) ? v : dflt;