@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/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.0.0-beta.41](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.40...v10.0.0-beta.41) (2026-05-17)
7
+
8
+
9
+ ### :rocket: New Features
10
+
11
+ * **storage,mover:** Rack 자동 cell build + cellComponent helper + carrier 호환성 확장 + Mover null guard ([ed906fd](https://github.com/things-scene/operato-scene/commit/ed906fdbcc24715158aeb14cee133f6183a5dd56))
12
+ * **storage:** Box, Pallet 에도 agv-deck pickupFrame 추가 ([fc74a1a](https://github.com/things-scene/operato-scene/commit/fc74a1a44fc9d10cff9b140180974fca93884b2a))
13
+ * **storage:** Crane fork pnp 시각 정밀화 + 기하학 단순화 ([73cff2d](https://github.com/things-scene/operato-scene/commit/73cff2d4fbd02e310cdaa6144889a1ad8ebda2f4))
14
+ * **storage:** Parcel.pickupFrames 에 agv-deck frame 추가 ([be20388](https://github.com/things-scene/operato-scene/commit/be203886231523653bee94a6f3ae6953a82dd794))
15
+
16
+
17
+ ### :bug: Bug Fix
18
+
19
+ * **storage:** cell 2D 좌표 결함 + crane.simulate default + rack 시각 + parcel material hoist ([c6aa929](https://github.com/things-scene/operato-scene/commit/c6aa929255327e40859e638448ff6ae9719e8715))
20
+
21
+
22
+
6
23
  ## [10.0.0-beta.40](https://github.com/things-scene/operato-scene/compare/v10.0.0-beta.39...v10.0.0-beta.40) (2026-05-16)
7
24
 
8
25
  **Note:** Version bump only for package @operato/scene-storage
package/dist/box.js CHANGED
@@ -88,6 +88,24 @@ let Box = class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {
88
88
  tolerance: { positionMm: 5, angleDeg: 1 },
89
89
  priority: 0,
90
90
  id: 'top-gripper'
91
+ }),
92
+ topApproachFrame({
93
+ carrierWorld: me,
94
+ topY: boxDepth,
95
+ approachDistance: 40,
96
+ toolType: 'agv-deck',
97
+ tolerance: { positionMm: 15, angleDeg: 3 },
98
+ priority: 1,
99
+ id: 'top-deck'
100
+ }),
101
+ topApproachFrame({
102
+ carrierWorld: me,
103
+ topY: boxDepth,
104
+ approachDistance: 80, // crane fork hover
105
+ toolType: 'forklift-fork',
106
+ tolerance: { positionMm: 25, angleDeg: 4 },
107
+ priority: 2,
108
+ id: 'top-fork'
91
109
  })
92
110
  ];
93
111
  }
package/dist/box.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"box.js","sourceRoot":"","sources":["../src/box.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\nimport {\n ComponentNature,\n RealObject,\n RectPath,\n Shape,\n topApproachFrame,\n getWorldPose,\n sceneComponent\n} from '@hatiolab/things-scene'\nimport type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'\nimport {\n Carriable,\n Legendable,\n Placeable,\n type Alignment,\n type LegendBinding,\n type PlacementArchetype\n} from '@operato/scene-base'\n\nimport { Box3D } from './box-3d.js'\n\n/**\n * Box material — drives 3D structure and color.\n *\n * - `wood` — wood crate: visible vertical slats, gaps between, open or\n * semi-open top. Used for heavy / industrial parts.\n * - `plastic` — plastic tote / bin: solid molded walls with stackable lip\n * at top. Used for fulfillment, parts kitting.\n *\n * Cardboard parcels are a separate component (see `parcel.ts`) — they have\n * different proportions, taping, and labels that warrant a distinct class.\n */\nexport type BoxMaterial = 'wood' | 'plastic'\n\n/** Box 컴포넌트 state */\nexport interface BoxState extends State {\n // ── 외관 ──\n material?: BoxMaterial\n\n // ── 3D 재질 ──\n material3d?: Material3D\n}\n\nconst BODY_LEGEND = {\n wood: '#a87644',\n plastic: '#3a5078',\n default: '#a87644'\n}\n\nconst NATURE: ComponentNature = {\n mutable: false,\n resizable: true,\n rotatable: true,\n properties: [\n {\n type: 'select',\n label: 'material',\n name: 'material',\n property: {\n options: [\n { display: 'Wood', value: 'wood' },\n { display: 'Plastic', value: 'plastic' }\n ]\n }\n }\n ],\n help: 'scene/component/box'\n}\n\n// Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,\n// AGV deck, robot-arm gripper, Spot for staging).\n/**\n * Box — a generic stackable container for goods. Wood crate or plastic tote\n * variants distinguished by `material` prop.\n *\n * Shape-based (not Container) — boxes nesting other components is rare in\n * logistics visualization (a *case* of items inside a box is data, not\n * scene-tree). If a future use case needs nested boxes, extend Container.\n */\n@sceneComponent('box')\nexport default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {\n declare state: BoxState\n\n static legends: Record<string, LegendBinding> = {\n bodyColor: { from: 'material', legend: BODY_LEGEND }\n }\n\n static placement: PlacementArchetype = 'operation'\n static align: Alignment = 'bottom'\n static defaultDepth = 300\n\n get nature() {\n return NATURE\n }\n\n get anchors() {\n return []\n }\n\n /** 2D — top-down rectangle. */\n render(ctx: CanvasRenderingContext2D) {\n const { width, height, left, top } = this.state\n ctx.beginPath()\n ctx.rect(left, top, width, height)\n }\n\n get fillStyle() {\n return (this.state.bodyColor as string) || '#a87644'\n }\n\n buildRealObject(): RealObject | undefined {\n return new Box3D(this)\n }\n\n /**\n * Phase H — pickup contract. Box 는 위에서 gripper / vacuum cup 으로 집기 —\n * 단일 entry (top center). Box 의 dimensions 가 작아서 forklift fork 보다는\n * gripper 가 일반적. forklift 로 들어올릴 box 는 통상 pallet 위에 stacking\n * 후 pallet 째로 운반.\n *\n * tolerance 가 pallet 보다 빡빡 (gripper 정밀도 vs forklift pocket 폭).\n */\n pickupFrames(): PickupFrame[] {\n const wp = getWorldPose(this)\n const me: PoseSerialized = {\n position: { x: wp.position.x, y: wp.position.y, z: wp.position.z },\n rotation: { x: wp.rotation.x, y: wp.rotation.y, z: wp.rotation.z, w: wp.rotation.w }\n }\n const boxDepth = (this.constructor as any).defaultDepth ?? 300\n\n return [\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth, // Box top in carrier-local Y (depth = full height; top at depth)\n approachDistance: 50, // gripper 가 hover 하는 거리\n toolType: 'gripper',\n tolerance: { positionMm: 5, angleDeg: 1 },\n priority: 0,\n id: 'top-gripper'\n })\n ]\n }\n}\n"]}
1
+ {"version":3,"file":"box.js","sourceRoot":"","sources":["../src/box.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n */\nimport {\n ComponentNature,\n RealObject,\n RectPath,\n Shape,\n topApproachFrame,\n getWorldPose,\n sceneComponent\n} from '@hatiolab/things-scene'\nimport type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'\nimport {\n Carriable,\n Legendable,\n Placeable,\n type Alignment,\n type LegendBinding,\n type PlacementArchetype\n} from '@operato/scene-base'\n\nimport { Box3D } from './box-3d.js'\n\n/**\n * Box material — drives 3D structure and color.\n *\n * - `wood` — wood crate: visible vertical slats, gaps between, open or\n * semi-open top. Used for heavy / industrial parts.\n * - `plastic` — plastic tote / bin: solid molded walls with stackable lip\n * at top. Used for fulfillment, parts kitting.\n *\n * Cardboard parcels are a separate component (see `parcel.ts`) — they have\n * different proportions, taping, and labels that warrant a distinct class.\n */\nexport type BoxMaterial = 'wood' | 'plastic'\n\n/** Box 컴포넌트 state */\nexport interface BoxState extends State {\n // ── 외관 ──\n material?: BoxMaterial\n\n // ── 3D 재질 ──\n material3d?: Material3D\n}\n\nconst BODY_LEGEND = {\n wood: '#a87644',\n plastic: '#3a5078',\n default: '#a87644'\n}\n\nconst NATURE: ComponentNature = {\n mutable: false,\n resizable: true,\n rotatable: true,\n properties: [\n {\n type: 'select',\n label: 'material',\n name: 'material',\n property: {\n options: [\n { display: 'Wood', value: 'wood' },\n { display: 'Plastic', value: 'plastic' }\n ]\n }\n }\n ],\n help: 'scene/component/box'\n}\n\n// Carriable: a box can be a child of any CarrierHolder (Pallet for stacking,\n// AGV deck, robot-arm gripper, Spot for staging).\n/**\n * Box — a generic stackable container for goods. Wood crate or plastic tote\n * variants distinguished by `material` prop.\n *\n * Shape-based (not Container) — boxes nesting other components is rare in\n * logistics visualization (a *case* of items inside a box is data, not\n * scene-tree). If a future use case needs nested boxes, extend Container.\n */\n@sceneComponent('box')\nexport default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {\n declare state: BoxState\n\n static legends: Record<string, LegendBinding> = {\n bodyColor: { from: 'material', legend: BODY_LEGEND }\n }\n\n static placement: PlacementArchetype = 'operation'\n static align: Alignment = 'bottom'\n static defaultDepth = 300\n\n get nature() {\n return NATURE\n }\n\n get anchors() {\n return []\n }\n\n /** 2D — top-down rectangle. */\n render(ctx: CanvasRenderingContext2D) {\n const { width, height, left, top } = this.state\n ctx.beginPath()\n ctx.rect(left, top, width, height)\n }\n\n get fillStyle() {\n return (this.state.bodyColor as string) || '#a87644'\n }\n\n buildRealObject(): RealObject | undefined {\n return new Box3D(this)\n }\n\n /**\n * Phase H — pickup contract. Box 는 위에서 gripper / vacuum cup 으로 집기 —\n * 단일 entry (top center). Box 의 dimensions 가 작아서 forklift fork 보다는\n * gripper 가 일반적. forklift 로 들어올릴 box 는 통상 pallet 위에 stacking\n * 후 pallet 째로 운반.\n *\n * tolerance 가 pallet 보다 빡빡 (gripper 정밀도 vs forklift pocket 폭).\n */\n pickupFrames(): PickupFrame[] {\n const wp = getWorldPose(this)\n const me: PoseSerialized = {\n position: { x: wp.position.x, y: wp.position.y, z: wp.position.z },\n rotation: { x: wp.rotation.x, y: wp.rotation.y, z: wp.rotation.z, w: wp.rotation.w }\n }\n const boxDepth = (this.constructor as any).defaultDepth ?? 300\n\n return [\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth, // Box top in carrier-local Y (depth = full height; top at depth)\n approachDistance: 50, // gripper 가 hover 하는 거리\n toolType: 'gripper',\n tolerance: { positionMm: 5, angleDeg: 1 },\n priority: 0,\n id: 'top-gripper'\n }),\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth,\n approachDistance: 40,\n toolType: 'agv-deck',\n tolerance: { positionMm: 15, angleDeg: 3 },\n priority: 1,\n id: 'top-deck'\n }),\n topApproachFrame({\n carrierWorld: me,\n topY: boxDepth,\n approachDistance: 80, // crane fork hover\n toolType: 'forklift-fork',\n tolerance: { positionMm: 25, angleDeg: 4 },\n priority: 2,\n id: 'top-fork'\n })\n ]\n }\n}\n"]}
@@ -2,13 +2,58 @@ import * as THREE from 'three';
2
2
  import { RealObjectGroup } from '@hatiolab/things-scene';
3
3
  export declare class Crane3D extends RealObjectGroup {
4
4
  private _forkGroup?;
5
- private _forkTopY;
5
+ private _carrierBaseY;
6
6
  private _bladeMidZ;
7
+ /** floor rail 만 제외한 나머지 (trolley + masts + carriage + fork) 의 movable parent. */
8
+ private _trolleyGroup?;
9
+ /** carriage + fork 의 lift parent (carriageHeight + forkLiftRT 변경 시 Y 만 update). */
10
+ private _carriageLiftGroup?;
11
+ /** Fork active extension mesh — scale.z 와 position.z 로 lerp (rebuild 없이). */
12
+ private _extLeftMesh?;
13
+ private _extRightMesh?;
14
+ private _extBaseParams?;
15
+ /** Fork mesh 의 group-local Y center (carriage 위 + bladeH/2). _applyForkExtensionMeshes 가 ext mesh Y 결정. */
16
+ private _forkOffsetY;
17
+ /** liftGroup.position.y 재계산용 base parameters. */
18
+ private _liftBaseParams?;
7
19
  build(): void;
8
20
  getCarriageFrame(): THREE.Object3D | undefined;
9
- get platformTopY(): number;
21
+ /**
22
+ * Fork blade *bottom* 의 liftGroup-local Y. *carrier 외부 bottom 정렬점*.
23
+ *
24
+ * 모델: carrier 의 외부 bottom 과 fork blade 의 bottom 이 *거의 일치*. fork 가
25
+ * carrier 의 bottom 부분을 *찔러 들어가* carrier 와 *겹친 자세* (pallet pocket
26
+ * 안 fork 진입). attachPointFor 가 `carrierBaseY + carrier.depth/2` 로 carrier
27
+ * center 를 정렬 → carrier bottom = fork blade bottom.
28
+ */
29
+ get carrierBaseY(): number;
10
30
  get bladeMidZ(): number;
11
31
  updateDimension(): void;
12
32
  onchange(after: Record<string, unknown>, before: Record<string, unknown>): void;
33
+ /** carriageHeight + forkLiftRT 를 liftGroup.position.y 로 변환. */
34
+ private _computeLiftGroupY;
35
+ /**
36
+ * Carrier 외부 bottom 의 world Y → carriageHeight state 값 inverse-solve.
37
+ *
38
+ * Forward 공식 (build):
39
+ * liftGroup.y crane-local = baseTrolleyY + baseH/2 + carriageHeight + forkLift + carriageH/2
40
+ * carrier 외부 bottom crane-local = liftGroup.y + carrierBaseY (= -bladeH/2)
41
+ * = baseTrolleyY + baseH/2 + carriageH/2 - bladeH/2 + carriageHeight + forkLift
42
+ *
43
+ * Inverse:
44
+ * carriageHeight = worldY − craneCenterY − (baseTrolleyY + baseH/2 + carriageH/2 − bladeH/2) − forkLift
45
+ */
46
+ solveCarriageHeightForCarrierBaseWorldY(worldY: number, forkLift?: number): number;
47
+ /**
48
+ * target 의 *crane-local Z* → fork extension 값 inverse-solve.
49
+ *
50
+ * Forward (_applyForkExtensionMeshes): `_bladeMidZ = sign * absExt`
51
+ * (carrier 가 ext 만큼 fork 따라 진출). Inverse: `ext = |localZ|, sign = sign(localZ)`.
52
+ *
53
+ * forkLength 로 clamp — localZ 가 forkLength 보다 멀면 carrier 가 fork tip 까지만.
54
+ */
55
+ solveForkExtensionForLocalZ(localZ: number): number;
56
+ /** Fork active extension mesh 의 scale.z + position.z + visibility update. */
57
+ private _applyForkExtensionMeshes;
13
58
  updateAlpha(): void;
14
59
  }
package/dist/crane-3d.js CHANGED
@@ -30,7 +30,7 @@
30
30
  * carriageHeight, forkExtension (±), forkLift
31
31
  */
32
32
  import * as THREE from 'three';
33
- import { RealObjectGroup } from '@hatiolab/things-scene';
33
+ import { RealObject, RealObjectGroup } from '@hatiolab/things-scene';
34
34
  const MAST_COLOR = 0xff7a00; // mast — orange
35
35
  const TROLLEY_COLOR = 0x3a4048; // base / top — dark charcoal
36
36
  const CARRIAGE_COLOR = 0xffcc00; // carriage (shuttle) — yellow
@@ -40,25 +40,45 @@ const RAIL_COLOR = 0x1a1f24; // rail — dark steel
40
40
  const LAMP_OFF = 0x222222;
41
41
  export class Crane3D extends RealObjectGroup {
42
42
  _forkGroup;
43
- _forkTopY = 0;
43
+ _carrierBaseY = 0;
44
44
  _bladeMidZ = 0;
45
+ /** floor rail 만 제외한 나머지 (trolley + masts + carriage + fork) 의 movable parent. */
46
+ _trolleyGroup;
47
+ /** carriage + fork 의 lift parent (carriageHeight + forkLiftRT 변경 시 Y 만 update). */
48
+ _carriageLiftGroup;
49
+ /** Fork active extension mesh — scale.z 와 position.z 로 lerp (rebuild 없이). */
50
+ _extLeftMesh;
51
+ _extRightMesh;
52
+ _extBaseParams;
53
+ /** Fork mesh 의 group-local Y center (carriage 위 + bladeH/2). _applyForkExtensionMeshes 가 ext mesh Y 결정. */
54
+ _forkOffsetY = 0;
55
+ /** liftGroup.position.y 재계산용 base parameters. */
56
+ _liftBaseParams;
45
57
  build() {
46
58
  super.build();
47
59
  this._forkGroup = undefined;
48
- this._forkTopY = 0;
60
+ this._carrierBaseY = 0;
49
61
  this._bladeMidZ = 0;
62
+ this._trolleyGroup = undefined;
63
+ this._carriageLiftGroup = undefined;
64
+ this._extLeftMesh = undefined;
65
+ this._extRightMesh = undefined;
66
+ this._extBaseParams = undefined;
67
+ this._liftBaseParams = undefined;
50
68
  const { width, height, depth } = this.component.state;
51
69
  const emissiveColor = this.component.state.lampEmissive || '#222222';
52
70
  const status = this.component.state.status;
53
71
  const lampOn = status && status !== 'idle';
54
72
  // Actuators
55
73
  const D = numOr(depth, Math.max(width, height) * 4);
56
- const carriageRaw = numOr(this.component.state.carriageHeight, D * 0.4);
74
+ const carriageRaw = numOr(this.component.state.carriageHeight, this.component._canonicalDefault?.('carriageHeight') ?? D * 0.4);
57
75
  const carriageHeight = Math.max(0, Math.min(carriageRaw, D * 0.85));
58
76
  const forkLength = numOr(this.component.state.forkLength, height * 0.6);
59
77
  const forkExtensionRaw = numOr(this.component.state.forkExtension, 0);
60
78
  const forkExtension = Math.max(-forkLength, Math.min(forkLength, forkExtensionRaw));
61
- const forkLift = numOr(this.component.state.forkLift, 0);
79
+ // forkLiftRT 시뮬 runtime current 들림. state.forkLift 는 *configured 진폭*
80
+ // (사용자 설정) 라 안 건드림. 3D carriage Y 는 runtime 값 사용.
81
+ const forkLift = numOr(this.component.state.forkLiftRT, 0);
62
82
  // ── Axis convention (FIXED): ─────────────────────────────────────
63
83
  // Rail = X (state.left = 2D X = 3D X). Crane 좌우 이동.
64
84
  // Fork = Z (2D Y = 3D Z). Fork 앞뒤 신축.
@@ -71,18 +91,24 @@ export class Crane3D extends RealObjectGroup {
71
91
  const topFrameH = S * 0.1;
72
92
  const topGuideH = S * 0.1;
73
93
  const carriageH = S * 0.12;
74
- const mastW = width * 0.1; // mast X 단면 (along rail)
94
+ // Carriage assembly 크기 state.carriageWidth 기반 (rail 길이 = crane.width
95
+ // 와 독립). 미명시 시 rail 의 10%.
96
+ const carriageAssemblyW = numOr(this.component.state.carriageWidth, width * 0.1);
97
+ const mastW = carriageAssemblyW * 0.15; // mast X 단면 (along rail)
75
98
  const mastD = height * 0.25; // mast Z 단면 (cross-rail)
76
- const mastSpacing = width * 0.7; // 두 mast X 간격
99
+ const mastSpacing = carriageAssemblyW * 0.85; // 두 mast X 간격
77
100
  const bladeW = S * 0.1;
78
- const bladeH = S * 0.05;
101
+ // bladeH fork 두께. carriage 보다 얇게 (실 fork prong 의 가는 모양).
102
+ // carriage 와 *같은 Y center (0)* 이고 두께만 carriageH * 0.35.
103
+ const bladeH = carriageH * 0.35;
79
104
  const bladeL = forkLength;
80
105
  const bladeSpacing = mastSpacing * 0.45;
81
106
  const carriageW = mastSpacing - mastW * 0.2;
82
107
  const carriageZ = height * 0.55;
83
- const cabW = S * 0.4;
84
- const cabH = S * 0.4;
85
- const cabD = S * 0.3;
108
+ // Cabinet 존재감만. 작게.
109
+ const cabW = S * 0.18;
110
+ const cabH = S * 0.18;
111
+ const cabD = S * 0.15;
86
112
  const baseY = -D / 2;
87
113
  const mastH = Math.max(D - railH * 2 - baseH - topFrameH - topGuideH, S * 0.5);
88
114
  // ── Materials ─────────────────────────────────────────────────────
@@ -92,31 +118,46 @@ export class Crane3D extends RealObjectGroup {
92
118
  const cabinetMat = new THREE.MeshStandardMaterial({ color: CONTROLLER_COLOR, metalness: 0.2, roughness: 0.6 });
93
119
  const forkMat = new THREE.MeshStandardMaterial({ color: FORK_COLOR, metalness: 0.85, roughness: 0.3 });
94
120
  const railMat = new THREE.MeshStandardMaterial({ color: RAIL_COLOR, metalness: 0.9, roughness: 0.3 });
95
- // ── Floor rail ────────────────────────────────────────────────────
121
+ // ── Floor rail (고정 — crane 본체 안 움직임). 폭 = crane.width (overhang 제거).
96
122
  {
97
- const geo = new THREE.BoxGeometry(width * 1.1, railH, height * 0.35);
123
+ const railThin = railH * 0.5;
124
+ const geo = new THREE.BoxGeometry(width, railThin, height * 0.15);
98
125
  const mesh = new THREE.Mesh(geo, railMat);
99
- mesh.position.set(0, baseY + railH / 2, 0);
126
+ mesh.position.set(0, baseY + railThin / 2, 0);
100
127
  mesh.receiveShadow = true;
101
128
  this.object3d.add(mesh);
102
129
  }
130
+ // ── Trolley group — carriage assembly (rail 위 X 만 이동) ─────────
131
+ // 모든 movable 부품 (base trolley, masts, carriage, fork, cabinet, lamp)
132
+ // 의 parent. carriagePosition 변경 시 group 의 local X 만 변경.
133
+ const trolleyGroup = new THREE.Group();
134
+ this._trolleyGroup = trolleyGroup;
135
+ this.object3d.add(trolleyGroup);
136
+ // 초기 carriagePosition 적용 — rail-local X (0 ~ width) → object3d-local X (-W/2 ~ +W/2)
137
+ const carriagePos = numOr(this.component.state.carriagePosition, this.component._canonicalDefault?.('carriagePosition') ?? width / 2);
138
+ trolleyGroup.position.x = carriagePos - width / 2;
103
139
  // ── Base trolley ──────────────────────────────────────────────────
140
+ // Cabinet 이 mast 바깥쪽에 자연스럽게 놓이도록 *trolley 폭을 mast + cabinet
141
+ // padding 까지 확장*. carriage assembly width 보다 양 옆으로 (cabW + gap) × 2.
142
+ const trolleyPad = cabW + S * 0.04;
143
+ const baseTrolleyW = carriageAssemblyW + trolleyPad * 2;
104
144
  const baseTrolleyY = baseY + railH + baseH / 2;
105
145
  {
106
- const geo = new THREE.BoxGeometry(width * 0.95, baseH, height * 0.7);
146
+ const geo = new THREE.BoxGeometry(baseTrolleyW, baseH, height * 0.7);
107
147
  const mesh = new THREE.Mesh(geo, trolleyMat);
108
148
  mesh.position.set(0, baseTrolleyY, 0);
109
149
  mesh.castShadow = true;
110
150
  mesh.receiveShadow = true;
111
- this.object3d.add(mesh);
151
+ trolleyGroup.add(mesh);
112
152
  }
113
- // ── Control cabinet on one side of base ───────────────────────────
153
+ // ── Control cabinet mast 바깥쪽 (trolley *확장 padding 영역* 위) ─────
114
154
  {
115
155
  const geo = new THREE.BoxGeometry(cabW, cabH, cabD);
116
156
  const cab = new THREE.Mesh(geo, cabinetMat);
117
- cab.position.set(-width * 0.4 + cabW / 2, baseTrolleyY + baseH / 2 + cabH / 2, -height * 0.25 + cabD / 2);
157
+ cab.position.set(-(carriageAssemblyW / 2 + cabW / 2 + S * 0.02), // mast 왼쪽 바깥
158
+ baseTrolleyY + baseH / 2 + cabH / 2, -height * 0.25 + cabD / 2);
118
159
  cab.castShadow = true;
119
- this.object3d.add(cab);
160
+ trolleyGroup.add(cab);
120
161
  }
121
162
  // ── Status lamp ───────────────────────────────────────────────────
122
163
  {
@@ -131,8 +172,8 @@ export class Crane3D extends RealObjectGroup {
131
172
  });
132
173
  const geo = new THREE.CylinderGeometry(lampR, lampR * 0.8, lampH, 12);
133
174
  const lamp = new THREE.Mesh(geo, lampMat);
134
- lamp.position.set(width * 0.4, baseTrolleyY + baseH / 2 + lampH / 2, 0);
135
- this.object3d.add(lamp);
175
+ lamp.position.set(carriageAssemblyW / 2 + lampR * 1.5 + S * 0.02, baseTrolleyY + baseH / 2 + lampH / 2, 0);
176
+ trolleyGroup.add(lamp);
136
177
  }
137
178
  // ── Twin masts ────────────────────────────────────────────────────
138
179
  const mastY = baseTrolleyY + baseH / 2 + mastH / 2;
@@ -142,128 +183,244 @@ export class Crane3D extends RealObjectGroup {
142
183
  mesh.position.set(xOff, mastY, 0);
143
184
  mesh.castShadow = true;
144
185
  mesh.receiveShadow = true;
145
- this.object3d.add(mesh);
186
+ trolleyGroup.add(mesh);
146
187
  }
147
- // ── Carriage + Fork 어셈블리 (forkLift 함께 이동) ───────────────
148
- // 시각 단절 방지를 위해 carriage fork *함께* forkLift 만큼 올림.
149
- // 의미상으로는 carriageHeight mast carriage 위치, forkLift *그 어셈블리의*
150
- // 추가 미세 Y. 적용된 위치를 carriage / fork 둘 다 공유.
151
- const carriageY = baseTrolleyY + baseH / 2 + carriageHeight + forkLift + carriageH / 2;
188
+ // ── Carriage + Fork lift group (carriageHeight + forkLiftRT 따라 Y 이동)
189
+ // _carriageLiftGroup 안에 carriage + forkGroup. forkLiftRT / carriageHeight
190
+ // 변경 *그룹 Y update* (mesh rebuild X). _forkGroup 의 child carrier 가
191
+ // *dispose 없이 그대로* 함께 따라 움직임.
192
+ const stubL = Math.min(carriageZ * 0.2, Math.max(bladeL * 0.05, 6));
193
+ const liftGroup = new THREE.Group();
194
+ this._carriageLiftGroup = liftGroup;
195
+ this._liftBaseParams = { baseTrolleyY, baseH, carriageH, bladeH };
196
+ liftGroup.position.set(0, this._computeLiftGroupY(carriageHeight, forkLift), 0);
197
+ trolleyGroup.add(liftGroup);
198
+ // Carriage — liftGroup local center
152
199
  {
153
200
  const geo = new THREE.BoxGeometry(carriageW, carriageH, carriageZ);
154
201
  const mesh = new THREE.Mesh(geo, carriageMat);
155
- mesh.position.set(0, carriageY, 0);
202
+ mesh.position.set(0, 0, 0);
156
203
  mesh.castShadow = true;
157
204
  mesh.receiveShadow = true;
158
- this.object3d.add(mesh);
205
+ liftGroup.add(mesh);
159
206
  }
160
- // ── Two-prong forks (양옆 stub + active 신축, 2D 와 동일 모델) ─────
161
- // ext=0: ±Z 면에 작은 stub. carriage 안에 들어있는 인상 (튀어나옴 없음)
162
- // ext=±forkLen: active stub + |ext| 길이로 신장. 반대쪽 stub 유지.
163
- // 회전 flip 없음 ext 0 지날 시각 점프 없음.
164
- const forkY = carriageY; // carriage 중심 Y (embed)
165
- const stubL = Math.min(carriageZ * 0.2, Math.max(bladeL * 0.05, 6));
207
+ // ── Two-prong forks ───────────────────────────────────────────────
208
+ // stub (4 box, fixed) + active extension (2 box, scale.z 로 lerp).
209
+ // active mesh *unit-length* 생성. _applyForkExtension scale.z
210
+ // position.z 길이/방향 update rebuild 없이 frame 부드러운 변형.
166
211
  const absExt = Math.abs(forkExtension);
167
212
  const sign = forkExtension >= 0 ? 1 : -1;
168
213
  {
169
214
  const group = new THREE.Group();
170
- group.position.set(0, forkY, 0);
215
+ // _forkGroup 은 liftGroup-local center (0,0,0) — frame 일치 단순화.
216
+ // attach localPosition (carrier 자식) 도 *group-local = liftGroup-local* 동일 frame.
217
+ // Fork mesh 자체가 group-local 안에서 carriage *위* 로 (mesh.position.y).
218
+ group.position.set(0, 0, 0);
219
+ // Fork mesh 가 carriage 와 *수평 (같은 Y 평면)* — carriage 안에 embed.
220
+ // fork mesh group-local Y center = 0 (carriage center 와 같음).
221
+ // fork blade *bottom* 면 group-local Y = -bladeH/2.
222
+ // fork blade *top* 면 group-local Y = +bladeH/2.
223
+ const forkOffsetY = 0;
171
224
  // 양옆 stub — 두 prong × 두 측면 = 4 box
172
225
  const stubGeo = new THREE.BoxGeometry(bladeW, bladeH, stubL);
173
226
  for (const xOff of [-bladeSpacing / 2, +bladeSpacing / 2]) {
174
227
  for (const zSide of [-1, +1]) {
175
228
  const mesh = new THREE.Mesh(stubGeo, forkMat);
176
- mesh.position.set(xOff, 0, zSide * (carriageZ / 2 + stubL / 2));
229
+ mesh.position.set(xOff, forkOffsetY, zSide * (carriageZ / 2 + stubL / 2));
177
230
  mesh.castShadow = true;
178
231
  mesh.receiveShadow = true;
179
232
  group.add(mesh);
180
233
  }
181
234
  }
182
- // Active side 신장
183
- if (absExt > 0.5) {
184
- const extGeo = new THREE.BoxGeometry(bladeW, bladeH, absExt);
185
- for (const xOff of [-bladeSpacing / 2, +bladeSpacing / 2]) {
186
- const mesh = new THREE.Mesh(extGeo, forkMat);
187
- mesh.position.set(xOff, 0, sign * (carriageZ / 2 + stubL + absExt / 2));
188
- mesh.castShadow = true;
189
- mesh.receiveShadow = true;
190
- group.add(mesh);
191
- }
192
- }
193
- // Pallet — 정지 시 carriage 중심 Z, 신축 시 fork 중간으로 이동.
194
- const loaded = forkLift > 0 || !!this.component.state.loaded || !!this.component.state.carrying;
195
- if (loaded) {
196
- const palletMat = new THREE.MeshStandardMaterial({
197
- color: 0xa08864, metalness: 0.1, roughness: 0.85
198
- });
199
- const palletW = bladeSpacing * 1.2;
200
- const palletH = Math.max(bladeH * 2.5, carriageH * 0.5);
201
- const palletL = Math.max(bladeL * 0.3, carriageZ * 0.6);
202
- const geo = new THREE.BoxGeometry(palletW, palletH, palletL);
203
- const pallet = new THREE.Mesh(geo, palletMat);
204
- const palletZ = absExt < 0.5 ? 0 : sign * (carriageZ / 2 + absExt / 2);
205
- pallet.position.set(0, carriageH / 2 + palletH / 2, palletZ);
206
- pallet.castShadow = true;
207
- pallet.receiveShadow = true;
208
- group.add(pallet);
209
- }
210
- this.object3d.add(group);
235
+ // Active extension — unit length, scale.z + position.z 로 변형
236
+ const extGeo = new THREE.BoxGeometry(bladeW, bladeH, 1);
237
+ const extLeft = new THREE.Mesh(extGeo, forkMat);
238
+ const extRight = new THREE.Mesh(extGeo, forkMat);
239
+ this._forkOffsetY = forkOffsetY;
240
+ extLeft.castShadow = true;
241
+ extLeft.receiveShadow = true;
242
+ extRight.castShadow = true;
243
+ extRight.receiveShadow = true;
244
+ group.add(extLeft);
245
+ group.add(extRight);
246
+ this._extLeftMesh = extLeft;
247
+ this._extRightMesh = extRight;
248
+ this._extBaseParams = { bladeSpacing, carriageZ, stubL };
249
+ this._applyForkExtensionMeshes(absExt, sign);
250
+ // carrier 초기 Z = sign * absExt (= _applyForkExtensionMeshes 의 공식 동일).
251
+ const carrierZ = sign * absExt;
252
+ liftGroup.add(group);
211
253
  this._forkGroup = group;
212
- this._forkTopY = carriageH / 2;
213
- this._bladeMidZ = absExt < 0.5 ? 0 : sign * (carriageZ / 2 + absExt / 2);
254
+ // Carrier 외부 bottom 정렬점 (liftGroup-local Y) = fork blade *bottom* =
255
+ // -bladeH/2 (fork mesh group-local center = 0, 두께 bladeH).
256
+ // 사용자 모델: "fork 의 아랫면 ≈ carrier 의 아랫면" — fork blade 가 carrier
257
+ // 의 bottom 부분 안으로 *찔러 들어감* (겹친 자세).
258
+ this._carrierBaseY = -bladeH / 2;
259
+ this._bladeMidZ = carrierZ;
214
260
  }
215
- // ── Top frame (connects mast tops) ────────────────────────────────
261
+ // ── Top frame (connects mast tops) — trolley 함께 이동 ─────────────
216
262
  const topFrameY = mastY + mastH / 2 + topFrameH / 2;
217
263
  {
218
264
  const geo = new THREE.BoxGeometry(mastSpacing + mastW, topFrameH, height * 0.35);
219
265
  const mesh = new THREE.Mesh(geo, trolleyMat);
220
266
  mesh.position.set(0, topFrameY, 0);
221
267
  mesh.castShadow = true;
222
- this.object3d.add(mesh);
268
+ trolleyGroup.add(mesh);
223
269
  }
224
- // ── Top guide trolley ─────────────────────────────────────────────
270
+ // ── Top guide trolley — trolley 함께 이동 (ceiling rail 위 미끄러짐) ─
225
271
  const topGuideY = topFrameY + topFrameH / 2 + topGuideH / 2;
226
272
  {
227
273
  const geo = new THREE.BoxGeometry(mastSpacing + mastW * 2, topGuideH, height * 0.3);
228
274
  const mesh = new THREE.Mesh(geo, trolleyMat);
229
275
  mesh.position.set(0, topGuideY, 0);
230
276
  mesh.castShadow = true;
231
- this.object3d.add(mesh);
232
- }
233
- // ── Ceiling rail ──────────────────────────────────────────────────
234
- {
235
- const geo = new THREE.BoxGeometry(width * 1.1, railH, height * 0.3);
236
- const mesh = new THREE.Mesh(geo, railMat);
237
- mesh.position.set(0, topGuideY + topGuideH / 2 + railH / 2, 0);
238
- this.object3d.add(mesh);
277
+ trolleyGroup.add(mesh);
239
278
  }
279
+ // Ceiling rail 생략 — 상단은 top guide trolley 만으로 충분. 사용자 의도.
240
280
  }
241
281
  getCarriageFrame() {
242
- return this._forkGroup ?? this.object3d;
282
+ // Fallback chain — carrier 가 *carriage transform (X 이동, Y lift)* 따라오도록.
283
+ // _forkGroup 미존재 시 _carriageLiftGroup (X+Y 따라옴) → _trolleyGroup (X 만) →
284
+ // root (no follow). 절대 root 로 떨어지지 않도록 lift/trolley 우선.
285
+ return this._forkGroup ?? this._carriageLiftGroup ?? this._trolleyGroup ?? this.object3d;
243
286
  }
244
- get platformTopY() {
245
- return this._forkTopY;
287
+ /**
288
+ * Fork blade *bottom* 의 liftGroup-local Y. *carrier 외부 bottom 정렬점*.
289
+ *
290
+ * 모델: carrier 의 외부 bottom 과 fork blade 의 bottom 이 *거의 일치*. fork 가
291
+ * carrier 의 bottom 부분을 *찔러 들어가* carrier 와 *겹친 자세* (pallet pocket
292
+ * 안 fork 진입). attachPointFor 가 `carrierBaseY + carrier.depth/2` 로 carrier
293
+ * center 를 정렬 → carrier bottom = fork blade bottom.
294
+ */
295
+ get carrierBaseY() {
296
+ return this._carrierBaseY;
246
297
  }
247
298
  get bladeMidZ() {
248
299
  return this._bladeMidZ;
249
300
  }
250
301
  updateDimension() { }
251
302
  onchange(after, before) {
252
- if ('width' in after ||
303
+ // carriagePosition trolleyGroup.position.x (mesh-level update).
304
+ if ('carriagePosition' in after && this._trolleyGroup) {
305
+ const W = numOr(this.component.state.width, 100);
306
+ const pos = numOr(after.carriagePosition, W / 2);
307
+ this._trolleyGroup.position.x = pos - W / 2;
308
+ }
309
+ // Mesh-level updates — fork extension / lift / carriage height. *rebuild 없이*
310
+ // mesh 의 scale / position 만 변경. _forkGroup 의 child carrier 가 dispose
311
+ // 없이 그대로 따라 움직임 (fork 작업 시 시각 자연스러움).
312
+ //
313
+ // status / bodyColor / lampEmissive 는 *cosmetic* — full rebuild 회피. 별도
314
+ // 처리 없음 시 status 변경 시 carrier dispose → 사라짐 결함의 원인. 향후
315
+ // material color 만 update 하는 path 추가 가능.
316
+ const needsFullRebuild = 'width' in after ||
253
317
  'height' in after ||
254
318
  'depth' in after ||
255
- 'carriageHeight' in after ||
256
319
  'forkLength' in after ||
257
- 'forkExtension' in after ||
258
- 'forkLift' in after ||
259
- 'status' in after ||
260
- 'bodyColor' in after ||
261
- 'lampEmissive' in after) {
320
+ 'carriageWidth' in after;
321
+ if (!needsFullRebuild) {
322
+ let meshUpdated = false;
323
+ if (('carriageHeight' in after || 'forkLiftRT' in after) && this._carriageLiftGroup) {
324
+ const state = this.component.state;
325
+ const D = numOr(state.depth, Math.max(numOr(state.width, 100), numOr(state.height, 100)) * 4);
326
+ const carriageRaw = numOr(state.carriageHeight, D * 0.4);
327
+ const carriageHeight = Math.max(0, Math.min(carriageRaw, D * 0.85));
328
+ const forkLift = numOr(state.forkLiftRT, 0);
329
+ this._carriageLiftGroup.position.y = this._computeLiftGroupY(carriageHeight, forkLift);
330
+ meshUpdated = true;
331
+ }
332
+ if ('forkExtension' in after && this._extLeftMesh && this._extRightMesh) {
333
+ const state = this.component.state;
334
+ const forkLength = numOr(state.forkLength, numOr(state.height, 100) * 0.6);
335
+ const forkExtensionRaw = numOr(state.forkExtension, 0);
336
+ const forkExtension = Math.max(-forkLength, Math.min(forkLength, forkExtensionRaw));
337
+ const absExt = Math.abs(forkExtension);
338
+ const sign = forkExtension >= 0 ? 1 : -1;
339
+ this._applyForkExtensionMeshes(absExt, sign);
340
+ meshUpdated = true;
341
+ }
342
+ if (meshUpdated)
343
+ return;
344
+ }
345
+ if (needsFullRebuild) {
262
346
  this.update();
263
347
  return;
264
348
  }
265
349
  super.onchange(after, before);
266
350
  }
351
+ /** carriageHeight + forkLiftRT 를 liftGroup.position.y 로 변환. */
352
+ _computeLiftGroupY(carriageHeight, forkLift) {
353
+ const p = this._liftBaseParams;
354
+ if (!p)
355
+ return 0;
356
+ return p.baseTrolleyY + p.baseH / 2 + carriageHeight + forkLift + p.carriageH / 2;
357
+ }
358
+ /**
359
+ * Carrier 외부 bottom 의 world Y → carriageHeight state 값 inverse-solve.
360
+ *
361
+ * Forward 공식 (build):
362
+ * liftGroup.y crane-local = baseTrolleyY + baseH/2 + carriageHeight + forkLift + carriageH/2
363
+ * carrier 외부 bottom crane-local = liftGroup.y + carrierBaseY (= -bladeH/2)
364
+ * = baseTrolleyY + baseH/2 + carriageH/2 - bladeH/2 + carriageHeight + forkLift
365
+ *
366
+ * Inverse:
367
+ * carriageHeight = worldY − craneCenterY − (baseTrolleyY + baseH/2 + carriageH/2 − bladeH/2) − forkLift
368
+ */
369
+ solveCarriageHeightForCarrierBaseWorldY(worldY, forkLift = 0) {
370
+ const p = this._liftBaseParams;
371
+ if (!p)
372
+ return 0;
373
+ this.object3d.updateWorldMatrix(true, false);
374
+ const v = new THREE.Vector3();
375
+ this.object3d.matrixWorld.decompose(v, new THREE.Quaternion(), new THREE.Vector3());
376
+ const craneCenterWorldY = v.y;
377
+ return worldY - craneCenterWorldY - (p.baseTrolleyY + p.baseH / 2 + p.carriageH / 2 - p.bladeH / 2) - forkLift;
378
+ }
379
+ /**
380
+ * target 의 *crane-local Z* → fork extension 값 inverse-solve.
381
+ *
382
+ * Forward (_applyForkExtensionMeshes): `_bladeMidZ = sign * absExt`
383
+ * (carrier 가 ext 만큼 fork 따라 진출). Inverse: `ext = |localZ|, sign = sign(localZ)`.
384
+ *
385
+ * forkLength 로 clamp — localZ 가 forkLength 보다 멀면 carrier 가 fork tip 까지만.
386
+ */
387
+ solveForkExtensionForLocalZ(localZ) {
388
+ const sign = localZ >= 0 ? 1 : -1;
389
+ const state = this.component.state;
390
+ const forkLen = numOr(state.forkLength, numOr(state.height, 100) * 0.6);
391
+ const clamped = Math.max(0, Math.min(forkLen, Math.abs(localZ)));
392
+ return sign * clamped;
393
+ }
394
+ /** Fork active extension mesh 의 scale.z + position.z + visibility update. */
395
+ _applyForkExtensionMeshes(absExt, sign) {
396
+ if (!this._extLeftMesh || !this._extRightMesh || !this._extBaseParams)
397
+ return;
398
+ const { bladeSpacing, carriageZ, stubL } = this._extBaseParams;
399
+ const visible = absExt > 0.5;
400
+ const len = Math.max(0.001, absExt);
401
+ const posZ = sign * (carriageZ / 2 + stubL + absExt / 2);
402
+ this._extLeftMesh.scale.z = len;
403
+ this._extRightMesh.scale.z = len;
404
+ this._extLeftMesh.position.set(-bladeSpacing / 2, this._forkOffsetY, posZ);
405
+ this._extRightMesh.position.set(+bladeSpacing / 2, this._forkOffsetY, posZ);
406
+ this._extLeftMesh.visible = visible;
407
+ this._extRightMesh.visible = visible;
408
+ // _bladeMidZ = carrier 의 Z 위치 = sign * absExt (= fork extension 만큼 직접).
409
+ // ext=0 → 0 (carriage 정중앙 — retract 끝 자세)
410
+ // ext=L → ±L (fork 가 L 만큼 진출한 위치 = carrier 도 그 위치)
411
+ // 단순 linear — solveForkExtensionForLocalZ 의 inverse 도 단순 (ext = |localZ|).
412
+ this._bladeMidZ = sign * absExt;
413
+ // _forkGroup 의 child carrier 의 Z 도 동기 — fork tip 위치 따라 carrier 가
414
+ // 함께 끌려와야 retract 시각 자연.
415
+ if (this._forkGroup) {
416
+ for (const child of this._forkGroup.children) {
417
+ const ctx = child.userData?.context;
418
+ if (ctx && ctx !== this && ctx instanceof RealObject) {
419
+ child.position.z = this._bladeMidZ;
420
+ }
421
+ }
422
+ }
423
+ }
267
424
  updateAlpha() { }
268
425
  }
269
426
  function numOr(v, dflt) {