@operato/scene-storage 10.0.0-beta.38 → 10.0.0-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/dist/box.js +18 -0
- package/dist/box.js.map +1 -1
- package/dist/crane-3d.d.ts +47 -2
- package/dist/crane-3d.js +246 -89
- package/dist/crane-3d.js.map +1 -1
- package/dist/crane.d.ts +96 -12
- package/dist/crane.js +395 -100
- package/dist/crane.js.map +1 -1
- package/dist/pallet.d.ts +15 -0
- package/dist/pallet.js +38 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel-3d.js +22 -18
- package/dist/parcel-3d.js.map +1 -1
- package/dist/parcel.d.ts +4 -3
- package/dist/parcel.js +24 -5
- package/dist/parcel.js.map +1 -1
- package/dist/storage-cell.d.ts +5 -2
- package/dist/storage-cell.js +21 -3
- package/dist/storage-cell.js.map +1 -1
- package/dist/storage-rack-3d.js +42 -7
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +26 -2
- package/dist/storage-rack.js +92 -10
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/box.ts +18 -0
- package/src/crane-3d.ts +258 -93
- package/src/crane.ts +445 -110
- package/src/pallet.ts +50 -1
- package/src/parcel-3d.ts +23 -18
- package/src/parcel.ts +24 -5
- package/src/storage-cell.ts +23 -3
- package/src/storage-rack-3d.ts +47 -8
- package/src/storage-rack.ts +110 -10
- package/test/test-cell-position.ts +105 -0
- package/test/test-crane-geometry.ts +167 -0
- package/test/test-phase-h-carrier-pickable.ts +4 -3
- package/translations/en.json +5 -1
- package/translations/ja.json +5 -1
- package/translations/ko.json +5 -1
- package/translations/ms.json +5 -1
- package/translations/zh.json +5 -1
- package/tsconfig.tsbuildinfo +1 -1
package/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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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,
|
|
113
|
+
approachDistance: 80,
|
|
113
114
|
toolType: 'gripper',
|
|
114
|
-
tolerance: { positionMm: 10, angleDeg: 2 },
|
|
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
|
}
|
package/src/storage-cell.ts
CHANGED
|
@@ -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
|
-
*
|
|
213
|
-
*
|
|
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
|
|
package/src/storage-rack-3d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
package/src/storage-rack.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
207
|
-
*
|
|
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
|
|
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
|
+
})
|