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

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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/box.js +18 -0
  5. package/dist/box.js.map +1 -1
  6. package/dist/crane-3d.d.ts +47 -2
  7. package/dist/crane-3d.js +246 -89
  8. package/dist/crane-3d.js.map +1 -1
  9. package/dist/crane.d.ts +96 -12
  10. package/dist/crane.js +395 -100
  11. package/dist/crane.js.map +1 -1
  12. package/dist/index.d.ts +3 -4
  13. package/dist/index.js +1 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/pallet.d.ts +15 -0
  16. package/dist/pallet.js +38 -2
  17. package/dist/pallet.js.map +1 -1
  18. package/dist/parcel-3d.js +22 -18
  19. package/dist/parcel-3d.js.map +1 -1
  20. package/dist/parcel.d.ts +4 -3
  21. package/dist/parcel.js +24 -5
  22. package/dist/parcel.js.map +1 -1
  23. package/dist/rack-grid-3d.d.ts +18 -7
  24. package/dist/rack-grid-3d.js +372 -69
  25. package/dist/rack-grid-3d.js.map +1 -1
  26. package/dist/rack-grid-cell.d.ts +21 -72
  27. package/dist/rack-grid-cell.js +147 -243
  28. package/dist/rack-grid-cell.js.map +1 -1
  29. package/dist/rack-grid.d.ts +277 -56
  30. package/dist/rack-grid.js +1230 -695
  31. package/dist/rack-grid.js.map +1 -1
  32. package/dist/rack-materials.d.ts +9 -0
  33. package/dist/rack-materials.js +55 -0
  34. package/dist/rack-materials.js.map +1 -0
  35. package/dist/storage-rack-3d.d.ts +15 -0
  36. package/dist/storage-rack-3d.js +165 -29
  37. package/dist/storage-rack-3d.js.map +1 -1
  38. package/dist/storage-rack.d.ts +253 -32
  39. package/dist/storage-rack.js +726 -66
  40. package/dist/storage-rack.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/box.ts +18 -0
  43. package/src/crane-3d.ts +258 -93
  44. package/src/crane.ts +445 -110
  45. package/src/index.ts +3 -4
  46. package/src/pallet.ts +50 -1
  47. package/src/parcel-3d.ts +23 -18
  48. package/src/parcel.ts +24 -5
  49. package/src/rack-grid-3d.ts +383 -80
  50. package/src/rack-grid-cell.ts +161 -305
  51. package/src/rack-grid.ts +1263 -762
  52. package/src/rack-materials.ts +61 -0
  53. package/src/storage-rack-3d.ts +182 -29
  54. package/src/storage-rack.ts +819 -67
  55. package/test/test-carrier-lifecycle.ts +361 -0
  56. package/test/test-coord-alignment.ts +201 -0
  57. package/test/test-crane-geometry.ts +167 -0
  58. package/test/test-external-to-rack.ts +461 -0
  59. package/test/test-mover-concurrent-bug.ts +304 -0
  60. package/test/test-mover-rollback.ts +290 -0
  61. package/test/test-phase-h-carrier-pickable.ts +4 -3
  62. package/test/test-r19-place-absorb.ts +174 -0
  63. package/test/test-rack-3d-attach-real.ts +301 -0
  64. package/test/test-rack-concurrent.ts +254 -0
  65. package/test/test-rack-edge-cases.ts +323 -0
  66. package/test/test-rack-grid-cell.ts +318 -0
  67. package/test/test-rack-grid-location.ts +657 -0
  68. package/test/test-real-3d-positioning.ts +158 -0
  69. package/test/test-slot-center-convention.ts +116 -0
  70. package/test/test-slot-target.ts +189 -0
  71. package/test/test-storage-rack-batched.ts +606 -0
  72. package/test/test-storage-rack-click.ts +329 -0
  73. package/test/test-storage-rack-slot-api.ts +357 -0
  74. package/test/test-toscene-convention.ts +162 -0
  75. package/test/test-user-scenario-sequential.ts +334 -0
  76. package/translations/en.json +7 -1
  77. package/translations/ja.json +7 -1
  78. package/translations/ko.json +7 -1
  79. package/translations/ms.json +7 -1
  80. package/translations/zh.json +7 -1
  81. package/tsconfig.tsbuildinfo +1 -1
  82. package/dist/rack-column.d.ts +0 -35
  83. package/dist/rack-column.js +0 -258
  84. package/dist/rack-column.js.map +0 -1
  85. package/dist/rack-grid-helpers.d.ts +0 -28
  86. package/dist/rack-grid-helpers.js +0 -71
  87. package/dist/rack-grid-helpers.js.map +0 -1
  88. package/dist/rack-grid-location.d.ts +0 -37
  89. package/dist/rack-grid-location.js +0 -227
  90. package/dist/rack-grid-location.js.map +0 -1
  91. package/dist/storage-cell-3d.d.ts +0 -25
  92. package/dist/storage-cell-3d.js +0 -88
  93. package/dist/storage-cell-3d.js.map +0 -1
  94. package/dist/storage-cell.d.ts +0 -70
  95. package/dist/storage-cell.js +0 -197
  96. package/dist/storage-cell.js.map +0 -1
  97. package/src/rack-column.ts +0 -340
  98. package/src/rack-grid-helpers.ts +0 -77
  99. package/src/rack-grid-location.ts +0 -286
  100. package/src/storage-cell-3d.ts +0 -101
  101. package/src/storage-cell.ts +0 -247
  102. package/test/test-rack-grid.ts +0 -77
