@operato/scene-storage 10.0.0-beta.48 → 10.0.0-beta.53
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 +36 -0
- package/dist/box.js +2 -2
- package/dist/box.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/pallet.js +2 -2
- package/dist/pallet.js.map +1 -1
- package/dist/parcel.js +2 -2
- package/dist/parcel.js.map +1 -1
- package/dist/picking-station-3d.d.ts +20 -0
- package/dist/picking-station-3d.js +162 -0
- package/dist/picking-station-3d.js.map +1 -0
- package/dist/picking-station.d.ts +50 -0
- package/dist/picking-station.js +186 -0
- package/dist/picking-station.js.map +1 -0
- package/dist/rack-capability.d.ts +11 -0
- package/dist/rack-capability.js +25 -0
- package/dist/rack-capability.js.map +1 -0
- package/dist/rack-grid.d.ts +4 -22
- package/dist/rack-grid.js +23 -115
- package/dist/rack-grid.js.map +1 -1
- package/dist/spot.d.ts +1 -0
- package/dist/spot.js +6 -2
- package/dist/spot.js.map +1 -1
- package/dist/stockpile-3d.d.ts +55 -0
- package/dist/stockpile-3d.js +387 -0
- package/dist/stockpile-3d.js.map +1 -0
- package/dist/stockpile-grid-3d.d.ts +30 -0
- package/dist/stockpile-grid-3d.js +301 -0
- package/dist/stockpile-grid-3d.js.map +1 -0
- package/dist/stockpile-grid.d.ts +85 -0
- package/dist/stockpile-grid.js +361 -0
- package/dist/stockpile-grid.js.map +1 -0
- package/dist/stockpile.d.ts +116 -0
- package/dist/stockpile.js +345 -0
- package/dist/stockpile.js.map +1 -0
- package/dist/storage-rack.d.ts +39 -44
- package/dist/storage-rack.js +71 -146
- package/dist/storage-rack.js.map +1 -1
- package/dist/templates/index.d.ts +80 -0
- package/dist/templates/index.js +7 -1
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/picking-station.d.ts +20 -0
- package/dist/templates/picking-station.js +22 -0
- package/dist/templates/picking-station.js.map +1 -0
- package/dist/templates/stockpile-grid.d.ts +37 -0
- package/dist/templates/stockpile-grid.js +38 -0
- package/dist/templates/stockpile-grid.js.map +1 -0
- package/dist/templates/stockpile.d.ts +29 -0
- package/dist/templates/stockpile.js +31 -0
- package/dist/templates/stockpile.js.map +1 -0
- package/package.json +3 -3
- package/src/box.ts +2 -1
- package/src/index.ts +14 -0
- package/src/pallet.ts +2 -1
- package/src/parcel.ts +2 -1
- package/src/picking-station-3d.ts +164 -0
- package/src/picking-station.ts +220 -0
- package/src/rack-capability.ts +26 -0
- package/src/rack-grid.ts +24 -108
- package/src/spot.ts +15 -1
- package/src/stockpile-3d.ts +412 -0
- package/src/stockpile-grid-3d.ts +327 -0
- package/src/stockpile-grid.ts +408 -0
- package/src/stockpile.ts +427 -0
- package/src/storage-rack.ts +82 -137
- package/src/templates/index.ts +7 -1
- package/src/templates/picking-station.ts +23 -0
- package/src/templates/stockpile-grid.ts +39 -0
- package/src/templates/stockpile.ts +32 -0
- package/test/test-rack-capability.ts +51 -0
- package/translations/en.json +23 -6
- package/translations/ja.json +23 -6
- package/translations/ko.json +22 -5
- package/translations/ms.json +23 -6
- package/translations/zh.json +22 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* PickingStation — 사람(또는 자동화) 작업 위치. carrier 가 도착하면 *_processingTimeMs_*
|
|
5
|
+
* 동안 머문 뒤 status='idle' 로 자동 전환되어 다음 mover 가 가져갈 수 있다.
|
|
6
|
+
*
|
|
7
|
+
* 단일 slot SlottedHolder (Spot 비슷) + 처리 시간/상태 + popupRef + click raycast.
|
|
8
|
+
* 3D 는 pad(영역) + 작업대(가운데 box) — picking/QC 작업 자리의 인지성을 위해.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as THREE from 'three'
|
|
12
|
+
import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
|
|
13
|
+
import type { State, Material3D } from '@hatiolab/things-scene'
|
|
14
|
+
import {
|
|
15
|
+
CarrierHolder,
|
|
16
|
+
Placeable,
|
|
17
|
+
SingleSlotHolder,
|
|
18
|
+
type AttachFrame,
|
|
19
|
+
type Alignment,
|
|
20
|
+
type Heights,
|
|
21
|
+
type PlacementArchetype,
|
|
22
|
+
type ProcessStep
|
|
23
|
+
} from '@operato/scene-base'
|
|
24
|
+
|
|
25
|
+
import { PickingStation3D } from './picking-station-3d.js'
|
|
26
|
+
|
|
27
|
+
export type PickingStationStatus = 'idle' | 'processing' | 'busy'
|
|
28
|
+
|
|
29
|
+
export interface PickingStationState extends State {
|
|
30
|
+
/** carrier 가 머무는 처리 시간 (ms). 0/미설정이면 즉시 idle 유지. */
|
|
31
|
+
processingTimeMs?: number
|
|
32
|
+
/** 현재 상태 (자동). */
|
|
33
|
+
status?: PickingStationStatus
|
|
34
|
+
/** click 시 invoke 할 Popup 컴포넌트 id. */
|
|
35
|
+
popupRef?: string
|
|
36
|
+
material3d?: Material3D
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SLOT_ID = 'station'
|
|
40
|
+
|
|
41
|
+
const NATURE: ComponentNature = {
|
|
42
|
+
mutable: false,
|
|
43
|
+
resizable: true,
|
|
44
|
+
rotatable: true,
|
|
45
|
+
properties: [
|
|
46
|
+
{ type: 'number', label: 'processing-time-ms', name: 'processingTimeMs' },
|
|
47
|
+
{ type: 'select', label: 'status', name: 'status',
|
|
48
|
+
property: { options: ['idle', 'processing', 'busy'] } },
|
|
49
|
+
{ type: 'id-input', label: 'popup-ref', name: 'popupRef',
|
|
50
|
+
property: { component: 'popup' } }
|
|
51
|
+
],
|
|
52
|
+
help: 'scene/component/picking-station'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@sceneComponent('picking-station')
|
|
56
|
+
export default class PickingStation
|
|
57
|
+
extends SingleSlotHolder()(CarrierHolder(Placeable(ContainerAbstract)))
|
|
58
|
+
implements ProcessStep
|
|
59
|
+
{
|
|
60
|
+
declare state: PickingStationState
|
|
61
|
+
declare _realObject?: PickingStation3D
|
|
62
|
+
|
|
63
|
+
static placement: PlacementArchetype = 'floor'
|
|
64
|
+
static align: Alignment = 'bottom'
|
|
65
|
+
static defaultDepth = (h: Heights) => h.operation - h.floor
|
|
66
|
+
|
|
67
|
+
get nature(): ComponentNature { return NATURE }
|
|
68
|
+
get anchors() { return [] }
|
|
69
|
+
|
|
70
|
+
// SingleSlotHolder hook overrides ───────────────────────────────────────────
|
|
71
|
+
_singleSlotId() { return SLOT_ID }
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds
|
|
75
|
+
* / emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt
|
|
76
|
+
* / getSlotAttachObject3d) — SingleSlotHolder mixin 제공.
|
|
77
|
+
*
|
|
78
|
+
* receiveAt 후 부가 dwell 동작은 `_onCarrierReceived` hook 으로 위임.
|
|
79
|
+
*/
|
|
80
|
+
_onCarrierReceived(_carrier: Component, _options?: any) {
|
|
81
|
+
const procMs = this.state.processingTimeMs ?? 0
|
|
82
|
+
if (procMs <= 0) return
|
|
83
|
+
this.setState({ status: 'processing' as PickingStationStatus })
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
if ((this.state.status as PickingStationStatus) === 'processing') {
|
|
86
|
+
this.setState({ status: 'idle' as PickingStationStatus })
|
|
87
|
+
}
|
|
88
|
+
}, procMs)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Phase ZT-V PR-6 ProcessStep ─────────────────────────────────────────────
|
|
92
|
+
readonly isProcessStep = true as const
|
|
93
|
+
get processingTimeMs() { return this.state.processingTimeMs ?? 0 }
|
|
94
|
+
|
|
95
|
+
/** carrier 를 작업대(table) 상단에 안착. */
|
|
96
|
+
attachPointFor(carrier: Component): AttachFrame | null {
|
|
97
|
+
const ro = this._realObject
|
|
98
|
+
const frame = ro?.getAttachFrame?.()
|
|
99
|
+
if (!frame) return null
|
|
100
|
+
const carrierDepth = resolveDepth(carrier)
|
|
101
|
+
return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 2D render ───────────────────────────────────────────────
|
|
105
|
+
render(ctx: CanvasRenderingContext2D) {
|
|
106
|
+
const { left = 0, top = 0, width = 100, height = 100 } = this.state
|
|
107
|
+
const fillStyle = (this.state.fillStyle as string) || '#5a8ab8'
|
|
108
|
+
const strokeStyle = (this.state.strokeStyle as string) || '#3d6a8f'
|
|
109
|
+
const status = (this.state.status ?? 'idle') as PickingStationStatus
|
|
110
|
+
|
|
111
|
+
// pad
|
|
112
|
+
ctx.save()
|
|
113
|
+
ctx.fillStyle = fillStyle
|
|
114
|
+
ctx.globalAlpha = 0.15
|
|
115
|
+
ctx.fillRect(left, top, width, height)
|
|
116
|
+
ctx.restore()
|
|
117
|
+
|
|
118
|
+
// outline
|
|
119
|
+
ctx.save()
|
|
120
|
+
ctx.strokeStyle = strokeStyle
|
|
121
|
+
ctx.lineWidth = 1.5
|
|
122
|
+
ctx.strokeRect(left + 0.75, top + 0.75, width - 1.5, height - 1.5)
|
|
123
|
+
ctx.restore()
|
|
124
|
+
|
|
125
|
+
// 작업대 (가운데 작은 사각)
|
|
126
|
+
const tw = width * 0.55, th = height * 0.45
|
|
127
|
+
const tx = left + (width - tw) / 2
|
|
128
|
+
const ty = top + (height - th) / 2
|
|
129
|
+
ctx.save()
|
|
130
|
+
ctx.fillStyle = strokeStyle
|
|
131
|
+
ctx.globalAlpha = 0.35
|
|
132
|
+
ctx.fillRect(tx, ty, tw, th)
|
|
133
|
+
ctx.restore()
|
|
134
|
+
|
|
135
|
+
// 상태
|
|
136
|
+
ctx.save()
|
|
137
|
+
const fontSize = Math.min(width, height) * 0.16
|
|
138
|
+
ctx.fillStyle = '#222'
|
|
139
|
+
ctx.font = `bold ${fontSize}px sans-serif`
|
|
140
|
+
ctx.textAlign = 'center'
|
|
141
|
+
ctx.textBaseline = 'middle'
|
|
142
|
+
ctx.fillText(status.toUpperCase(), left + width / 2, top + height / 2)
|
|
143
|
+
ctx.restore()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Popup + click ────────────────────────────────────────────
|
|
147
|
+
get eventMap() {
|
|
148
|
+
return { '(self)': { '(self)': { click: this._onStationClick } } }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _onStationClick = (mouseEvent: MouseEvent) => {
|
|
152
|
+
if (!(this as any).app?.isViewMode) return
|
|
153
|
+
const hit = this._raycastStationHit(mouseEvent)
|
|
154
|
+
if (!hit) return
|
|
155
|
+
this._invokePopup()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private _invokePopup(): void {
|
|
159
|
+
const popupRefId = this.state.popupRef
|
|
160
|
+
if (!popupRefId) return
|
|
161
|
+
const popupComp: any = (this as any).root?.findById?.(popupRefId)
|
|
162
|
+
if (!popupComp || typeof popupComp.openPopup !== 'function') return
|
|
163
|
+
const anchor = this.slotTargetAt(SLOT_ID)
|
|
164
|
+
const carrier = this.obtainCarrier(SLOT_ID)
|
|
165
|
+
popupComp.openPopup({
|
|
166
|
+
componentId: (this.state as any).id,
|
|
167
|
+
status: this.state.status ?? 'idle',
|
|
168
|
+
processingTimeMs: this.state.processingTimeMs,
|
|
169
|
+
currentCarrierId: (carrier as any)?.state?.id ?? null
|
|
170
|
+
}, { anchor })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private _raycastStationHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
|
|
174
|
+
const ro: any = (this as any)._realObject
|
|
175
|
+
if (!ro?.object3d) return undefined
|
|
176
|
+
const tc: any = ro.threeContainer
|
|
177
|
+
if (!tc) return undefined
|
|
178
|
+
const cap: any = tc._threeCapability ?? tc._capability
|
|
179
|
+
let intersects: THREE.Intersection[] | undefined
|
|
180
|
+
if (cap?.getObjectsByRaycast) intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
|
|
181
|
+
if (!intersects || intersects.length === 0) {
|
|
182
|
+
const scene = tc.scene3d as THREE.Scene | undefined
|
|
183
|
+
const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
|
|
184
|
+
const camera =
|
|
185
|
+
(tc.activeCamera3d as THREE.Camera | undefined) ??
|
|
186
|
+
(cap?.activeCamera as THREE.Camera | undefined) ??
|
|
187
|
+
(cap?.camera as THREE.Camera | undefined)
|
|
188
|
+
const canvas = renderer?.domElement
|
|
189
|
+
if (!scene || !canvas || !camera) return undefined
|
|
190
|
+
const rect = canvas.getBoundingClientRect()
|
|
191
|
+
if (rect.width === 0 || rect.height === 0) return undefined
|
|
192
|
+
const ndc = new THREE.Vector2(
|
|
193
|
+
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
|
194
|
+
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
195
|
+
)
|
|
196
|
+
const raycaster = new THREE.Raycaster()
|
|
197
|
+
raycaster.setFromCamera(ndc, camera)
|
|
198
|
+
intersects = raycaster.intersectObjects(scene.children, true)
|
|
199
|
+
}
|
|
200
|
+
if (!intersects || intersects.length === 0) return undefined
|
|
201
|
+
const closest = intersects[0]
|
|
202
|
+
let obj: THREE.Object3D | null = closest.object
|
|
203
|
+
while (obj) {
|
|
204
|
+
if (obj.userData?.context === ro) return closest
|
|
205
|
+
obj = obj.parent
|
|
206
|
+
}
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
buildRealObject(): RealObject | undefined {
|
|
211
|
+
return new PickingStation3D(this)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveDepth(c: Component): number {
|
|
216
|
+
const eff = (c as any)._realObject?.effectiveDepth
|
|
217
|
+
if (typeof eff === 'number' && Number.isFinite(eff)) return eff
|
|
218
|
+
const d = (c as any)?.state?.depth
|
|
219
|
+
return typeof d === 'number' && Number.isFinite(d) ? d : 0
|
|
220
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* Rack 적재 mover 능력 판정 — *_순수 로직_* (things-scene 무관). storage-rack 의
|
|
5
|
+
* canAcceptFromMover 가 위임. mocha 환경에서 StorageRack 직접 import 불가하므로
|
|
6
|
+
* 판정 로직만 분리해 단위 검증.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* rack 이 *_이 mover 의 toolType_* 을 선반 적재용으로 수용하는가.
|
|
11
|
+
*
|
|
12
|
+
* rack 선반 적재는 높이 도달 mover (crane / stacker / forklift) 의 몫. 평탄 데크
|
|
13
|
+
* 차량(agv-deck)은 바닥 운반 전용 — 선반 직접 적재 불가 → 거부. 거부된 mover 는
|
|
14
|
+
* transfer planner 가 자동으로 in-port 경유(환승)를 택하게 만든다.
|
|
15
|
+
*
|
|
16
|
+
* @param moverToolType 적재하려는 mover 의 toolType (undefined 면 능력 미상 → 허용)
|
|
17
|
+
* @param blockedTools 거부 toolType 목록 (default ['agv-deck'])
|
|
18
|
+
*/
|
|
19
|
+
export function rackAcceptsMoverTool(
|
|
20
|
+
moverToolType: string | undefined | null,
|
|
21
|
+
blockedTools: readonly string[] = ['agv-deck']
|
|
22
|
+
): boolean {
|
|
23
|
+
if (moverToolType == null) return true
|
|
24
|
+
if (!Array.isArray(blockedTools)) return true
|
|
25
|
+
return !blockedTools.includes(moverToolType)
|
|
26
|
+
}
|
package/src/rack-grid.ts
CHANGED
|
@@ -38,7 +38,9 @@ import * as THREE from 'three'
|
|
|
38
38
|
import {
|
|
39
39
|
CarrierHolder,
|
|
40
40
|
Placeable,
|
|
41
|
+
RecordStorage,
|
|
41
42
|
SlotTarget,
|
|
43
|
+
componentBoundingBox,
|
|
42
44
|
type AttachFrame,
|
|
43
45
|
type Alignment,
|
|
44
46
|
type Heights,
|
|
@@ -267,25 +269,33 @@ export interface RackGridState extends State {
|
|
|
267
269
|
|
|
268
270
|
@sceneComponent('rack-grid')
|
|
269
271
|
export default class RackGrid
|
|
270
|
-
extends
|
|
272
|
+
extends RecordStorage<{ cellId: string; [key: string]: any }>()(
|
|
273
|
+
CarrierHolder(Placeable(ContainerAbstract))
|
|
274
|
+
)
|
|
271
275
|
implements SlottedHolder
|
|
272
276
|
{
|
|
273
277
|
declare state: RackGridState
|
|
274
278
|
|
|
279
|
+
// RecordStorage mixin hook overrides.
|
|
280
|
+
// record.cellId 가 slotId (3-segment '{col}-{row}-{shelf}' format).
|
|
281
|
+
_recordToSlotId(record: { cellId: string }): string {
|
|
282
|
+
return record.cellId ?? ''
|
|
283
|
+
}
|
|
284
|
+
// 3D rebuild — rebuildStockMesh 우선, 없으면 mixin default.
|
|
285
|
+
_rebuildVisual(): void {
|
|
286
|
+
const ro = (this as any)._realObject
|
|
287
|
+
if (ro?.rebuildStockMesh) ro.rebuildStockMesh()
|
|
288
|
+
else if (ro?.update) ro.update()
|
|
289
|
+
}
|
|
290
|
+
|
|
275
291
|
// Phase Auto-Nav (AN-PR-2) — Obstacle 자격. AMR / mover 의 자동 path planning
|
|
276
292
|
// 시 회피 대상. `state.isObstacle` 명시 false 시 override (예외 처리).
|
|
277
293
|
get isObstacle(): boolean {
|
|
278
294
|
return (this.state as any)?.isObstacle !== false
|
|
279
295
|
}
|
|
280
296
|
obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return {
|
|
284
|
-
left: s.left, top: s.top,
|
|
285
|
-
width: s.width, height: s.height,
|
|
286
|
-
y: typeof s.zPos === 'number' ? s.zPos : 0,
|
|
287
|
-
zHeight: typeof s.depth === 'number' ? s.depth : 0
|
|
288
|
-
}
|
|
297
|
+
// scene-base 의 componentBoundingBox 위임 — rotation 적용된 AABB.
|
|
298
|
+
return componentBoundingBox(this)
|
|
289
299
|
}
|
|
290
300
|
|
|
291
301
|
static placement: PlacementArchetype = 'floor'
|
|
@@ -422,63 +432,8 @@ export default class RackGrid
|
|
|
422
432
|
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
423
433
|
}
|
|
424
434
|
|
|
425
|
-
|
|
426
|
-
get records(): Array<{ cellId: string; [key: string]: any }> {
|
|
427
|
-
return (this.state.data as any) ?? []
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ── Legend integration ──────────────────────────────────
|
|
431
|
-
|
|
432
|
-
private _legendTarget?: Component
|
|
435
|
+
// records / legendTarget / _onLegendChanged / resolveLegendColor — RecordStorage mixin 제공.
|
|
433
436
|
|
|
434
|
-
/**
|
|
435
|
-
* Legend 컴포넌트 lookup. 우선순위:
|
|
436
|
-
* 1) state.legendTarget id 명시
|
|
437
|
-
* 2) scene 전체 의 type='legend' 첫 번째 컴포넌트 (자동 발견)
|
|
438
|
-
*/
|
|
439
|
-
get legendTarget(): Component | undefined {
|
|
440
|
-
if (this._legendTarget) return this._legendTarget
|
|
441
|
-
|
|
442
|
-
const id = this.state.legendTarget
|
|
443
|
-
if (id) {
|
|
444
|
-
const found = (this.root as any)?.findById?.(id) as Component | undefined
|
|
445
|
-
if (found) {
|
|
446
|
-
this._legendTarget = found
|
|
447
|
-
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
448
|
-
return found
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const visit = (node: any): Component | undefined => {
|
|
453
|
-
if (!node) return undefined
|
|
454
|
-
if (node.state?.type === 'legend') return node as Component
|
|
455
|
-
const children = node.components as Component[] | undefined
|
|
456
|
-
if (children) {
|
|
457
|
-
for (const c of children) {
|
|
458
|
-
const r = visit(c)
|
|
459
|
-
if (r) return r
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return undefined
|
|
463
|
-
}
|
|
464
|
-
const found = visit(this.root)
|
|
465
|
-
if (found) {
|
|
466
|
-
this._legendTarget = found
|
|
467
|
-
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
468
|
-
}
|
|
469
|
-
return found
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
private _onLegendChanged = (): void => {
|
|
473
|
-
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
|
|
478
|
-
* - `range.value === recordValue` (카테고리 일치)
|
|
479
|
-
* - `range.min ≤ Number(v) < range.max` (수치 범위)
|
|
480
|
-
* - 매칭 없으면 `defaultColor` 또는 undefined
|
|
481
|
-
*/
|
|
482
437
|
// ── View-mode click → rack-grid-cell-click event + popup ──
|
|
483
438
|
|
|
484
439
|
get eventMap() {
|
|
@@ -521,24 +476,13 @@ export default class RackGrid
|
|
|
521
476
|
|
|
522
477
|
const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
|
|
523
478
|
this.trigger('rack-grid-cell-click', payload)
|
|
524
|
-
this._invokePopup(cellId, record)
|
|
479
|
+
if (cellId) this._invokePopup(cellId, record ?? { cellId })
|
|
525
480
|
|
|
526
481
|
// popup 외부 click 으로 인식되어 자동 close 되는 회귀 차단
|
|
527
482
|
mouseEvent.stopPropagation?.()
|
|
528
483
|
}
|
|
529
484
|
|
|
530
|
-
|
|
531
|
-
private _invokePopup(cellId: string | undefined, record: any): void {
|
|
532
|
-
const popupRefId = this.state.popupRef
|
|
533
|
-
if (!popupRefId || !cellId) return
|
|
534
|
-
const popupComp: any = (this.root as any)?.findById?.(popupRefId)
|
|
535
|
-
if (!popupComp || typeof popupComp.openPopup !== 'function') {
|
|
536
|
-
console.warn(`[rack-grid] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
|
|
537
|
-
return
|
|
538
|
-
}
|
|
539
|
-
const anchor = this.slotTargetAt(cellId)
|
|
540
|
-
popupComp.openPopup(record ?? { cellId }, { anchor })
|
|
541
|
-
}
|
|
485
|
+
// _invokePopup — RecordStorage mixin 제공 (signature 동일: slotId=cellId, payload=record).
|
|
542
486
|
|
|
543
487
|
/** raycast → 우리 RackGrid 의 어떤 mesh 가 closest hit 인지. */
|
|
544
488
|
private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
|
|
@@ -617,35 +561,7 @@ export default class RackGrid
|
|
|
617
561
|
return `${col}-${row}-${shelf}`
|
|
618
562
|
}
|
|
619
563
|
|
|
620
|
-
resolveLegendColor
|
|
621
|
-
const legend = this.legendTarget
|
|
622
|
-
if (!legend) return undefined
|
|
623
|
-
const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
|
|
624
|
-
if (!status) return undefined
|
|
625
|
-
|
|
626
|
-
const field = status.field as string | undefined
|
|
627
|
-
const ranges = status.ranges as any[] | undefined
|
|
628
|
-
if (!field || !Array.isArray(ranges)) return undefined
|
|
629
|
-
|
|
630
|
-
const value = record?.[field]
|
|
631
|
-
if (value === undefined || value === null) return status.defaultColor
|
|
632
|
-
|
|
633
|
-
for (const range of ranges) {
|
|
634
|
-
if (!range) continue
|
|
635
|
-
if (range.value !== undefined) {
|
|
636
|
-
if (range.value === value) return range.color
|
|
637
|
-
continue
|
|
638
|
-
}
|
|
639
|
-
const num = Number(value)
|
|
640
|
-
if (!Number.isFinite(num)) continue
|
|
641
|
-
const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
|
|
642
|
-
const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
|
|
643
|
-
const minOk = min === undefined || num >= min
|
|
644
|
-
const maxOk = max === undefined || num < max
|
|
645
|
-
if (minOk && maxOk) return range.color
|
|
646
|
-
}
|
|
647
|
-
return status.defaultColor as string | undefined
|
|
648
|
-
}
|
|
564
|
+
// resolveLegendColor — RecordStorage mixin 제공.
|
|
649
565
|
|
|
650
566
|
/**
|
|
651
567
|
* 새 (rows × columns) 에 맞춰 children 재구성. rack-table.buildCells 정확 클론.
|
|
@@ -1256,7 +1172,7 @@ export default class RackGrid
|
|
|
1256
1172
|
const cellId = this._resolveToCellId(slotIdOrLocation)
|
|
1257
1173
|
if (!cellId) return false
|
|
1258
1174
|
if (this._carrierChildAt(cellId)) return true
|
|
1259
|
-
return this.records.some(r => r.cellId === cellId)
|
|
1175
|
+
return this.records.some((r: any) => r.cellId === cellId)
|
|
1260
1176
|
}
|
|
1261
1177
|
|
|
1262
1178
|
/**
|
package/src/spot.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { State, Material3D } from '@hatiolab/things-scene'
|
|
|
34
34
|
import {
|
|
35
35
|
CarrierHolder,
|
|
36
36
|
Placeable,
|
|
37
|
+
SingleSlotHolder,
|
|
37
38
|
type AttachFrame,
|
|
38
39
|
type Alignment,
|
|
39
40
|
type Heights,
|
|
@@ -68,7 +69,11 @@ const NATURE: ComponentNature = {
|
|
|
68
69
|
// which forces `isHTMLElement(): true` and trips the 3D pipeline's
|
|
69
70
|
// addObject DOM-skip gate. Spot is purely 3D.
|
|
70
71
|
@sceneComponent('spot')
|
|
71
|
-
export default class Spot extends
|
|
72
|
+
export default class Spot extends SingleSlotHolder()(
|
|
73
|
+
CarrierHolder(Placeable(ContainerAbstract))
|
|
74
|
+
) {
|
|
75
|
+
// SingleSlotHolder hook override — Spot 의 slot id 'pad'.
|
|
76
|
+
_singleSlotId() { return SPOT_SLOT_ID }
|
|
72
77
|
declare state: SpotState
|
|
73
78
|
declare _realObject?: Spot3D
|
|
74
79
|
|
|
@@ -167,8 +172,17 @@ export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
|
|
|
167
172
|
localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
|
|
168
173
|
}
|
|
169
174
|
}
|
|
175
|
+
|
|
176
|
+
// SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds /
|
|
177
|
+
// emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt /
|
|
178
|
+
// getSlotAttachObject3d) — SingleSlotHolder mixin 제공.
|
|
179
|
+
// virtual location handoff buffer 동작: receiveAt 가 reparent (components +
|
|
180
|
+
// 3D) 로 carrier 를 자식으로 유지 → 다음 mover 가 obtainCarrier 로 승계.
|
|
170
181
|
}
|
|
171
182
|
|
|
183
|
+
/** Spot 의 유일 virtual slot id. */
|
|
184
|
+
const SPOT_SLOT_ID = 'pad'
|
|
185
|
+
|
|
172
186
|
function resolveDepth(c: Component): number {
|
|
173
187
|
const eff = (c as any)._realObject?.effectiveDepth
|
|
174
188
|
if (typeof eff === 'number' && Number.isFinite(eff)) return eff
|