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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/box.js +18 -0
  3. package/dist/box.js.map +1 -1
  4. package/dist/crane-3d.d.ts +47 -2
  5. package/dist/crane-3d.js +246 -89
  6. package/dist/crane-3d.js.map +1 -1
  7. package/dist/crane.d.ts +96 -12
  8. package/dist/crane.js +395 -100
  9. package/dist/crane.js.map +1 -1
  10. package/dist/pallet.d.ts +15 -0
  11. package/dist/pallet.js +38 -2
  12. package/dist/pallet.js.map +1 -1
  13. package/dist/parcel-3d.js +22 -18
  14. package/dist/parcel-3d.js.map +1 -1
  15. package/dist/parcel.d.ts +4 -3
  16. package/dist/parcel.js +24 -5
  17. package/dist/parcel.js.map +1 -1
  18. package/dist/storage-cell.d.ts +5 -2
  19. package/dist/storage-cell.js +21 -3
  20. package/dist/storage-cell.js.map +1 -1
  21. package/dist/storage-rack-3d.js +42 -7
  22. package/dist/storage-rack-3d.js.map +1 -1
  23. package/dist/storage-rack.d.ts +26 -2
  24. package/dist/storage-rack.js +92 -10
  25. package/dist/storage-rack.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/box.ts +18 -0
  28. package/src/crane-3d.ts +258 -93
  29. package/src/crane.ts +445 -110
  30. package/src/pallet.ts +50 -1
  31. package/src/parcel-3d.ts +23 -18
  32. package/src/parcel.ts +24 -5
  33. package/src/storage-cell.ts +23 -3
  34. package/src/storage-rack-3d.ts +47 -8
  35. package/src/storage-rack.ts +110 -10
  36. package/test/test-cell-position.ts +105 -0
  37. package/test/test-crane-geometry.ts +167 -0
  38. package/test/test-phase-h-carrier-pickable.ts +4 -3
  39. package/translations/en.json +5 -1
  40. package/translations/ja.json +5 -1
  41. package/translations/ko.json +5 -1
  42. package/translations/ms.json +5 -1
  43. package/translations/zh.json +5 -1
  44. package/tsconfig.tsbuildinfo +1 -1
package/src/pallet.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  RealObject,
9
9
  Pose6DOF,
10
10
  rectangularFootprintFrames,
11
+ topApproachFrame,
11
12
  getWorldPose,
12
13
  sceneComponent
13
14
  } from '@hatiolab/things-scene'
@@ -41,6 +42,15 @@ export interface PalletState extends State {
41
42
  // ── 외관 ──
42
43
  material?: PalletMaterial
43
44
 
45
+ /**
46
+ * Fork pocket 의 깊이 (mm) — pallet 외부 bottom (skid bottom) 부터 deck bottom
47
+ * 까지의 수직 거리. fork blade 가 이 *pocket 안* 으로 수평 진입. 표준 EUR
48
+ * pallet (144mm depth) 의 pocket ≈ 50~60mm.
49
+ *
50
+ * 미명시 시 default = depth 의 40% (= ~60mm for 150mm pallet).
51
+ */
52
+ pocketDepth?: number
53
+
44
54
  // ── 3D 재질 ──
45
55
  material3d?: Material3D
46
56
  }
@@ -66,6 +76,12 @@ const NATURE: ComponentNature = {
66
76
  { display: 'Plastic', value: 'plastic' }
67
77
  ]
68
78
  }
79
+ },
80
+ {
81
+ type: 'number',
82
+ label: 'pocket-depth',
83
+ name: 'pocketDepth',
84
+ placeholder: 'mm — fork 진입 pocket 깊이 (skid bottom → deck bottom). default depth × 40%.'
69
85
  }
70
86
  ],
71
87
  help: 'scene/component/pallet'
