@operato/scene-storage 10.0.0-beta.48 → 10.0.0-beta.50
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 +16 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +6 -0
- package/dist/index.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 +56 -0
- package/dist/picking-station.js +212 -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.js +3 -10
- package/dist/rack-grid.js.map +1 -1
- package/dist/spot.d.ts +19 -1
- package/dist/spot.js +63 -1
- 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 +88 -0
- package/dist/stockpile-grid.js +429 -0
- package/dist/stockpile-grid.js.map +1 -0
- package/dist/stockpile.d.ts +133 -0
- package/dist/stockpile.js +439 -0
- package/dist/stockpile.js.map +1 -0
- package/dist/storage-rack.d.ts +12 -0
- package/dist/storage-rack.js +20 -10
- 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/index.ts +14 -0
- package/src/picking-station-3d.ts +164 -0
- package/src/picking-station.ts +243 -0
- package/src/rack-capability.ts +26 -0
- package/src/rack-grid.ts +3 -8
- package/src/spot.ts +62 -0
- package/src/stockpile-3d.ts +412 -0
- package/src/stockpile-grid-3d.ts +327 -0
- package/src/stockpile-grid.ts +456 -0
- package/src/stockpile.ts +508 -0
- package/src/storage-rack.ts +21 -8
- 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 +18 -6
- package/translations/ja.json +18 -6
- package/translations/ko.json +17 -5
- package/translations/ms.json +18 -6
- package/translations/zh.json +17 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* StockpileGrid — cols × rows cell 들로 분할된 평치 영역. cell 들은 grid 공통 설정
|
|
5
|
+
* (carrierPreset/stackPattern/크기/capacity 등)을 *_모두 공유_* 하고, 각 cell 은 자기
|
|
6
|
+
* 위치(col, row)에서의 records 만 별도 가진다. cell 별 override 는 의미가 없어
|
|
7
|
+
* 제거됨 — cell 별 다른 설정이 진짜 필요한 경우엔 Stockpile 컴포넌트를 따로 두는
|
|
8
|
+
* 것이 자연스럽다.
|
|
9
|
+
*
|
|
10
|
+
* state.data = grid 의 cell 들 배열 (각 element 는 cell-단위 stockpile-like 데이터).
|
|
11
|
+
* data: [
|
|
12
|
+
* { col: 0, row: 0, data: [{ id, ... }, ...] },
|
|
13
|
+
* { col: 1, row: 0, data: [...] },
|
|
14
|
+
* ...
|
|
15
|
+
* ]
|
|
16
|
+
* cell.data 가 그 cell 의 records. 외부 데이터 매핑은 이 배열을 target.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as THREE from 'three'
|
|
20
|
+
import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
|
|
21
|
+
import type { State, Material3D } from '@hatiolab/things-scene'
|
|
22
|
+
import {
|
|
23
|
+
CarrierHolder,
|
|
24
|
+
Placeable,
|
|
25
|
+
SlotTarget,
|
|
26
|
+
type AttachFrame,
|
|
27
|
+
type Alignment,
|
|
28
|
+
type Heights,
|
|
29
|
+
type PlacementArchetype
|
|
30
|
+
} from '@operato/scene-base'
|
|
31
|
+
|
|
32
|
+
import { StockpileGrid3D } from './stockpile-grid-3d.js'
|
|
33
|
+
import type { StackPattern, CarrierPreset, StockpileRecord, PickPolicy } from './stockpile.js'
|
|
34
|
+
|
|
35
|
+
/** grid 의 한 cell — 위치 + 그 cell 의 records. */
|
|
36
|
+
export interface StockpileGridCell {
|
|
37
|
+
col: number
|
|
38
|
+
row: number
|
|
39
|
+
data?: StockpileRecord[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StockpileGridState extends State {
|
|
43
|
+
cols?: number
|
|
44
|
+
rows?: number
|
|
45
|
+
/** 한 cell 의 평면 크기 (전체 width/height = cellWidth*cols, cellHeight*rows). */
|
|
46
|
+
cellWidth?: number
|
|
47
|
+
cellHeight?: number
|
|
48
|
+
|
|
49
|
+
/** grid 의 cell 들 — 각 cell 은 { col, row, data: Record[] }. */
|
|
50
|
+
data?: StockpileGridCell[]
|
|
51
|
+
|
|
52
|
+
// ── 공통 carrier 설정 (모든 cell 공유) ──
|
|
53
|
+
stackPattern?: StackPattern
|
|
54
|
+
carrierPreset?: CarrierPreset
|
|
55
|
+
carrierWidth?: number
|
|
56
|
+
carrierHeight?: number
|
|
57
|
+
carrierDepth?: number
|
|
58
|
+
carrierGap?: number
|
|
59
|
+
capacity?: number // cell 별 capacity (모든 cell 공통)
|
|
60
|
+
stackHeightLimit?: number
|
|
61
|
+
pickPolicy?: PickPolicy
|
|
62
|
+
|
|
63
|
+
popupRef?: string
|
|
64
|
+
legendTarget?: string
|
|
65
|
+
material3d?: Material3D
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const NATURE: ComponentNature = {
|
|
69
|
+
mutable: false,
|
|
70
|
+
resizable: true,
|
|
71
|
+
rotatable: true,
|
|
72
|
+
properties: [
|
|
73
|
+
{ type: 'number', label: 'cols', name: 'cols' },
|
|
74
|
+
{ type: 'number', label: 'rows', name: 'rows' },
|
|
75
|
+
{ type: 'number', label: 'cell-width', name: 'cellWidth' },
|
|
76
|
+
{ type: 'number', label: 'cell-height', name: 'cellHeight' },
|
|
77
|
+
{ type: 'select', label: 'stack-pattern', name: 'stackPattern',
|
|
78
|
+
property: { options: ['row', 'staggered', 'pyramid', 'column', 'pile'] } },
|
|
79
|
+
{ type: 'select', label: 'carrier-preset', name: 'carrierPreset',
|
|
80
|
+
property: { options: ['box', 'pallet', 'drum', 'sack', 'crate', 'bale'] } },
|
|
81
|
+
{ type: 'number', label: 'carrier-width', name: 'carrierWidth' },
|
|
82
|
+
{ type: 'number', label: 'carrier-height', name: 'carrierHeight' },
|
|
83
|
+
{ type: 'number', label: 'carrier-depth', name: 'carrierDepth' },
|
|
84
|
+
{ type: 'number', label: 'carrier-gap', name: 'carrierGap' },
|
|
85
|
+
{ type: 'number', label: 'capacity', name: 'capacity' },
|
|
86
|
+
{ type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
|
|
87
|
+
{ type: 'select', label: 'pick-policy', name: 'pickPolicy',
|
|
88
|
+
property: { options: ['lifo', 'fifo'] } },
|
|
89
|
+
{ type: 'id-input', label: 'popup-ref', name: 'popupRef',
|
|
90
|
+
property: { component: 'popup' } },
|
|
91
|
+
{ type: 'id-input', label: 'legend-target', name: 'legendTarget',
|
|
92
|
+
property: { component: 'legend' },
|
|
93
|
+
placeholder: '미명시 시 scene 의 legend 자동 발견' }
|
|
94
|
+
],
|
|
95
|
+
help: 'scene/component/stockpile-grid'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@sceneComponent('stockpile-grid')
|
|
99
|
+
export default class StockpileGrid extends CarrierHolder(Placeable(ContainerAbstract)) {
|
|
100
|
+
declare state: StockpileGridState
|
|
101
|
+
declare _realObject?: StockpileGrid3D
|
|
102
|
+
|
|
103
|
+
static placement: PlacementArchetype = 'floor'
|
|
104
|
+
static align: Alignment = 'bottom'
|
|
105
|
+
static defaultDepth = (_h: Heights) => 5
|
|
106
|
+
|
|
107
|
+
get nature(): ComponentNature { return NATURE }
|
|
108
|
+
get anchors() { return [] }
|
|
109
|
+
|
|
110
|
+
// ── grid 좌표 helpers ─────────────────────────────────────
|
|
111
|
+
get cols(): number { return Math.max(1, Math.floor(this.state.cols ?? 3)) }
|
|
112
|
+
get rows(): number { return Math.max(1, Math.floor(this.state.rows ?? 3)) }
|
|
113
|
+
get cellW(): number { return this.state.cellWidth ?? 80 }
|
|
114
|
+
get cellH(): number { return this.state.cellHeight ?? 80 }
|
|
115
|
+
|
|
116
|
+
cellIdOf(col: number, row: number): string { return `${col}-${row}` }
|
|
117
|
+
parseCellId(slotId: string): { col: number; row: number } | null {
|
|
118
|
+
const m = slotId.match(/^(\d+)-(\d+)$/)
|
|
119
|
+
if (!m) return null
|
|
120
|
+
return { col: Number(m[1]), row: Number(m[2]) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** state.data 에서 (col,row) cell 찾기. */
|
|
124
|
+
private _findCell(col: number, row: number): StockpileGridCell | undefined {
|
|
125
|
+
return (this.state.data ?? []).find(c => c.col === col && c.row === row)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** cell 의 records (없으면 빈 배열). */
|
|
129
|
+
recordsOf(col: number, row: number): ReadonlyArray<StockpileRecord> {
|
|
130
|
+
return this._findCell(col, row)?.data ?? []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** capacity 는 모든 cell 공통. */
|
|
134
|
+
capacityOf(_col: number, _row: number): number | undefined {
|
|
135
|
+
return this.state.capacity
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── SlottedHolder — cell 별 slot ─────────────────────────────
|
|
139
|
+
slotIds(): ReadonlyArray<string> {
|
|
140
|
+
const out: string[] = []
|
|
141
|
+
for (let r = 0; r < this.rows; r++) {
|
|
142
|
+
for (let c = 0; c < this.cols; c++) out.push(this.cellIdOf(c, r))
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
hasCarrierAt(slotId: string): boolean {
|
|
147
|
+
const p = this.parseCellId(slotId)
|
|
148
|
+
return !!p && this.recordsOf(p.col, p.row).length > 0
|
|
149
|
+
}
|
|
150
|
+
canReceiveAt(slotId: string, _carrier?: Component): boolean {
|
|
151
|
+
const p = this.parseCellId(slotId)
|
|
152
|
+
if (!p) return false
|
|
153
|
+
const cap = this.capacityOf(p.col, p.row)
|
|
154
|
+
if (typeof cap === 'number' && this.recordsOf(p.col, p.row).length >= cap) return false
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
occupiedSlotIds(): ReadonlyArray<string> {
|
|
158
|
+
const out: string[] = []
|
|
159
|
+
for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
|
|
160
|
+
if (this.recordsOf(c, r).length > 0) out.push(this.cellIdOf(c, r))
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
emptySlotIds(): ReadonlyArray<string> {
|
|
165
|
+
const out: string[] = []
|
|
166
|
+
for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
|
|
167
|
+
if (this.canReceiveAt(this.cellIdOf(c, r))) out.push(this.cellIdOf(c, r))
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
obtainCarrier(slotId: string): Component | null {
|
|
173
|
+
const p = this.parseCellId(slotId)
|
|
174
|
+
if (!p) return null
|
|
175
|
+
const recs = this.recordsOf(p.col, p.row)
|
|
176
|
+
if (recs.length === 0) return null
|
|
177
|
+
const records = [...recs]
|
|
178
|
+
const policy = (this.state.pickPolicy ?? 'lifo') as PickPolicy
|
|
179
|
+
const record = policy === 'fifo' ? records.shift()! : records.pop()!
|
|
180
|
+
this._setCellRecords(p.col, p.row, records)
|
|
181
|
+
return this._materializeCarrier(record, p.col, p.row)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async receiveAt(slotId: string, carrier: Component, _options?: any): Promise<void> {
|
|
185
|
+
const p = this.parseCellId(slotId)
|
|
186
|
+
if (!p) return
|
|
187
|
+
const cstate: any = (carrier as any)?.state ?? {}
|
|
188
|
+
const cid = cstate.id ?? `stkg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
189
|
+
const record: StockpileRecord = {
|
|
190
|
+
id: String(cid),
|
|
191
|
+
...(cstate.type ? { type: cstate.type } : {}),
|
|
192
|
+
...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
|
|
193
|
+
...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
|
|
194
|
+
...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
|
|
195
|
+
}
|
|
196
|
+
const records = [...this.recordsOf(p.col, p.row), record]
|
|
197
|
+
this._setCellRecords(p.col, p.row, records)
|
|
198
|
+
;(carrier as any)?.dispose?.()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async accept(carrier: Component, options?: any): Promise<void> {
|
|
202
|
+
const empty = this.emptySlotIds()
|
|
203
|
+
if (empty.length === 0) return
|
|
204
|
+
return this.receiveAt(empty[0], carrier, options)
|
|
205
|
+
}
|
|
206
|
+
async receive(carrier: Component, options?: any): Promise<void> {
|
|
207
|
+
return this.accept(carrier, options)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
slotTargetAt(slotId: string): SlotTarget { return new SlotTarget(this as any, slotId) }
|
|
211
|
+
getSlotAttachObject3d(slotId: string): any {
|
|
212
|
+
return (this as any)._realObject?.getCellPadMesh?.(slotId)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
attachPointFor(carrier: Component): AttachFrame | null {
|
|
216
|
+
const ro = this._realObject
|
|
217
|
+
const frame = ro?.getAttachFrame?.()
|
|
218
|
+
if (!frame) return null
|
|
219
|
+
const carrierDepth = resolveDepth(carrier)
|
|
220
|
+
return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private _setCellRecords(col: number, row: number, records: StockpileRecord[]): void {
|
|
224
|
+
const cells = [...((this.state.data as StockpileGridCell[]) ?? [])]
|
|
225
|
+
const idx = cells.findIndex(c => c.col === col && c.row === row)
|
|
226
|
+
if (idx === -1) cells.push({ col, row, data: records })
|
|
227
|
+
else cells[idx] = { ...cells[idx], data: records }
|
|
228
|
+
;(this.state as any).data = cells
|
|
229
|
+
this._realObject?.update?.()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private _materializeCarrier(record: StockpileRecord, col: number, row: number): Component | null {
|
|
233
|
+
const preset = (this.state.carrierPreset ?? 'box') as CarrierPreset
|
|
234
|
+
const PRESET_TO_TYPE: Record<CarrierPreset, string> = {
|
|
235
|
+
box: 'box', pallet: 'pallet', crate: 'box',
|
|
236
|
+
drum: 'parcel', sack: 'parcel', bale: 'parcel'
|
|
237
|
+
}
|
|
238
|
+
const carrierType = (record as any).type ?? PRESET_TO_TYPE[preset] ?? 'parcel'
|
|
239
|
+
const CarrierClass = (Component as any).register(carrierType) as
|
|
240
|
+
| (new (...args: any[]) => Component) | undefined
|
|
241
|
+
if (!CarrierClass) {
|
|
242
|
+
console.warn(`[stockpile-grid] carrier type "${carrierType}" 미등록`)
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const cw = this.state.carrierWidth ?? 30
|
|
247
|
+
const ch = this.state.carrierHeight ?? 30
|
|
248
|
+
const cd = this.state.carrierDepth ?? 22
|
|
249
|
+
|
|
250
|
+
const { id: _id, refid: _refid, transform: _tf, ...recordCopy } = record as any
|
|
251
|
+
const cellCenterX = col * this.cellW + this.cellW / 2
|
|
252
|
+
const cellCenterZ = row * this.cellH + this.cellH / 2
|
|
253
|
+
|
|
254
|
+
const carrierState: any = {
|
|
255
|
+
...recordCopy,
|
|
256
|
+
type: carrierType,
|
|
257
|
+
width: cw,
|
|
258
|
+
height: ch,
|
|
259
|
+
depth: cd,
|
|
260
|
+
refid: _nextStockpileGridCarrierRefid(),
|
|
261
|
+
left: cellCenterX - cw / 2,
|
|
262
|
+
top: cellCenterZ - ch / 2
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const carrier = new CarrierClass(carrierState, (this as any)._app)
|
|
266
|
+
;(this as any).addComponent(carrier, { silent: true })
|
|
267
|
+
void (carrier as any).realObject
|
|
268
|
+
;(carrier as any).applyHolderAttachPoint?.()
|
|
269
|
+
return carrier
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Legend ─────────────────────────────────────────────────
|
|
273
|
+
private _legendTarget?: Component
|
|
274
|
+
get legendTarget(): Component | undefined {
|
|
275
|
+
if (this._legendTarget) return this._legendTarget
|
|
276
|
+
const id = this.state.legendTarget
|
|
277
|
+
if (id) {
|
|
278
|
+
const found = ((this as any).root)?.findById?.(id) as Component | undefined
|
|
279
|
+
if (found) {
|
|
280
|
+
this._legendTarget = found
|
|
281
|
+
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
282
|
+
return found
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const visit = (node: any): Component | undefined => {
|
|
286
|
+
if (!node) return undefined
|
|
287
|
+
if (node.state?.type === 'legend') return node as Component
|
|
288
|
+
const children = node.components as Component[] | undefined
|
|
289
|
+
if (children) for (const c of children) { const r = visit(c); if (r) return r }
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
const found = visit((this as any).root)
|
|
293
|
+
if (found) {
|
|
294
|
+
this._legendTarget = found
|
|
295
|
+
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
296
|
+
}
|
|
297
|
+
return found
|
|
298
|
+
}
|
|
299
|
+
private _onLegendChanged = (): void => { ;(this._realObject as any)?.update?.() }
|
|
300
|
+
|
|
301
|
+
resolveLegendColor(record: any): string | undefined {
|
|
302
|
+
const legend = this.legendTarget
|
|
303
|
+
if (!legend) return undefined
|
|
304
|
+
const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
|
|
305
|
+
if (!status) return undefined
|
|
306
|
+
const field = status.field as string | undefined
|
|
307
|
+
const ranges = status.ranges as any[] | undefined
|
|
308
|
+
if (!field || !Array.isArray(ranges)) return undefined
|
|
309
|
+
const value = record?.[field]
|
|
310
|
+
if (value === undefined || value === null) return status.defaultColor
|
|
311
|
+
for (const range of ranges) {
|
|
312
|
+
if (!range) continue
|
|
313
|
+
if (range.value !== undefined) {
|
|
314
|
+
if (range.value === value) return range.color
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const num = Number(value)
|
|
318
|
+
if (!Number.isFinite(num)) continue
|
|
319
|
+
const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
|
|
320
|
+
const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
|
|
321
|
+
const minOk = min === undefined || num >= min
|
|
322
|
+
const maxOk = max === undefined || num < max
|
|
323
|
+
if (minOk && maxOk) return range.color
|
|
324
|
+
}
|
|
325
|
+
return status.defaultColor as string | undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Popup + click ─────────────────────────────────────────────
|
|
329
|
+
get eventMap() {
|
|
330
|
+
return { '(self)': { '(self)': { click: this._onGridClick } } }
|
|
331
|
+
}
|
|
332
|
+
private _onGridClick = (mouseEvent: MouseEvent) => {
|
|
333
|
+
if (!(this as any).app?.isViewMode) return
|
|
334
|
+
const hit = this._raycastHit(mouseEvent)
|
|
335
|
+
if (!hit) return
|
|
336
|
+
const slotId = (hit.object?.userData?.slotId as string | undefined)
|
|
337
|
+
this._invokePopup(slotId)
|
|
338
|
+
}
|
|
339
|
+
private _invokePopup(slotId?: string): void {
|
|
340
|
+
const popupRefId = this.state.popupRef
|
|
341
|
+
if (!popupRefId) return
|
|
342
|
+
const popupComp: any = (this as any).root?.findById?.(popupRefId)
|
|
343
|
+
if (!popupComp || typeof popupComp.openPopup !== 'function') return
|
|
344
|
+
if (slotId) {
|
|
345
|
+
const p = this.parseCellId(slotId)
|
|
346
|
+
const anchor = this.slotTargetAt(slotId)
|
|
347
|
+
popupComp.openPopup({
|
|
348
|
+
cellId: slotId,
|
|
349
|
+
col: p?.col, row: p?.row,
|
|
350
|
+
records: p ? this.recordsOf(p.col, p.row) : [],
|
|
351
|
+
capacity: p ? this.capacityOf(p.col, p.row) : undefined
|
|
352
|
+
}, { anchor })
|
|
353
|
+
} else {
|
|
354
|
+
const anchor = this.slotTargetAt(this.cellIdOf(0, 0))
|
|
355
|
+
popupComp.openPopup({
|
|
356
|
+
componentId: (this.state as any).id,
|
|
357
|
+
cols: this.cols, rows: this.rows
|
|
358
|
+
}, { anchor })
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
|
|
363
|
+
const ro: any = (this as any)._realObject
|
|
364
|
+
if (!ro?.object3d) return undefined
|
|
365
|
+
const tc: any = ro.threeContainer
|
|
366
|
+
if (!tc) return undefined
|
|
367
|
+
const cap: any = tc._threeCapability ?? tc._capability
|
|
368
|
+
let intersects: THREE.Intersection[] | undefined
|
|
369
|
+
if (cap?.getObjectsByRaycast) intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
|
|
370
|
+
if (!intersects || intersects.length === 0) {
|
|
371
|
+
const scene = tc.scene3d as THREE.Scene | undefined
|
|
372
|
+
const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
|
|
373
|
+
const camera =
|
|
374
|
+
(tc.activeCamera3d as THREE.Camera | undefined) ??
|
|
375
|
+
(cap?.activeCamera as THREE.Camera | undefined) ??
|
|
376
|
+
(cap?.camera as THREE.Camera | undefined)
|
|
377
|
+
const canvas = renderer?.domElement
|
|
378
|
+
if (!scene || !canvas || !camera) return undefined
|
|
379
|
+
const rect = canvas.getBoundingClientRect()
|
|
380
|
+
if (rect.width === 0 || rect.height === 0) return undefined
|
|
381
|
+
const ndc = new THREE.Vector2(
|
|
382
|
+
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
|
383
|
+
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
384
|
+
)
|
|
385
|
+
const raycaster = new THREE.Raycaster()
|
|
386
|
+
raycaster.setFromCamera(ndc, camera)
|
|
387
|
+
intersects = raycaster.intersectObjects(scene.children, true)
|
|
388
|
+
}
|
|
389
|
+
if (!intersects || intersects.length === 0) return undefined
|
|
390
|
+
const closest = intersects[0]
|
|
391
|
+
let obj: THREE.Object3D | null = closest.object
|
|
392
|
+
while (obj) {
|
|
393
|
+
if (obj.userData?.context === ro) return closest
|
|
394
|
+
obj = obj.parent
|
|
395
|
+
}
|
|
396
|
+
return undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── 2D render ─────────────────────────────────────────────
|
|
400
|
+
render(ctx: CanvasRenderingContext2D) {
|
|
401
|
+
const { left = 0, top = 0 } = this.state
|
|
402
|
+
const totalW = this.cellW * this.cols
|
|
403
|
+
const totalH = this.cellH * this.rows
|
|
404
|
+
const fillStyle = (this.state.fillStyle as string) || '#c89c5c'
|
|
405
|
+
const strokeStyle = (this.state.strokeStyle as string) || '#7a5a2e'
|
|
406
|
+
|
|
407
|
+
ctx.save()
|
|
408
|
+
ctx.fillStyle = fillStyle
|
|
409
|
+
ctx.globalAlpha = 0.15
|
|
410
|
+
ctx.fillRect(left, top, totalW, totalH)
|
|
411
|
+
ctx.restore()
|
|
412
|
+
|
|
413
|
+
ctx.save()
|
|
414
|
+
ctx.strokeStyle = strokeStyle
|
|
415
|
+
ctx.lineWidth = 1.2
|
|
416
|
+
ctx.strokeRect(left + 0.6, top + 0.6, totalW - 1.2, totalH - 1.2)
|
|
417
|
+
for (let c = 1; c < this.cols; c++) {
|
|
418
|
+
const x = left + c * this.cellW
|
|
419
|
+
ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + totalH); ctx.stroke()
|
|
420
|
+
}
|
|
421
|
+
for (let r = 1; r < this.rows; r++) {
|
|
422
|
+
const y = top + r * this.cellH
|
|
423
|
+
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(left + totalW, y); ctx.stroke()
|
|
424
|
+
}
|
|
425
|
+
ctx.restore()
|
|
426
|
+
|
|
427
|
+
ctx.save()
|
|
428
|
+
const fontSize = Math.min(this.cellW, this.cellH) * 0.22
|
|
429
|
+
ctx.fillStyle = '#333'
|
|
430
|
+
ctx.font = `bold ${fontSize}px sans-serif`
|
|
431
|
+
ctx.textAlign = 'center'
|
|
432
|
+
ctx.textBaseline = 'middle'
|
|
433
|
+
for (let r = 0; r < this.rows; r++) for (let c = 0; c < this.cols; c++) {
|
|
434
|
+
const count = this.recordsOf(c, r).length
|
|
435
|
+
if (count === 0) continue
|
|
436
|
+
ctx.fillText(`${count}`, left + (c + 0.5) * this.cellW, top + (r + 0.5) * this.cellH)
|
|
437
|
+
}
|
|
438
|
+
ctx.restore()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
buildRealObject(): RealObject | undefined {
|
|
442
|
+
return new StockpileGrid3D(this)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let _stockpileGridCarrierSeq = 0
|
|
447
|
+
function _nextStockpileGridCarrierRefid(): number {
|
|
448
|
+
return 850000 + (_stockpileGridCarrierSeq++)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resolveDepth(c: Component): number {
|
|
452
|
+
const eff = (c as any)._realObject?.effectiveDepth
|
|
453
|
+
if (typeof eff === 'number' && Number.isFinite(eff)) return eff
|
|
454
|
+
const d = (c as any)?.state?.depth
|
|
455
|
+
return typeof d === 'number' && Number.isFinite(d) ? d : 0
|
|
456
|
+
}
|