@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/index.d.ts +9 -0
  3. package/dist/index.js +6 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/picking-station-3d.d.ts +20 -0
  6. package/dist/picking-station-3d.js +162 -0
  7. package/dist/picking-station-3d.js.map +1 -0
  8. package/dist/picking-station.d.ts +56 -0
  9. package/dist/picking-station.js +212 -0
  10. package/dist/picking-station.js.map +1 -0
  11. package/dist/rack-capability.d.ts +11 -0
  12. package/dist/rack-capability.js +25 -0
  13. package/dist/rack-capability.js.map +1 -0
  14. package/dist/rack-grid.js +3 -10
  15. package/dist/rack-grid.js.map +1 -1
  16. package/dist/spot.d.ts +19 -1
  17. package/dist/spot.js +63 -1
  18. package/dist/spot.js.map +1 -1
  19. package/dist/stockpile-3d.d.ts +55 -0
  20. package/dist/stockpile-3d.js +387 -0
  21. package/dist/stockpile-3d.js.map +1 -0
  22. package/dist/stockpile-grid-3d.d.ts +30 -0
  23. package/dist/stockpile-grid-3d.js +301 -0
  24. package/dist/stockpile-grid-3d.js.map +1 -0
  25. package/dist/stockpile-grid.d.ts +88 -0
  26. package/dist/stockpile-grid.js +429 -0
  27. package/dist/stockpile-grid.js.map +1 -0
  28. package/dist/stockpile.d.ts +133 -0
  29. package/dist/stockpile.js +439 -0
  30. package/dist/stockpile.js.map +1 -0
  31. package/dist/storage-rack.d.ts +12 -0
  32. package/dist/storage-rack.js +20 -10
  33. package/dist/storage-rack.js.map +1 -1
  34. package/dist/templates/index.d.ts +80 -0
  35. package/dist/templates/index.js +7 -1
  36. package/dist/templates/index.js.map +1 -1
  37. package/dist/templates/picking-station.d.ts +20 -0
  38. package/dist/templates/picking-station.js +22 -0
  39. package/dist/templates/picking-station.js.map +1 -0
  40. package/dist/templates/stockpile-grid.d.ts +37 -0
  41. package/dist/templates/stockpile-grid.js +38 -0
  42. package/dist/templates/stockpile-grid.js.map +1 -0
  43. package/dist/templates/stockpile.d.ts +29 -0
  44. package/dist/templates/stockpile.js +31 -0
  45. package/dist/templates/stockpile.js.map +1 -0
  46. package/package.json +3 -3
  47. package/src/index.ts +14 -0
  48. package/src/picking-station-3d.ts +164 -0
  49. package/src/picking-station.ts +243 -0
  50. package/src/rack-capability.ts +26 -0
  51. package/src/rack-grid.ts +3 -8
  52. package/src/spot.ts +62 -0
  53. package/src/stockpile-3d.ts +412 -0
  54. package/src/stockpile-grid-3d.ts +327 -0
  55. package/src/stockpile-grid.ts +456 -0
  56. package/src/stockpile.ts +508 -0
  57. package/src/storage-rack.ts +21 -8
  58. package/src/templates/index.ts +7 -1
  59. package/src/templates/picking-station.ts +23 -0
  60. package/src/templates/stockpile-grid.ts +39 -0
  61. package/src/templates/stockpile.ts +32 -0
  62. package/test/test-rack-capability.ts +51 -0
  63. package/translations/en.json +18 -6
  64. package/translations/ja.json +18 -6
  65. package/translations/ko.json +17 -5
  66. package/translations/ms.json +18 -6
  67. package/translations/zh.json +17 -5
  68. 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
+ }