@@ -121,6 +137,24 @@ export default class Pallet extends Carriable(Legendable(Placeable(ContainerAbst
121
137
  return NATURE
122
138
  }
123
139
 
140
+ /**
141
+ * Fork pocket 의 깊이 — fork blade 가 진입하는 *pallet 외부 bottom 부터 deck
142
+ * bottom* 까지의 수직 거리. crane.attachPointFor 가 이 값을 차감해서
143
+ * carrier 외부 bottom (skid) 이 fork blade bottom *아래로 pocketDepth 만큼*
144
+ * 깊이 정렬 (= fork 가 pallet 의 *deck 와 skid 사이* 안 으로 들어간 자세).
145
+ */
146
+ get pocketDepth(): number {
147
+ const explicit = (this.state as any).pocketDepth
148
+ if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) {
149
+ return explicit
150
+ }
151
+ const d = this.state.depth
152
+ const depth = typeof d === 'number' && Number.isFinite(d) && d > 0
153
+ ? d
154
+ : ((this.constructor as any).defaultDepth ?? 150)
155
+ return depth * 0.4
156
+ }
157
+
124
158
  get anchors() {
125
159
  return []
126
160
  }
@@ -240,7 +274,7 @@ export default class Pallet extends Carriable(Legendable(Placeable(ContainerAbst
240
274
 
241
275
  const longerAxis = Math.max(width, height)
242
276
 
243
- return rectangularFootprintFrames({
277
+ const forkFrames = rectangularFootprintFrames({
244
278
  carrierWorld: me,
245
279
  width,
246
280
  depth: height,
@@ -251,5 +285,20 @@ export default class Pallet extends Carriable(Legendable(Placeable(ContainerAbst
251
285
  tolerance: { positionMm: 50, angleDeg: 5 },
252
286
  priority: 0
253
287
  })
288
+
289
+ // 큰 AGV (deck size 충분) 가 pallet 을 위에 적재하는 패턴. priority 낮음
290
+ // — forklift fork 가 더 자연스러운 default. AGV deck size 가 pallet 보다
291
+ // 작으면 application 측이 carrier 의 dimensions check 필요.
292
+ const deckFrame = topApproachFrame({
293
+ carrierWorld: me,
294
+ topY: palletDepth,
295
+ approachDistance: longerAxis * 0.5 + 100,
296
+ toolType: 'agv-deck',
297
+ tolerance: { positionMm: 30, angleDeg: 4 },
298
+ priority: 1,
299
+ id: 'top-deck'
300
+ })
301
+
302
+ return [...forkFrames, deckFrame]
254
303
  }
255
304
  }
package/src/parcel-3d.ts CHANGED
@@ -21,6 +21,26 @@ const CARDBOARD_COLOR = 0xc8a878
21
21
  const TAPE_COLOR = 0xddc899
22
22
  const LABEL_COLOR = 0xeeeeee
23
23
 
24
+ // ── Module-level shared materials ───────────────────────────────────────────
25
+ // Parcel 인스턴스가 수백~수천 가능. instance 별 new MeshStandardMaterial 시
26
+ // material 개수 폭증 → GPU memory + draw call 비효율. static color 라 단일
27
+ // instance 공유. 색 변경 시 모든 Parcel 에 자동 반영 (의도).
28
+ const PARCEL_BODY_MATERIAL = new THREE.MeshStandardMaterial({
29
+ color: CARDBOARD_COLOR,
30
+ metalness: 0,
31
+ roughness: 0.9
32
+ })
33
+ const PARCEL_TAPE_MATERIAL = new THREE.MeshStandardMaterial({
34
+ color: TAPE_COLOR,
35
+ metalness: 0.05,
36
+ roughness: 0.5
37
+ })
38
+ const PARCEL_LABEL_MATERIAL = new THREE.MeshStandardMaterial({
39
+ color: LABEL_COLOR,
40
+ metalness: 0,
41
+ roughness: 0.4
42
+ })
43
+
24
44
  export class Parcel3D extends RealObjectGroup {
25
45
  build() {
26
46
  super.build()
@@ -30,12 +50,7 @@ export class Parcel3D extends RealObjectGroup {
30
50
 
31
51
  // ── Main body ────────────────────────────────────────────────────
32
52
  const bodyGeo = new THREE.BoxGeometry(width, depth, height)
33
- const bodyMaterial = new THREE.MeshStandardMaterial({
34
- color: CARDBOARD_COLOR,
35
- metalness: 0,
36
- roughness: 0.9
37
- })
38
- const bodyMesh = new THREE.Mesh(bodyGeo, bodyMaterial)
53
+ const bodyMesh = new THREE.Mesh(bodyGeo, PARCEL_BODY_MATERIAL)
39
54
  bodyMesh.position.set(0, 0, 0)
40
55
  bodyMesh.castShadow = true
41
56
  bodyMesh.receiveShadow = true
@@ -48,12 +63,7 @@ export class Parcel3D extends RealObjectGroup {
48
63
  const tapeGeo = tapeAlongLong
49
64
  ? new THREE.BoxGeometry(width * 1.005, tapeT, tapeW)
50
65
  : new THREE.BoxGeometry(tapeW, tapeT, height * 1.005)
51
- const tapeMaterial = new THREE.MeshStandardMaterial({
52
- color: TAPE_COLOR,
53
- metalness: 0.05,
54
- roughness: 0.5
55
- })
56
- const tapeMesh = new THREE.Mesh(tapeGeo, tapeMaterial)
66
+ const tapeMesh = new THREE.Mesh(tapeGeo, PARCEL_TAPE_MATERIAL)
57
67
  tapeMesh.position.set(0, baseY + depth + tapeT / 2 - 0.01, 0)
58
68
  this.object3d.add(tapeMesh)
59
69
 
@@ -61,12 +71,7 @@ export class Parcel3D extends RealObjectGroup {
61
71
  const labelW = Math.min(width, height) * 0.35
62
72
  const labelH = labelW * 0.6
63
73
  const labelGeo = new THREE.BoxGeometry(labelW, depth * 0.005, labelH)
64
- const labelMaterial = new THREE.MeshStandardMaterial({
65
- color: LABEL_COLOR,
66
- metalness: 0,
67
- roughness: 0.4
68
- })
69
- const labelMesh = new THREE.Mesh(labelGeo, labelMaterial)
74
+ const labelMesh = new THREE.Mesh(labelGeo, PARCEL_LABEL_MATERIAL)
70
75
  // Position on top, off-center by ~25% of long axis
71
76
  if (tapeAlongLong) {
72
77
  labelMesh.position.set(width * 0.2, baseY + depth + depth * 0.0025, -height * 0.15)
package/src/parcel.ts CHANGED
@@ -93,9 +93,10 @@ export default class Parcel extends Carriable(Placeable(RectPath(Shape))) {
93
93
  }
94
94
 
95
95
  /**
96
- * Phase H — pickup contract. Parcel 위에서 vacuum gripper / suction cup 으로
97
- * 집기 Box 동일한 패턴이지만 cardboard 표면이라 더 큰 흡착 필요.
98
- * tolerance 약간 완화 (cardboard 변형 가능성).
96
+ * Phase H — pickup contract. Parcel pickup 방식:
97
+ * - gripper (vacuum / suction): 위에서 흡착 RobotArm
98
+ * - agv-deck: AGV/Forklift deck 위에 위에서 얹기 — 같은 top approach 지만
99
+ * deck 자체가 운반체라 tolerance 더 완화
99
100
  */
100
101
  pickupFrames(): PickupFrame[] {
101
102
  const wp = getWorldPose(this)
@@ -109,11 +110,29 @@ export default class Parcel extends Carriable(Placeable(RectPath(Shape))) {
109
110
  topApproachFrame({
110
111
  carrierWorld: me,
111
112
  topY: parcelDepth,
112
- approachDistance: 80, // gripper hover 거리 (Box 보다 더 — vacuum 펼침)
113
+ approachDistance: 80,
113
114
  toolType: 'gripper',
114
- tolerance: { positionMm: 10, angleDeg: 2 }, // cardboard 변형 감안
115
+ tolerance: { positionMm: 10, angleDeg: 2 },
115
116
  priority: 0,
116
117
  id: 'top-suction'
118
+ }),
119
+ topApproachFrame({
120
+ carrierWorld: me,
121
+ topY: parcelDepth,
122
+ approachDistance: 60,
123
+ toolType: 'agv-deck',
124
+ tolerance: { positionMm: 20, angleDeg: 5 },
125
+ priority: 1,
126
+ id: 'top-deck'
127
+ }),
128
+ topApproachFrame({
129
+ carrierWorld: me,
130
+ topY: parcelDepth,
131
+ approachDistance: 100, // crane fork 가 cell 진입 hover
132
+ toolType: 'forklift-fork',
133
+ tolerance: { positionMm: 30, angleDeg: 5 }, // fork 적재 tolerance
134
+ priority: 2, // gripper/deck 다음
135
+ id: 'top-fork'
117
136
  })
118
137
  ]
119
138
  }
@@ -157,6 +157,22 @@ export default class RackCell extends CarrierHolder(ContainerAbstract) {
157
157
  }
158
158
  carrier[TRANSFER_SLOT_KEY] = this.cellId
159
159
  this.reparent(carrier, options)
160
+
161
+ // carrier.state.left/top/zPos 을 *cell-local center* 로 명시. 이전 holder
162
+ // 의 state (예: crane-local center) 가 그대로 남으면 *다음 pick 시
163
+ // moveTo(carrier) 의 target.center 계산이 *잘못된 좌표* 로 → 엉뚱한 위치
164
+ // 이동 결함. transient placement 'carried' 라 3D obj3d.position 영향 X,
165
+ // 2D render 와 moveTo 의 center 계산에만 영향.
166
+ const cw = numOr((this as any).state?.width, 0)
167
+ const ch = numOr((this as any).state?.height, 0)
168
+ const carrierW = numOr(carrier?.state?.width, 0)
169
+ const carrierH = numOr(carrier?.state?.height, 0)
170
+ carrier.setState?.({
171
+ left: (cw - carrierW) / 2,
172
+ top: (ch - carrierH) / 2,
173
+ zPos: 0
174
+ })
175
+
160
176
  this.trigger('transfer-received', {
161
177
  type: 'transfer-received',
162
178
  component: carrier,
@@ -209,16 +225,20 @@ export default class RackCell extends CarrierHolder(ContainerAbstract) {
209
225
 
210
226
  /**
211
227
  * Return the 3D attach frame for carriers placed in this cell.
212
- * Carriers are lifted by their own halfDepth so the bottom face
213
- * rests at the cell's Y-center (which is levelHeight/2 above the beam).
228
+ *
229
+ * Center-origin convention: cell *local origin* cell center
230
+ * (= levelHeight/2 above the shelf beam). carrier 의 *bottom face* 가 cell
231
+ * 의 *bottom* (= local Y -cellDepth/2) 에 닿도록 carrier center =
232
+ * -cellDepth/2 + carrierDepth/2.
214
233
  */
215
234
  attachPointFor(carrier: Component): AttachFrame | null {
216
235
  const root = this._realObject?.object3d
217
236
  if (!root) return null
218
237
  const carrierDepth = resolveCarrierDepth(carrier)
238
+ const cellDepth = numOr((this as any).state?.depth, 0)
219
239
  return {
220
240
  attach: root,
221
- localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
241
+ localPosition: { x: 0, y: -cellDepth / 2 + carrierDepth / 2, z: 0 }
222
242
  }
223
243
  }
224
244
 
@@ -34,10 +34,17 @@ export class StorageRack3D extends RealObjectGroup {
34
34
  const { width, height, depth = 3000 } = this.component.state
35
35
  const levels = Math.max(1, Math.floor((this.component.state.levels as number) || 4))
36
36
  const bays = Math.max(1, Math.floor((this.component.state.bays as number) || 5))
37
-
38
- const baseY = -depth / 2
37
+ const shelfBase = Math.max(0, Math.min(
38
+ (this.component.state.shelfBaseHeight as number) || 0,
39
+ depth * 0.9
40
+ ))
41
+ const shelfZone = depth - shelfBase // 실제 shelf 가 차지하는 Y
42
+
43
+ const baseY = -depth / 2 // rack 바닥 (3D Y 의 최저)
44
+ const shelfBaseY = baseY + shelfBase // 첫 shelf 의 시작 (= level 1 의 바닥)
39
45
  const postW = Math.min(width / bays, height) * 0.06
40
- const beamH = depth * 0.025
46
+ // beam 두께 = post 비슷 (산업 beam 이 post 보다 약간 두꺼움 — 1.2배)
47
+ const beamH = postW * 1.2
41
48
  const braceT = postW * 0.6
42
49
 
43
50
  const postMaterial = new THREE.MeshStandardMaterial({
@@ -75,11 +82,11 @@ export class StorageRack3D extends RealObjectGroup {
75
82
  this.object3d.add(postMesh)
76
83
 
77
84
  // ── Horizontal beams (front + back faces at each level) ──────────
78
- // levels + 1 vertical positions (level 0 = ground, level N = top).
85
+ // shelf zone 안 levels+1 위치 (level 0 = shelfBase, level N = 천장).
79
86
  const beamGeos: THREE.BufferGeometry[] = []
80
87
  for (let lv = 0; lv <= levels; lv++) {
81
88
  const yFrac = lv / levels
82
- const y = baseY + yFrac * depth - beamH / 2 + (lv === 0 ? beamH : 0)
89
+ const y = shelfBaseY + yFrac * shelfZone - beamH / 2 + (lv === 0 ? beamH : 0)
83
90
 
84
91
  for (const zSign of [-1, 1]) {
85
92
  const beam = new THREE.BoxGeometry(width, beamH, beamH)
@@ -97,7 +104,7 @@ export class StorageRack3D extends RealObjectGroup {
97
104
  // bay-tall cell. Visual signature of a load-bearing rack.
98
105
  const braceGeos: THREE.BufferGeometry[] = []
99
106
  const cellW = width / bays
100
- const cellH = depth / levels
107
+ const cellH = shelfZone / levels // cell 높이 (shelf zone 안)
101
108
  const braceLen = Math.sqrt(cellW * cellW + cellH * cellH)
102
109
  const braceAngle = Math.atan2(cellH, cellW)
103
110
  const backZ = height / 2 - postW * 0.6
@@ -109,7 +116,7 @@ export class StorageRack3D extends RealObjectGroup {
109
116
  const cellCenterX = (bay - bays / 2 + 0.5) * cellW
110
117
 
111
118
  for (let lv = 0; lv < levels; lv++) {
112
- const cellCenterY = baseY + (lv + 0.5) * cellH
119
+ const cellCenterY = shelfBaseY + (lv + 0.5) * cellH
113
120
 
114
121
  for (const sign of [-1, 1]) {
115
122
  const brace = new THREE.BoxGeometry(braceLen, braceT, braceT)
@@ -124,6 +131,37 @@ export class StorageRack3D extends RealObjectGroup {
124
131
  braceMesh.castShadow = true
125
132
  this.object3d.add(braceMesh)
126
133
  }
134
+
135
+ // ── Shelf planes (level 별 반투명 무볼륨 판) ────────────────────────────
136
+ // 각 level 의 *바닥 면* 에 plane — cell 위치 시각 인식. carrier 가 그 위
137
+ // 에 놓이는 *지지면*. 반투명.
138
+ //
139
+ // X-Z 넓이를 *frame 안쪽* 으로 줄여 mesh 겹침 자체 제거 (Z-fight 회피).
140
+ // X: 양 옆 corner post 안쪽 (-postW 양쪽)
141
+ // Z: 앞/뒤 beam 안쪽 (-beamH 양쪽)
142
+ const shelfW = Math.max(0, width - 2 * postW)
143
+ const shelfD = Math.max(0, height - 2 * beamH)
144
+ const shelfGeo = new THREE.PlaneGeometry(shelfW, shelfD)
145
+ shelfGeo.rotateX(-Math.PI / 2) // X-Y plane → X-Z plane (= horizontal)
146
+ const shelfMaterial = new THREE.MeshStandardMaterial({
147
+ color: BEAM_COLOR,
148
+ metalness: 0.3,
149
+ roughness: 0.6,
150
+ transparent: true,
151
+ opacity: 0.25,
152
+ side: THREE.DoubleSide
153
+ })
154
+ for (let lv = 0; lv < levels; lv++) {
155
+ // shelf plane Y = 해당 level 의 *load beam top* 정확 일치 (cell 바닥 면).
156
+ // beam center Y = shelfBaseY + yFrac*shelfZone - beamH/2 + (lv===0 ? beamH : 0)
157
+ // beam top Y = beam center + beamH/2 = shelfBaseY + yFrac*shelfZone + (lv===0 ? beamH : 0)
158
+ const yFrac = lv / levels
159
+ const y = shelfBaseY + yFrac * shelfZone + (lv === 0 ? beamH : 0)
160
+ const shelf = new THREE.Mesh(shelfGeo, shelfMaterial)
161
+ shelf.position.set(0, y, 0)
162
+ shelf.receiveShadow = true
163
+ this.object3d.add(shelf)
164
+ }
127
165
  }
128
166
 
129
167
  updateDimension() {}
@@ -134,7 +172,8 @@ export class StorageRack3D extends RealObjectGroup {
134
172
  'bays' in after ||
135
173
  'width' in after ||
136
174
  'height' in after ||
137
- 'depth' in after
175
+ 'depth' in after ||
176
+ 'shelfBaseHeight' in after
138
177
  ) {
139
178
  this.update()
140
179
  return
@@ -22,6 +22,13 @@ export interface StorageRackState extends State {
22
22
  bays?: number
23
23
  levels?: number
24
24
 
25
+ /**
26
+ * Level 1 (첫 shelf) 의 *시작 높이* (mm, rack 의 3D Y 축, 바닥부터). 미명시 0
27
+ * (바닥 = 첫 shelf). 양수 시 그만큼 위로 올라가 stocker port / conveyor 같은
28
+ * 컴포넌트가 들어갈 *빈 공간* 확보. Frame uprights 는 바닥 ~ 천장 그대로.
29
+ */
30
+ shelfBaseHeight?: number
31
+
25
32
  // ── 디버그 ──
26
33
  debugCells?: boolean
27
34
 
@@ -45,6 +52,12 @@ const NATURE: ComponentNature = {
45
52
  label: 'bays',
46
53
  name: 'bays',
47
54
  placeholder: '# of horizontal bays (default 5)'
55
+ },
56
+ {
57
+ type: 'number',
58
+ label: 'shelf-base-height',
59
+ name: 'shelfBaseHeight',
60
+ placeholder: 'mm — level 1 시작 높이 (바닥부터). stocker port / conveyor 공간.'
48
61
  }
49
62
  ],
50
63
  help: 'scene/component/rack'
@@ -98,6 +111,50 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
98
111
  return []
99
112
  }
100
113
 
114
+ /**
115
+ * Model serialization — storage-cell 자식 자동 제외. cells 는 _buildCells() 가
116
+ * runtime 재생성 (added() 호출 시점). 저장하면 *redundant 모델 크기 폭증* +
117
+ * load 시 _buildCells 와 중복. rack 의 bays/levels/shelfBaseHeight 만 저장,
118
+ * cells 는 derive.
119
+ */
120
+ get hierarchy(): Record<string, any> {
121
+ const base = super.hierarchy as Record<string, any>
122
+ if (base?.components && Array.isArray(base.components)) {
123
+ base.components = base.components.filter(
124
+ (c: any) => c?.type !== 'storage-cell'
125
+ )
126
+ if (base.components.length === 0) delete base.components
127
+ }
128
+ return base
129
+ }
130
+
131
+ /**
132
+ * Lifecycle — RackCell child 자동 build. Rack 은 항상 cells 가짐.
133
+ */
134
+ added(parent: any): void {
135
+ super.added?.(parent)
136
+ this._buildCells()
137
+ }
138
+
139
+ /**
140
+ * Runtime — bays / levels 변경 시 RackCell child 재구성. _buildCells() 는
141
+ * 기존 cell 제거 후 재생성 (idempotent), 단 carrier 보유 시 결함 위험 —
142
+ * application 책임.
143
+ */
144
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>): void {
145
+ super.onchange?.(after, before)
146
+ if (
147
+ 'bays' in after ||
148
+ 'levels' in after ||
149
+ 'shelfBaseHeight' in after ||
150
+ 'width' in after ||
151
+ 'height' in after ||
152
+ 'depth' in after
153
+ ) {
154
+ this._buildCells()
155
+ }
156
+ }
157
+
101
158
  // ── CellContainer ─────────────────────────────────────────────────────────
102
159
 
103
160
  /**
@@ -116,6 +173,11 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
116
173
  const width = (this.state.width as number) || 1000
117
174
  const rackDepth = (this.state.depth as number) || 3000 // Y: floor→ceiling
118
175
  const rackHeight = (this.state.height as number) || 600 // Z: front→back
176
+ const shelfBase = Math.max(0, Math.min(
177
+ (this.state.shelfBaseHeight as number) || 0,
178
+ rackDepth * 0.9 // clamp ≤ 90% — 최소 shelf zone
179
+ ))
180
+ const shelfZone = rackDepth - shelfBase // 실제 shelf 가 차지하는 Y 영역
119
181
 
120
182
  return CellMap.grid({
121
183
  bays,
@@ -123,7 +185,8 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
123
185
  levels,
124
186
  bayWidth: width / bays,
125
187
  rowDepth: rackHeight,
126
- levelHeight: rackDepth / levels
188
+ levelHeight: shelfZone / levels,
189
+ origin: { x: 0, y: shelfBase, z: 0 } // 첫 cell 의 Y = shelfBase
127
190
  })
128
191
  }
129
192
 
@@ -151,14 +214,35 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
151
214
  return
152
215
  }
153
216
 
217
+ // cell 의 state.left/top 는 *rack-local* (= parent-relative). things-scene
218
+ // 의 toScene() 이 parent chain 따라 board-absolute 변환 자동. 이전 board-
219
+ // absolute 설정은 *이중 변환* 결함 (rack.left 와 cell.left 둘 다 rack.transform
220
+ // 적용 받아 dx 폭증 → carriagePos clamp → carriage 안 움직임).
221
+ const rackWidth = (this.state.width as number) ?? 1000
222
+ const rackHeight = (this.state.height as number) ?? 100
223
+ const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
224
+ const bayWidth = rackWidth / bays
225
+
154
226
  const context = this._app
155
227
  for (const cell of this.cellMap.cells) {
228
+ // cell.bay / row 는 1-based. storage-rack 은 rows=1 라 모든 cell 이 같은 2D
229
+ // top. level (수직) 은 2D 표현 안 함 — Crane.engage 의 carriageHeight 가 처리.
230
+ const bayIdx = cell.bay - 1 // 0-based
231
+ const cellW = cell.size.width
232
+ const cellH = cell.size.depth // 2D height = 3D Z (rack depth axis)
233
+ // rack-local 좌표 (rack 의 origin = rack.left/top, things-scene 의 자식 좌표)
234
+ const cellLeft = bayIdx * bayWidth + (bayWidth - cellW) / 2
235
+ const cellTop = (rackHeight - cellH) / 2
236
+
156
237
  const model = {
157
238
  type: 'storage-cell',
158
239
  cellId: cell.id,
159
- width: cell.size.width,
160
- height: cell.size.depth, // 2D height = 3D Z depth
161
- depth: cell.size.height // 3D Y = level height
240
+ left: cellLeft,
241
+ top: cellTop,
242
+ width: cellW,
243
+ height: cellH,
244
+ depth: cell.size.height, // 3D Y = level height
245
+ zPos: cell.localPosition.y // ← 3D Y 위치 (level 따라 다름)
162
246
  }
163
247
  const rackCell = new RackCellClass(model, context)
164
248
  this.addComponent(rackCell)
@@ -203,22 +287,38 @@ export default class Rack extends CellContainer(CarrierHolder(Placeable(Containe
203
287
  // ── 2D rendering ─────────────────────────────────────────────────────────
204
288
 
205
289
  /**
206
- * 2D — top-down rectangle showing the rack footprint, with subdivisions
207
- * suggesting the bay layout.
290
+ * 2D — top-down rectangle showing the rack footprint with bay subdivisions.
291
+ * 편집/배치 가능하도록 *명시 fill + stroke* — pipeline 분기 무관하게 항상
292
+ * 보임. fill 은 반투명 (carrier / cell 위 overlay).
208
293
  */
209
294
  render(ctx: CanvasRenderingContext2D) {
210
- const { width, height, left, top } = this.state
295
+ const left = (this.state.left as number) ?? 0
296
+ const top = (this.state.top as number) ?? 0
297
+ const width = (this.state.width as number) ?? 400
298
+ const height = (this.state.height as number) ?? 100
211
299
  const bays = Math.max(1, Math.floor((this.state.bays as number) || 5))
300
+ const fill = (this.state.fillStyle as string) || '#a0a0a8'
301
+ const stroke = (this.state.strokeStyle as string) || '#555'
302
+ const lineWidth = (this.state.lineWidth as number) || 1
303
+
304
+ // Fill (반투명)
305
+ ctx.save()
306
+ ctx.fillStyle = fill
307
+ ctx.globalAlpha = 0.2
308
+ ctx.fillRect(left, top, width, height)
309
+ ctx.restore()
212
310
 
311
+ // Stroke — outer + bay subdivisions
312
+ ctx.strokeStyle = stroke
313
+ ctx.lineWidth = lineWidth
314
+ ctx.strokeRect(left, top, width, height)
213
315
  ctx.beginPath()
214
- // Outer rectangle
215
- ctx.rect(left, top, width, height)
216
- // Bay subdivisions (vertical lines)
217
316
  for (let i = 1; i < bays; i++) {
218
317
  const x = left + (width * i) / bays
219
318
  ctx.moveTo(x, top)
220
319
  ctx.lineTo(x, top + height)
221
320
  }
321
+ ctx.stroke()
222
322
  }
223
323
 
224
324
  get fillStyle() {
@@ -0,0 +1,105 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * 정밀 진단 — Rack 의 _buildCells 가 cell 의 board-absolute state.left/top
5
+ * 정확히 설정하는지 + Crane.moveTo 의 railLocalX 계산 정확한지.
6
+ */
7
+
8
+ import 'should'
9
+ import { readFileSync } from 'fs'
10
+ import { resolve, dirname } from 'path'
11
+ import { fileURLToPath } from 'url'
12
+
13
+ const __filename = fileURLToPath(import.meta.url)
14
+ const __dirname = dirname(__filename)
15
+
16
+ function readSrc(rel: string): string {
17
+ return readFileSync(resolve(__dirname, rel), 'utf-8')
18
+ }
19
+
20
+ // ── Test 1: storage-rack._buildCells 의 cell 좌표 계산 검증 ─────────────────
21
+
22
+ describe('storage-rack._buildCells: cell 의 rack-local state.left/top', () => {
23
+ it('coordinate formula — rack-local (toScene 이 parent transform 자동)', () => {
24
+ // rack-local: cell.state.left = bayIdx * bayWidth + (bayWidth - cellW)/2
25
+ // things-scene 의 toScene() 이 parent (rack) 의 transform 자동 적용 →
26
+ // board-absolute 변환. rackLeft 더하면 *이중 변환* 결함.
27
+ const src = readSrc('../src/storage-rack.ts')
28
+ src.should.match(/cellLeft = bayIdx \* bayWidth/)
29
+ src.should.not.match(/cellLeft = rackLeft/) // rackLeft 더하면 안 됨
30
+ src.should.match(/left: cellLeft/)
31
+ src.should.match(/top: cellTop/)
32
+ })
33
+
34
+ it('cell.size.width 는 cellMap.grid 의 bayWidth (= rack.width / bays)', () => {
35
+ // 즉 (bayWidth - cellW) / 2 = 0 → bay 별 cellLeft = rackLeft + bayIdx * bayWidth
36
+ const src = readSrc('../src/storage-rack.ts')
37
+ src.should.match(/bayWidth = rackWidth \/ bays/)
38
+ })
39
+
40
+ it('storage-rack 은 rows=1 hardcoded', () => {
41
+ const src = readSrc('../src/storage-rack.ts')
42
+ src.should.match(/rows: 1/)
43
+ })
44
+ })
45
+
46
+ // ── Test 2: Crane.moveTo 의 railLocalX 계산 ─────────────────────────────
47
+
48
+ describe('Crane.moveTo: target.center 의 carrier 위치 → carriagePosition', () => {
49
+ it('rotation=0 + crane.center.x = 200, target.center.x = 360 → railLocalX = 160', () => {
50
+ // crane.left=0, width=400 → crane.center.x = 200
51
+ // target (cell) 의 center.x = 360
52
+ // dx = 360 - 200 = 160, dy = 0
53
+ // rotation = 0 → cos=1, sin=0
54
+ // railLocalX = 160 * 1 + 0 * 0 = 160
55
+ // carriagePosition = clamp(160 + 400/2, [cw/2, width-cw/2]) = clamp(360, ...) → 360
56
+ const rotation = 0
57
+ const cos = Math.cos(rotation)
58
+ const sin = Math.sin(rotation)
59
+ const dx = 360 - 200
60
+ const dy = 0
61
+ const railLocalX = dx * cos + dy * sin
62
+ const W = 400
63
+ const cw = 40
64
+ const minPos = cw / 2
65
+ const maxPos = W - cw / 2
66
+ const carriagePos = Math.max(minPos, Math.min(maxPos, railLocalX + W / 2))
67
+ carriagePos.should.equal(360)
68
+ })
69
+
70
+ it('clamp 적용 — target 이 rail 범위 밖 (target.center.x = 1000, crane.center.x = 200)', () => {
71
+ const dx = 1000 - 200 // 800 — 매우 큼
72
+ const dy = 0
73
+ const railLocalX = dx * 1 + dy * 0 // 800
74
+ const W = 400
75
+ const cw = 40
76
+ const minPos = cw / 2 // 20
77
+ const maxPos = W - cw / 2 // 380
78
+ const carriagePos = Math.max(minPos, Math.min(maxPos, railLocalX + W / 2))
79
+ carriagePos.should.equal(380) // clamp to maxPos
80
+ })
81
+
82
+ it('clamp 적용 — target 이 rail 왼쪽 밖 (target.center.x = -500, crane.center.x = 200)', () => {
83
+ const dx = -500 - 200 // -700
84
+ const dy = 0
85
+ const railLocalX = dx * 1 + dy * 0 // -700
86
+ const W = 400
87
+ const cw = 40
88
+ const carriagePos = Math.max(cw / 2, Math.min(W - cw / 2, railLocalX + W / 2))
89
+ carriagePos.should.equal(20) // clamp to minPos
90
+ })
91
+
92
+ it('rotation=π/2 (90도) — Y projection 사용', () => {
93
+ // rack 이 사선 90도 — crane 의 rail 도 90도. target 의 Y 좌표만 사용.
94
+ const rotation = Math.PI / 2
95
+ const cos = Math.cos(rotation) // ≈ 0
96
+ const sin = Math.sin(rotation) // = 1
97
+ const dx = 0
98
+ const dy = 160
99
+ const railLocalX = dx * cos + dy * sin // ≈ 160
100
+ const W = 400
101
+ const cw = 40
102
+ const carriagePos = Math.max(cw / 2, Math.min(W - cw / 2, railLocalX + W / 2))
103
+ carriagePos.should.be.approximately(360, 0.001)
104
+ })
105
+ })