package/src/crane-3d.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  */
32
32
 
33
33
  import * as THREE from 'three'
34
- import { RealObjectGroup } from '@hatiolab/things-scene'
34
+ import { RealObject, RealObjectGroup } from '@hatiolab/things-scene'
35
35
 
36
36
  const MAST_COLOR = 0xff7a00 // mast — orange
37
37
  const TROLLEY_COLOR = 0x3a4048 // base / top — dark charcoal
@@ -43,15 +43,33 @@ const LAMP_OFF = 0x222222
43
43
 
44
44
  export class Crane3D extends RealObjectGroup {
45
45
  private _forkGroup?: THREE.Group
46
- private _forkTopY: number = 0
46
+ private _carrierBaseY: number = 0
47
47
  private _bladeMidZ: number = 0
48
+ /** floor rail 만 제외한 나머지 (trolley + masts + carriage + fork) 의 movable parent. */
49
+ private _trolleyGroup?: THREE.Group
50
+ /** carriage + fork 의 lift parent (carriageHeight + forkLiftRT 변경 시 Y 만 update). */
51
+ private _carriageLiftGroup?: THREE.Group
52
+ /** Fork active extension mesh — scale.z 와 position.z 로 lerp (rebuild 없이). */
53
+ private _extLeftMesh?: THREE.Mesh
54
+ private _extRightMesh?: THREE.Mesh
55
+ private _extBaseParams?: { bladeSpacing: number; carriageZ: number; stubL: number }
56
+ /** Fork mesh 의 group-local Y center (carriage 위 + bladeH/2). _applyForkExtensionMeshes 가 ext mesh Y 결정. */
57
+ private _forkOffsetY: number = 0
58
+ /** liftGroup.position.y 재계산용 base parameters. */
59
+ private _liftBaseParams?: { baseTrolleyY: number; baseH: number; carriageH: number; bladeH: number }
48
60
 
49
61
  build() {
50
62
  super.build()
51
63
 
52
64
  this._forkGroup = undefined
53
- this._forkTopY = 0
65
+ this._carrierBaseY = 0
54
66
  this._bladeMidZ = 0
67
+ this._trolleyGroup = undefined
68
+ this._carriageLiftGroup = undefined
69
+ this._extLeftMesh = undefined
70
+ this._extRightMesh = undefined
71
+ this._extBaseParams = undefined
72
+ this._liftBaseParams = undefined
55
73
 
56
74
  const { width, height, depth } = this.component.state
57
75
  const emissiveColor = (this.component.state.lampEmissive as string) || '#222222'
@@ -60,13 +78,15 @@ export class Crane3D extends RealObjectGroup {
60
78
 
61
79
  // Actuators
62
80
  const D = numOr(depth, Math.max(width, height) * 4)
63
- const carriageRaw = numOr((this.component.state as any).carriageHeight, D * 0.4)
81
+ const carriageRaw = numOr((this.component.state as any).carriageHeight, (this.component as any)._canonicalDefault?.('carriageHeight') ?? D * 0.4)
64
82
  const carriageHeight = Math.max(0, Math.min(carriageRaw, D * 0.85))
65
83
 
66
84
  const forkLength = numOr((this.component.state as any).forkLength, height * 0.6)
67
85
  const forkExtensionRaw = numOr((this.component.state as any).forkExtension, 0)
68
86
  const forkExtension = Math.max(-forkLength, Math.min(forkLength, forkExtensionRaw))
69
- const forkLift = numOr((this.component.state as any).forkLift, 0)
87
+ // forkLiftRT 시뮬 runtime current 들림. state.forkLift 는 *configured 진폭*
88
+ // (사용자 설정) 라 안 건드림. 3D carriage Y 는 runtime 값 사용.
89
+ const forkLift = numOr((this.component.state as any).forkLiftRT, 0)
70
90
 
71
91
  // ── Axis convention (FIXED): ─────────────────────────────────────
72
92
  // Rail = X (state.left = 2D X = 3D X). Crane 좌우 이동.
@@ -80,18 +100,24 @@ export class Crane3D extends RealObjectGroup {
80
100
  const topFrameH = S * 0.1
81
101
  const topGuideH = S * 0.1
82
102
  const carriageH = S * 0.12
83
- const mastW = width * 0.1 // mast X 단면 (along rail)
84
- const mastD = height * 0.25 // mast Z 단면 (cross-rail)
85
- const mastSpacing = width * 0.7 // 두 mast X 간격
103
+ // Carriage assembly 크기 state.carriageWidth 기반 (rail 길이 = crane.width
104
+ // 독립). 미명시 rail 10%.
105
+ const carriageAssemblyW = numOr((this.component.state as any).carriageWidth, width * 0.1)
106
+ const mastW = carriageAssemblyW * 0.15 // mast X 단면 (along rail)
107
+ const mastD = height * 0.25 // mast Z 단면 (cross-rail)
108
+ const mastSpacing = carriageAssemblyW * 0.85 // 두 mast X 간격
86
109
  const bladeW = S * 0.1
87
- const bladeH = S * 0.05
110
+ // bladeH fork 두께. carriage 보다 얇게 (실 fork prong 의 가는 모양).
111
+ // carriage 와 *같은 Y center (0)* 이고 두께만 carriageH * 0.35.
112
+ const bladeH = carriageH * 0.35
88
113
  const bladeL = forkLength
89
114
  const bladeSpacing = mastSpacing * 0.45
90
115
  const carriageW = mastSpacing - mastW * 0.2
91
116
  const carriageZ = height * 0.55
92
- const cabW = S * 0.4
93
- const cabH = S * 0.4
94
- const cabD = S * 0.3
117
+ // Cabinet 존재감만. 작게.
118
+ const cabW = S * 0.18
119
+ const cabH = S * 0.18
120
+ const cabD = S * 0.15
95
121
 
96
122
  const baseY = -D / 2
97
123
  const mastH = Math.max(D - railH * 2 - baseH - topFrameH - topGuideH, S * 0.5)
@@ -104,37 +130,52 @@ export class Crane3D extends RealObjectGroup {
104
130
  const forkMat = new THREE.MeshStandardMaterial({ color: FORK_COLOR, metalness: 0.85, roughness: 0.3 })
105
131
  const railMat = new THREE.MeshStandardMaterial({ color: RAIL_COLOR, metalness: 0.9, roughness: 0.3 })
106
132
 
107
- // ── Floor rail ────────────────────────────────────────────────────
133
+ // ── Floor rail (고정 — crane 본체 안 움직임). 폭 = crane.width (overhang 제거).
108
134
  {
109
- const geo = new THREE.BoxGeometry(width * 1.1, railH, height * 0.35)
135
+ const railThin = railH * 0.5
136
+ const geo = new THREE.BoxGeometry(width, railThin, height * 0.15)
110
137
  const mesh = new THREE.Mesh(geo, railMat)
111
- mesh.position.set(0, baseY + railH / 2, 0)
138
+ mesh.position.set(0, baseY + railThin / 2, 0)
112
139
  mesh.receiveShadow = true
113
140
  this.object3d.add(mesh)
114
141
  }
115
142
 
143
+ // ── Trolley group — carriage assembly (rail 위 X 만 이동) ─────────
144
+ // 모든 movable 부품 (base trolley, masts, carriage, fork, cabinet, lamp)
145
+ // 의 parent. carriagePosition 변경 시 group 의 local X 만 변경.
146
+ const trolleyGroup = new THREE.Group()
147
+ this._trolleyGroup = trolleyGroup
148
+ this.object3d.add(trolleyGroup)
149
+ // 초기 carriagePosition 적용 — rail-local X (0 ~ width) → object3d-local X (-W/2 ~ +W/2)
150
+ const carriagePos = numOr((this.component.state as any).carriagePosition, (this.component as any)._canonicalDefault?.('carriagePosition') ?? width / 2)
151
+ trolleyGroup.position.x = carriagePos - width / 2
152
+
116
153
  // ── Base trolley ──────────────────────────────────────────────────
154
+ // Cabinet 이 mast 바깥쪽에 자연스럽게 놓이도록 *trolley 폭을 mast + cabinet
155
+ // padding 까지 확장*. carriage assembly width 보다 양 옆으로 (cabW + gap) × 2.
156
+ const trolleyPad = cabW + S * 0.04
157
+ const baseTrolleyW = carriageAssemblyW + trolleyPad * 2
117
158
  const baseTrolleyY = baseY + railH + baseH / 2
118
159
  {
119
- const geo = new THREE.BoxGeometry(width * 0.95, baseH, height * 0.7)
160
+ const geo = new THREE.BoxGeometry(baseTrolleyW, baseH, height * 0.7)
120
161
  const mesh = new THREE.Mesh(geo, trolleyMat)
121
162
  mesh.position.set(0, baseTrolleyY, 0)
122
163
  mesh.castShadow = true
123
164
  mesh.receiveShadow = true
124
- this.object3d.add(mesh)
165
+ trolleyGroup.add(mesh)
125
166
  }
126
167
 
127
- // ── Control cabinet on one side of base ───────────────────────────
168
+ // ── Control cabinet mast 바깥쪽 (trolley *확장 padding 영역* 위) ─────
128
169
  {
129
170
  const geo = new THREE.BoxGeometry(cabW, cabH, cabD)
130
171
  const cab = new THREE.Mesh(geo, cabinetMat)
131
172
  cab.position.set(
132
- -width * 0.4 + cabW / 2,
173
+ -(carriageAssemblyW / 2 + cabW / 2 + S * 0.02), // mast 왼쪽 바깥
133
174
  baseTrolleyY + baseH / 2 + cabH / 2,
134
175
  -height * 0.25 + cabD / 2
135
176
  )
136
177
  cab.castShadow = true
137
- this.object3d.add(cab)
178
+ trolleyGroup.add(cab)
138
179
  }
139
180
 
140
181
  // ── Status lamp ───────────────────────────────────────────────────
@@ -150,8 +191,8 @@ export class Crane3D extends RealObjectGroup {
150
191
  })
151
192
  const geo = new THREE.CylinderGeometry(lampR, lampR * 0.8, lampH, 12)
152
193
  const lamp = new THREE.Mesh(geo, lampMat)
153
- lamp.position.set(width * 0.4, baseTrolleyY + baseH / 2 + lampH / 2, 0)
154
- this.object3d.add(lamp)
194
+ lamp.position.set(carriageAssemblyW / 2 + lampR * 1.5 + S * 0.02, baseTrolleyY + baseH / 2 + lampH / 2, 0)
195
+ trolleyGroup.add(lamp)
155
196
  }
156
197
 
157
198
  // ── Twin masts ────────────────────────────────────────────────────
@@ -162,118 +203,131 @@ export class Crane3D extends RealObjectGroup {
162
203
  mesh.position.set(xOff, mastY, 0)
163
204
  mesh.castShadow = true
164
205
  mesh.receiveShadow = true
165
- this.object3d.add(mesh)
206
+ trolleyGroup.add(mesh)
166
207
  }
167
208
 
168
- // ── Carriage + Fork 어셈블리 (forkLift 함께 이동) ───────────────
169
- // 시각 단절 방지를 위해 carriage fork *함께* forkLift 만큼 올림.
170
- // 의미상으로는 carriageHeight mast carriage 위치, forkLift *그 어셈블리의*
171
- // 추가 미세 Y. 적용된 위치를 carriage / fork 둘 다 공유.
172
- const carriageY = baseTrolleyY + baseH / 2 + carriageHeight + forkLift + carriageH / 2
209
+ // ── Carriage + Fork lift group (carriageHeight + forkLiftRT 따라 Y 이동)
210
+ // _carriageLiftGroup 안에 carriage + forkGroup. forkLiftRT / carriageHeight
211
+ // 변경 *그룹 Y update* (mesh rebuild X). _forkGroup 의 child carrier 가
212
+ // *dispose 없이 그대로* 함께 따라 움직임.
213
+ const stubL = Math.min(carriageZ * 0.2, Math.max(bladeL * 0.05, 6))
214
+ const liftGroup = new THREE.Group()
215
+ this._carriageLiftGroup = liftGroup
216
+ this._liftBaseParams = { baseTrolleyY, baseH, carriageH, bladeH }
217
+ liftGroup.position.set(0, this._computeLiftGroupY(carriageHeight, forkLift), 0)
218
+ trolleyGroup.add(liftGroup)
219
+
220
+ // Carriage — liftGroup local center
173
221
  {
174
222
  const geo = new THREE.BoxGeometry(carriageW, carriageH, carriageZ)
175
223
  const mesh = new THREE.Mesh(geo, carriageMat)
176
- mesh.position.set(0, carriageY, 0)
224
+ mesh.position.set(0, 0, 0)
177
225
  mesh.castShadow = true
178
226
  mesh.receiveShadow = true
179
- this.object3d.add(mesh)
227
+ liftGroup.add(mesh)
180
228
  }
181
229
 
182
- // ── Two-prong forks (양옆 stub + active 신축, 2D 와 동일 모델) ─────
183
- // ext=0: ±Z 면에 작은 stub. carriage 안에 들어있는 인상 (튀어나옴 없음)
184
- // ext=±forkLen: active stub + |ext| 길이로 신장. 반대쪽 stub 유지.
185
- // 회전 flip 없음 ext 0 지날 시각 점프 없음.
186
- const forkY = carriageY // carriage 중심 Y (embed)
187
- const stubL = Math.min(carriageZ * 0.2, Math.max(bladeL * 0.05, 6))
230
+ // ── Two-prong forks ───────────────────────────────────────────────
231
+ // stub (4 box, fixed) + active extension (2 box, scale.z 로 lerp).
232
+ // active mesh *unit-length* 생성. _applyForkExtension scale.z
233
+ // position.z 길이/방향 update rebuild 없이 frame 부드러운 변형.
188
234
  const absExt = Math.abs(forkExtension)
189
235
  const sign = forkExtension >= 0 ? 1 : -1
190
236
  {
191
237
  const group = new THREE.Group()
192
- group.position.set(0, forkY, 0)
238
+ // _forkGroup 은 liftGroup-local center (0,0,0) — frame 일치 단순화.
239
+ // attach localPosition (carrier 자식) 도 *group-local = liftGroup-local* 동일 frame.
240
+ // Fork mesh 자체가 group-local 안에서 carriage *위* 로 (mesh.position.y).
241
+ group.position.set(0, 0, 0)
242
+
243
+ // Fork mesh 가 carriage 와 *수평 (같은 Y 평면)* — carriage 안에 embed.
244
+ // fork mesh group-local Y center = 0 (carriage center 와 같음).
245
+ // fork blade *bottom* 면 group-local Y = -bladeH/2.
246
+ // fork blade *top* 면 group-local Y = +bladeH/2.
247
+ const forkOffsetY = 0
193
248
 
194
249
  // 양옆 stub — 두 prong × 두 측면 = 4 box
195
250
  const stubGeo = new THREE.BoxGeometry(bladeW, bladeH, stubL)
196
251
  for (const xOff of [-bladeSpacing / 2, +bladeSpacing / 2]) {
197
252
  for (const zSide of [-1, +1]) {
198
253
  const mesh = new THREE.Mesh(stubGeo, forkMat)
199
- mesh.position.set(xOff, 0, zSide * (carriageZ / 2 + stubL / 2))
254
+ mesh.position.set(xOff, forkOffsetY, zSide * (carriageZ / 2 + stubL / 2))
200
255
  mesh.castShadow = true
201
256
  mesh.receiveShadow = true
202
257
  group.add(mesh)
203
258
  }
204
259
  }
205
260
 
206
- // Active side 신장
207
- if (absExt > 0.5) {
208
- const extGeo = new THREE.BoxGeometry(bladeW, bladeH, absExt)
209
- for (const xOff of [-bladeSpacing / 2, +bladeSpacing / 2]) {
210
- const mesh = new THREE.Mesh(extGeo, forkMat)
211
- mesh.position.set(xOff, 0, sign * (carriageZ / 2 + stubL + absExt / 2))
212
- mesh.castShadow = true
213
- mesh.receiveShadow = true
214
- group.add(mesh)
215
- }
216
- }
217
-
218
- // Pallet — 정지 시 carriage 중심 Z, 신축 시 fork 중간으로 이동.
219
- const loaded = forkLift > 0 || !!(this.component.state as any).loaded || !!(this.component.state as any).carrying
220
- if (loaded) {
221
- const palletMat = new THREE.MeshStandardMaterial({
222
- color: 0xa08864, metalness: 0.1, roughness: 0.85
223
- })
224
- const palletW = bladeSpacing * 1.2
225
- const palletH = Math.max(bladeH * 2.5, carriageH * 0.5)
226
- const palletL = Math.max(bladeL * 0.3, carriageZ * 0.6)
227
- const geo = new THREE.BoxGeometry(palletW, palletH, palletL)
228
- const pallet = new THREE.Mesh(geo, palletMat)
229
- const palletZ = absExt < 0.5 ? 0 : sign * (carriageZ / 2 + absExt / 2)
230
- pallet.position.set(0, carriageH / 2 + palletH / 2, palletZ)
231
- pallet.castShadow = true
232
- pallet.receiveShadow = true
233
- group.add(pallet)
234
- }
235
-
236
- this.object3d.add(group)
261
+ // Active extension — unit length, scale.z + position.z 로 변형
262
+ const extGeo = new THREE.BoxGeometry(bladeW, bladeH, 1)
263
+ const extLeft = new THREE.Mesh(extGeo, forkMat)
264
+ const extRight = new THREE.Mesh(extGeo, forkMat)
265
+ this._forkOffsetY = forkOffsetY
266
+ extLeft.castShadow = true
267
+ extLeft.receiveShadow = true
268
+ extRight.castShadow = true
269
+ extRight.receiveShadow = true
270
+ group.add(extLeft)
271
+ group.add(extRight)
272
+ this._extLeftMesh = extLeft
273
+ this._extRightMesh = extRight
274
+ this._extBaseParams = { bladeSpacing, carriageZ, stubL }
275
+ this._applyForkExtensionMeshes(absExt, sign)
276
+
277
+ // carrier 초기 Z = sign * absExt (= _applyForkExtensionMeshes 의 공식 동일).
278
+ const carrierZ = sign * absExt
279
+
280
+ liftGroup.add(group)
237
281
  this._forkGroup = group
238
- this._forkTopY = carriageH / 2
239
- this._bladeMidZ = absExt < 0.5 ? 0 : sign * (carriageZ / 2 + absExt / 2)
282
+ // Carrier 외부 bottom 정렬점 (liftGroup-local Y) = fork blade *bottom* =
283
+ // -bladeH/2 (fork mesh group-local center = 0, 두께 bladeH).
284
+ // 사용자 모델: "fork 의 아랫면 ≈ carrier 의 아랫면" — fork blade 가 carrier
285
+ // 의 bottom 부분 안으로 *찔러 들어감* (겹친 자세).
286
+ this._carrierBaseY = -bladeH / 2
287
+ this._bladeMidZ = carrierZ
240
288
  }
241
289
 
242
- // ── Top frame (connects mast tops) ────────────────────────────────
290
+ // ── Top frame (connects mast tops) — trolley 함께 이동 ─────────────
243
291
  const topFrameY = mastY + mastH / 2 + topFrameH / 2
244
292
  {
245
293
  const geo = new THREE.BoxGeometry(mastSpacing + mastW, topFrameH, height * 0.35)
246
294
  const mesh = new THREE.Mesh(geo, trolleyMat)
247
295
  mesh.position.set(0, topFrameY, 0)
248
296
  mesh.castShadow = true
249
- this.object3d.add(mesh)
297
+ trolleyGroup.add(mesh)
250
298
  }
251
299
 
252
- // ── Top guide trolley ─────────────────────────────────────────────
300
+ // ── Top guide trolley — trolley 함께 이동 (ceiling rail 위 미끄러짐) ─
253
301
  const topGuideY = topFrameY + topFrameH / 2 + topGuideH / 2
254
302
  {
255
303
  const geo = new THREE.BoxGeometry(mastSpacing + mastW * 2, topGuideH, height * 0.3)
256
304
  const mesh = new THREE.Mesh(geo, trolleyMat)
257
305
  mesh.position.set(0, topGuideY, 0)
258
306
  mesh.castShadow = true
259
- this.object3d.add(mesh)
307
+ trolleyGroup.add(mesh)
260
308
  }
261
309
 
262
- // ── Ceiling rail ──────────────────────────────────────────────────
263
- {
264
- const geo = new THREE.BoxGeometry(width * 1.1, railH, height * 0.3)
265
- const mesh = new THREE.Mesh(geo, railMat)
266
- mesh.position.set(0, topGuideY + topGuideH / 2 + railH / 2, 0)
267
- this.object3d.add(mesh)
268
- }
310
+ // Ceiling rail 생략 — 상단은 top guide trolley 만으로 충분. 사용자 의도.
311
+
269
312
  }
270
313
 
271
314
  getCarriageFrame(): THREE.Object3D | undefined {
272
- return this._forkGroup ?? this.object3d
315
+ // Fallback chain — carrier 가 *carriage transform (X 이동, Y lift)* 따라오도록.
316
+ // _forkGroup 미존재 시 _carriageLiftGroup (X+Y 따라옴) → _trolleyGroup (X 만) →
317
+ // root (no follow). 절대 root 로 떨어지지 않도록 lift/trolley 우선.
318
+ return this._forkGroup ?? this._carriageLiftGroup ?? this._trolleyGroup ?? this.object3d
273
319
  }
274
320
 
275
- get platformTopY(): number {
276
- return this._forkTopY
321
+ /**
322
+ * Fork blade *bottom* 의 liftGroup-local Y. *carrier 외부 bottom 정렬점*.
323
+ *
324
+ * 모델: carrier 의 외부 bottom 과 fork blade 의 bottom 이 *거의 일치*. fork 가
325
+ * carrier 의 bottom 부분을 *찔러 들어가* carrier 와 *겹친 자세* (pallet pocket
326
+ * 안 fork 진입). attachPointFor 가 `carrierBaseY + carrier.depth/2` 로 carrier
327
+ * center 를 정렬 → carrier bottom = fork blade bottom.
328
+ */
329
+ get carrierBaseY(): number {
330
+ return this._carrierBaseY
277
331
  }
278
332
 
279
333
  get bladeMidZ(): number {
@@ -283,24 +337,135 @@ export class Crane3D extends RealObjectGroup {
283
337
  updateDimension() {}
284
338
 
285
339
  onchange(after: Record<string, unknown>, before: Record<string, unknown>) {
286
- if (
340
+ // carriagePosition — trolleyGroup.position.x (mesh-level update).
341
+ if ('carriagePosition' in after && this._trolleyGroup) {
342
+ const W = numOr(this.component.state.width, 100)
343
+ const pos = numOr(after.carriagePosition, W / 2)
344
+ this._trolleyGroup.position.x = pos - W / 2
345
+ }
346
+
347
+ // Mesh-level updates — fork extension / lift / carriage height. *rebuild 없이*
348
+ // mesh 의 scale / position 만 변경. _forkGroup 의 child carrier 가 dispose
349
+ // 없이 그대로 따라 움직임 (fork 작업 시 시각 자연스러움).
350
+ //
351
+ // status / bodyColor / lampEmissive 는 *cosmetic* — full rebuild 회피. 별도
352
+ // 처리 없음 시 status 변경 시 carrier dispose → 사라짐 결함의 원인. 향후
353
+ // material color 만 update 하는 path 추가 가능.
354
+ const needsFullRebuild =
287
355
  'width' in after ||
288
356
  'height' in after ||
289
357
  'depth' in after ||
290
- 'carriageHeight' in after ||
291
358
  'forkLength' in after ||
292
- 'forkExtension' in after ||
293
- 'forkLift' in after ||
294
- 'status' in after ||
295
- 'bodyColor' in after ||
296
- 'lampEmissive' in after
297
- ) {
359
+ 'carriageWidth' in after
360
+
361
+ if (!needsFullRebuild) {
362
+ let meshUpdated = false
363
+ if (('carriageHeight' in after || 'forkLiftRT' in after) && this._carriageLiftGroup) {
364
+ const state = this.component.state as any
365
+ const D = numOr(state.depth, Math.max(numOr(state.width, 100), numOr(state.height, 100)) * 4)
366
+ const carriageRaw = numOr(state.carriageHeight, D * 0.4)
367
+ const carriageHeight = Math.max(0, Math.min(carriageRaw, D * 0.85))
368
+ const forkLift = numOr(state.forkLiftRT, 0)
369
+ this._carriageLiftGroup.position.y = this._computeLiftGroupY(carriageHeight, forkLift)
370
+ meshUpdated = true
371
+ }
372
+ if ('forkExtension' in after && this._extLeftMesh && this._extRightMesh) {
373
+ const state = this.component.state as any
374
+ const forkLength = numOr(state.forkLength, numOr(state.height, 100) * 0.6)
375
+ const forkExtensionRaw = numOr(state.forkExtension, 0)
376
+ const forkExtension = Math.max(-forkLength, Math.min(forkLength, forkExtensionRaw))
377
+ const absExt = Math.abs(forkExtension)
378
+ const sign = forkExtension >= 0 ? 1 : -1
379
+ this._applyForkExtensionMeshes(absExt, sign)
380
+ meshUpdated = true
381
+ }
382
+ if (meshUpdated) return
383
+ }
384
+
385
+ if (needsFullRebuild) {
298
386
  this.update()
299
387
  return
300
388
  }
301
389
  super.onchange(after, before)
302
390
  }
303
391
 
392
+ /** carriageHeight + forkLiftRT 를 liftGroup.position.y 로 변환. */
393
+ private _computeLiftGroupY(carriageHeight: number, forkLift: number): number {
394
+ const p = this._liftBaseParams
395
+ if (!p) return 0
396
+ return p.baseTrolleyY + p.baseH / 2 + carriageHeight + forkLift + p.carriageH / 2
397
+ }
398
+
399
+ /**
400
+ * Carrier 외부 bottom 의 world Y → carriageHeight state 값 inverse-solve.
401
+ *
402
+ * Forward 공식 (build):
403
+ * liftGroup.y crane-local = baseTrolleyY + baseH/2 + carriageHeight + forkLift + carriageH/2
404
+ * carrier 외부 bottom crane-local = liftGroup.y + carrierBaseY (= -bladeH/2)
405
+ * = baseTrolleyY + baseH/2 + carriageH/2 - bladeH/2 + carriageHeight + forkLift
406
+ *
407
+ * Inverse:
408
+ * carriageHeight = worldY − craneCenterY − (baseTrolleyY + baseH/2 + carriageH/2 − bladeH/2) − forkLift
409
+ */
410
+ solveCarriageHeightForCarrierBaseWorldY(worldY: number, forkLift: number = 0): number {
411
+ const p = this._liftBaseParams
412
+ if (!p) return 0
413
+ this.object3d.updateWorldMatrix(true, false)
414
+ const v = new THREE.Vector3()
415
+ this.object3d.matrixWorld.decompose(v, new THREE.Quaternion(), new THREE.Vector3())
416
+ const craneCenterWorldY = v.y
417
+ return worldY - craneCenterWorldY - (p.baseTrolleyY + p.baseH / 2 + p.carriageH / 2 - p.bladeH / 2) - forkLift
418
+ }
419
+
420
+ /**
421
+ * target 의 *crane-local Z* → fork extension 값 inverse-solve.
422
+ *
423
+ * Forward (_applyForkExtensionMeshes): `_bladeMidZ = sign * absExt`
424
+ * (carrier 가 ext 만큼 fork 따라 진출). Inverse: `ext = |localZ|, sign = sign(localZ)`.
425
+ *
426
+ * forkLength 로 clamp — localZ 가 forkLength 보다 멀면 carrier 가 fork tip 까지만.
427
+ */
428
+ solveForkExtensionForLocalZ(localZ: number): number {
429
+ const sign = localZ >= 0 ? 1 : -1
430
+ const state = this.component.state as any
431
+ const forkLen = numOr(state.forkLength, numOr(state.height, 100) * 0.6)
432
+ const clamped = Math.max(0, Math.min(forkLen, Math.abs(localZ)))
433
+ return sign * clamped
434
+ }
435
+
436
+ /** Fork active extension mesh 의 scale.z + position.z + visibility update. */
437
+ private _applyForkExtensionMeshes(absExt: number, sign: number) {
438
+ if (!this._extLeftMesh || !this._extRightMesh || !this._extBaseParams) return
439
+ const { bladeSpacing, carriageZ, stubL } = this._extBaseParams
440
+ const visible = absExt > 0.5
441
+ const len = Math.max(0.001, absExt)
442
+ const posZ = sign * (carriageZ / 2 + stubL + absExt / 2)
443
+ this._extLeftMesh.scale.z = len
444
+ this._extRightMesh.scale.z = len
445
+ this._extLeftMesh.position.set(-bladeSpacing / 2, this._forkOffsetY, posZ)
446
+ this._extRightMesh.position.set(+bladeSpacing / 2, this._forkOffsetY, posZ)
447
+ this._extLeftMesh.visible = visible
448
+ this._extRightMesh.visible = visible
449
+
450
+ // _bladeMidZ = carrier 의 Z 위치 = sign * absExt (= fork extension 만큼 직접).
451
+ // ext=0 → 0 (carriage 정중앙 — retract 끝 자세)
452
+ // ext=L → ±L (fork 가 L 만큼 진출한 위치 = carrier 도 그 위치)
453
+ // 단순 linear — solveForkExtensionForLocalZ 의 inverse 도 단순 (ext = |localZ|).
454
+ this._bladeMidZ = sign * absExt
455
+
456
+ // _forkGroup 의 child carrier 의 Z 도 동기 — fork tip 위치 따라 carrier 가
457
+ // 함께 끌려와야 retract 시각 자연.
458
+ if (this._forkGroup) {
459
+ for (const child of this._forkGroup.children) {
460
+ const ctx = (child as any).userData?.context
461
+ if (ctx && ctx !== this && ctx instanceof RealObject) {
462
+ child.position.z = this._bladeMidZ
463
+ }
464
+ }
465
+ }
466
+
467
+ }
468
+
304
469
  updateAlpha() {}
305
470
  }
306
471