@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.
- package/CHANGELOG.md +25 -0
- package/dist/box.js +18 -0
- package/dist/box.js.map +1 -1
- package/dist/crane-3d.d.ts +47 -2
- package/dist/crane-3d.js +246 -89
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +96 -12
- package/dist/crane.js +395 -100
- package/dist/crane.js.map +1 -1
- package/dist/pallet.d.ts +15 -0
- package/dist/pallet.js +38 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel-3d.js +22 -18
- package/dist/parcel-3d.js.map +1 -1
- package/dist/parcel.d.ts +4 -3
- package/dist/parcel.js +24 -5
- package/dist/parcel.js.map +1 -1
- package/dist/storage-cell.d.ts +5 -2
- package/dist/storage-cell.js +21 -3
- package/dist/storage-cell.js.map +1 -1
- package/dist/storage-rack-3d.js +42 -7
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +26 -2
- package/dist/storage-rack.js +92 -10
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box.ts +18 -0
- package/src/crane-3d.ts +258 -93
- package/src/crane.ts +445 -110
- package/src/pallet.ts +50 -1
- package/src/parcel-3d.ts +23 -18
- package/src/parcel.ts +24 -5
- package/src/storage-cell.ts +23 -3
- package/src/storage-rack-3d.ts +47 -8
- package/src/storage-rack.ts +110 -10
- package/test/test-cell-position.ts +105 -0
- package/test/test-crane-geometry.ts +167 -0
- package/test/test-phase-h-carrier-pickable.ts +4 -3
- package/translations/en.json +5 -1
- package/translations/ja.json +5 -1
- package/translations/ko.json +5 -1
- package/translations/ms.json +5 -1
- package/translations/zh.json +5 -1
- 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: '
|
|
45
|
-
label: '
|
|
46
|
-
name: '
|
|
47
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
//
|
|
255
|
-
|
|
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.
|
|
478
|
+
ctx.globalAlpha = 0.6;
|
|
258
479
|
ctx.fillRect(left, top + height - railW, width, railW);
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
const
|
|
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
|
|
287
|
-
// 길이 (Y) 고정. carrier / fork 모두 이
|
|
288
|
-
const
|
|
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 -
|
|
513
|
+
const carriageX = cx - deckW / 2;
|
|
291
514
|
const carriageY = cy - carriageH / 2;
|
|
292
515
|
ctx.fillStyle = CARRIAGE_C;
|
|
293
|
-
ctx.fillRect(carriageX, carriageY,
|
|
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
|
-
// 포크 폭 —
|
|
300
|
-
|
|
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 =
|
|
547
|
+
const carrW = deckW * 0.9;
|
|
326
548
|
const carrH = carriageH * 0.9;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
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
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
// 이동:
|
|
522
|
-
await this._tween({ status: 'moving',
|
|
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({
|
|
804
|
+
await this._tween({ forkLiftRT: liftH }, jitter(400));
|
|
525
805
|
await this._tween({ forkExtension: 0 }, jitter(700));
|
|
526
|
-
await this._tween({ status: 'moving',
|
|
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({
|
|
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 의 `
|
|
535
|
-
* frameClock.simTime
|
|
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 :
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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;
|