@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/box.js +2 -2
  3. package/dist/box.js.map +1 -1
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.js +6 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pallet.js +2 -2
  8. package/dist/pallet.js.map +1 -1
  9. package/dist/parcel.js +2 -2
  10. package/dist/parcel.js.map +1 -1
  11. package/dist/picking-station-3d.d.ts +20 -0
  12. package/dist/picking-station-3d.js +162 -0
  13. package/dist/picking-station-3d.js.map +1 -0
  14. package/dist/picking-station.d.ts +50 -0
  15. package/dist/picking-station.js +186 -0
  16. package/dist/picking-station.js.map +1 -0
  17. package/dist/rack-capability.d.ts +11 -0
  18. package/dist/rack-capability.js +25 -0
  19. package/dist/rack-capability.js.map +1 -0
  20. package/dist/rack-grid.d.ts +4 -22
  21. package/dist/rack-grid.js +23 -115
  22. package/dist/rack-grid.js.map +1 -1
  23. package/dist/spot.d.ts +1 -0
  24. package/dist/spot.js +6 -2
  25. package/dist/spot.js.map +1 -1
  26. package/dist/stockpile-3d.d.ts +55 -0
  27. package/dist/stockpile-3d.js +387 -0
  28. package/dist/stockpile-3d.js.map +1 -0
  29. package/dist/stockpile-grid-3d.d.ts +30 -0
  30. package/dist/stockpile-grid-3d.js +301 -0
  31. package/dist/stockpile-grid-3d.js.map +1 -0
  32. package/dist/stockpile-grid.d.ts +85 -0
  33. package/dist/stockpile-grid.js +361 -0
  34. package/dist/stockpile-grid.js.map +1 -0
  35. package/dist/stockpile.d.ts +116 -0
  36. package/dist/stockpile.js +345 -0
  37. package/dist/stockpile.js.map +1 -0
  38. package/dist/storage-rack.d.ts +39 -44
  39. package/dist/storage-rack.js +71 -146
  40. package/dist/storage-rack.js.map +1 -1
  41. package/dist/templates/index.d.ts +80 -0
  42. package/dist/templates/index.js +7 -1
  43. package/dist/templates/index.js.map +1 -1
  44. package/dist/templates/picking-station.d.ts +20 -0
  45. package/dist/templates/picking-station.js +22 -0
  46. package/dist/templates/picking-station.js.map +1 -0
  47. package/dist/templates/stockpile-grid.d.ts +37 -0
  48. package/dist/templates/stockpile-grid.js +38 -0
  49. package/dist/templates/stockpile-grid.js.map +1 -0
  50. package/dist/templates/stockpile.d.ts +29 -0
  51. package/dist/templates/stockpile.js +31 -0
  52. package/dist/templates/stockpile.js.map +1 -0
  53. package/package.json +3 -3
  54. package/src/box.ts +2 -1
  55. package/src/index.ts +14 -0
  56. package/src/pallet.ts +2 -1
  57. package/src/parcel.ts +2 -1
  58. package/src/picking-station-3d.ts +164 -0
  59. package/src/picking-station.ts +220 -0
  60. package/src/rack-capability.ts +26 -0
  61. package/src/rack-grid.ts +24 -108
  62. package/src/spot.ts +15 -1
  63. package/src/stockpile-3d.ts +412 -0
  64. package/src/stockpile-grid-3d.ts +327 -0
  65. package/src/stockpile-grid.ts +408 -0
  66. package/src/stockpile.ts +427 -0
  67. package/src/storage-rack.ts +82 -137
  68. package/src/templates/index.ts +7 -1
  69. package/src/templates/picking-station.ts +23 -0
  70. package/src/templates/stockpile-grid.ts +39 -0
  71. package/src/templates/stockpile.ts +32 -0
  72. package/test/test-rack-capability.ts +51 -0
  73. package/translations/en.json +23 -6
  74. package/translations/ja.json +23 -6
  75. package/translations/ko.json +22 -5
  76. package/translations/ms.json +23 -6
  77. package/translations/zh.json +22 -5
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,412 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Stockpile3D — 평치 영역의 3D 시각화.
5
+ *
6
+ * - floor pad (얇은 box, 영역 footprint).
7
+ * - records.length 만큼 가상 carrier mesh 를 *_stackPattern_* 으로 자동 배치.
8
+ * - mesh geometry / material 은 *_carrierPreset_* 별.
9
+ *
10
+ * 핵심 — 실제 carrier 컴포넌트를 자식으로 두지 않고 *_가상 mesh_* 만 그려서 다수
11
+ * 적치를 가볍게 표현. records 변경 시 update() 가 호출되어 mesh 가 재배치된다.
12
+ */
13
+
14
+ import * as THREE from 'three'
15
+ import { RealObjectGroup } from '@hatiolab/things-scene'
16
+
17
+ import type Stockpile from './stockpile.js'
18
+ import type { StackPattern, CarrierPreset } from './stockpile.js'
19
+
20
+ const PAD_DEPTH = 2
21
+
22
+ /** carrierPreset 별 default 색 (state.carrierWidth/Height/Depth 미지정 시 크기와 함께). */
23
+ const PRESET_COLOR: Record<CarrierPreset, number> = {
24
+ box: 0xb87333, // kraft brown
25
+ pallet: 0x8b6f47, // 짙은 나무
26
+ drum: 0x556679, // 금속/blue-grey
27
+ sack: 0xd4c89a, // beige 천
28
+ crate: 0x9a7548, // 나무 상자
29
+ bale: 0xa89968 // 압축 베일
30
+ }
31
+
32
+ const PRESET_DEFAULT_SIZE: Record<CarrierPreset, { w: number; h: number; d: number }> = {
33
+ box: { w: 26, h: 26, d: 20 },
34
+ pallet: { w: 40, h: 30, d: 15 },
35
+ drum: { w: 24, h: 24, d: 30 },
36
+ sack: { w: 30, h: 28, d: 18 },
37
+ crate: { w: 30, h: 30, d: 22 },
38
+ bale: { w: 32, h: 30, d: 18 }
39
+ }
40
+
41
+ export class Stockpile3D extends RealObjectGroup {
42
+ private _carriersGroup?: THREE.Group
43
+ private _padMesh?: THREE.Mesh
44
+ /** pad 의 외곽선 — strokeStyle 표현 (2D dashed outline 의 3D 대응). */
45
+ private _padOutline?: THREE.LineSegments
46
+ /** record.id → 해당 carrier mesh 매핑 — popup anchor / tether 의 대상으로 사용. */
47
+ private _meshByRecordId: Map<string, THREE.Mesh> = new Map()
48
+ /** Legend 매핑된 색을 material 로 캐시 — 같은 색 record 들은 material 공유 (drawcall ↓). */
49
+ private _materialByColor: Map<number, THREE.MeshStandardMaterial> = new Map()
50
+
51
+ private _getMaterialForColor(colorHex: number): THREE.MeshStandardMaterial {
52
+ let m = this._materialByColor.get(colorHex)
53
+ if (!m) {
54
+ m = new THREE.MeshStandardMaterial({ color: colorHex, roughness: 0.7 })
55
+ this._materialByColor.set(colorHex, m)
56
+ }
57
+ return m
58
+ }
59
+
60
+ private _disposeMaterialCache(): void {
61
+ for (const m of this._materialByColor.values()) m.dispose()
62
+ this._materialByColor.clear()
63
+ }
64
+
65
+ build() {
66
+ super.build()
67
+ const state = this.component.state as any
68
+ const w = state.width ?? 100
69
+ const h = state.height ?? 100
70
+
71
+ // ── floor pad — 영역 표시 (얇은 box) ──────────────────────────────
72
+ const padGeom = new THREE.BoxGeometry(w, PAD_DEPTH, h)
73
+ const padColor = this._padColor()
74
+ const padMat = new THREE.MeshStandardMaterial({
75
+ color: padColor, roughness: 0.85, transparent: true, opacity: 0.55,
76
+ emissive: padColor, emissiveIntensity: 0.05
77
+ })
78
+ this._padMesh = new THREE.Mesh(padGeom, padMat)
79
+ this._padMesh.position.y = PAD_DEPTH / 2
80
+ this._padMesh.receiveShadow = true
81
+ this.object3d.add(this._padMesh)
82
+
83
+ // pad 외곽선 — strokeStyle 3D 표현. 2D 의 dashed outline 과 같은 의도.
84
+ const outlineColor = this._strokeColor()
85
+ const outlineMat = new THREE.LineBasicMaterial({ color: outlineColor })
86
+ this._padOutline = new THREE.LineSegments(new THREE.EdgesGeometry(padGeom), outlineMat)
87
+ this._padOutline.position.y = PAD_DEPTH / 2
88
+ this.object3d.add(this._padOutline)
89
+
90
+ // ── carriers group — records 기반 자동 적치 ─────────────────────
91
+ this._carriersGroup = new THREE.Group()
92
+ this._carriersGroup.position.y = PAD_DEPTH
93
+ this.object3d.add(this._carriersGroup)
94
+ this._rebuildCarriers()
95
+ }
96
+
97
+ update() {
98
+ super.update()
99
+ this._rebuildCarriers()
100
+ }
101
+
102
+ /**
103
+ * carrier attach point.
104
+ * - slotId 가 record.id 와 매칭되면 그 record 의 mesh 반환 → popup tether 가 정확히
105
+ * 해당 stock 에 연결.
106
+ * - 아니면 pad 반환 (단일 'pile' slot, mover place 의 default anchor).
107
+ */
108
+ getAttachFrame(slotId?: string): THREE.Object3D | undefined {
109
+ if (slotId) {
110
+ const mesh = this._meshByRecordId.get(slotId)
111
+ if (mesh) return mesh
112
+ }
113
+ return this._padMesh
114
+ }
115
+
116
+ /** state.fillStyle → THREE.Color. 잘못된 값/없으면 default. capacity 점유율에 따른
117
+ * 색 변경은 강요하지 않음 — 필요하면 사용자가 mapping(eval)으로 fillStyle 을 조정. */
118
+ private _padColor(): THREE.Color {
119
+ const raw = (this.component.state as any)?.fillStyle
120
+ if (typeof raw === 'string' && raw.length > 0) {
121
+ try { return new THREE.Color(raw) } catch { /* fallthrough */ }
122
+ }
123
+ return new THREE.Color(0xc89c5c)
124
+ }
125
+
126
+ /** state.strokeStyle → THREE.Color (pad 외곽선 색). 미설정 시 fillStyle 따라가되
127
+ * 대비 위해 약간 어둡게 */
128
+ private _strokeColor(): THREE.Color {
129
+ const raw = (this.component.state as any)?.strokeStyle
130
+ if (typeof raw === 'string' && raw.length > 0) {
131
+ try { return new THREE.Color(raw) } catch { /* fallthrough */ }
132
+ }
133
+ return this._padColor().clone().multiplyScalar(0.6)
134
+ }
135
+
136
+ private _applyStrokeColor(): void {
137
+ if (!this._padOutline) return
138
+ const m = this._padOutline.material as THREE.LineBasicMaterial
139
+ m.color.copy(this._strokeColor())
140
+ m.needsUpdate = true
141
+ }
142
+
143
+ /** state.fillStyle 변경 즉시 반영 — pad material 의 color + emissive 갱신. */
144
+ private _applyPadColor(): void {
145
+ if (!this._padMesh) return
146
+ const m = this._padMesh.material as THREE.MeshStandardMaterial
147
+ const c = this._padColor()
148
+ m.color.copy(c)
149
+ m.emissive.copy(c)
150
+ m.needsUpdate = true
151
+ }
152
+
153
+ private _rebuildCarriers(): void {
154
+ const g = this._carriersGroup
155
+ if (!g) return
156
+ // 기존 mesh 모두 제거 (geometry/material 은 공유돼 dispose 안 함)
157
+ while (g.children.length > 0) g.remove(g.children[0])
158
+
159
+ const stockpile = this.component as unknown as Stockpile
160
+ const count = stockpile.records.length
161
+ if (count === 0) return
162
+
163
+ const state = stockpile.state as any
164
+ const areaW = state.width ?? 100
165
+ const areaH = state.height ?? 100
166
+ const pattern = (state.stackPattern as StackPattern) ?? 'row'
167
+ const preset = (state.carrierPreset as CarrierPreset) ?? 'box'
168
+
169
+ // carrier 크기 — 명시값 우선, 없으면 preset default
170
+ const def = PRESET_DEFAULT_SIZE[preset] ?? PRESET_DEFAULT_SIZE.box
171
+ const cw = state.carrierWidth ?? def.w
172
+ const ch = state.carrierHeight ?? def.h
173
+ const cd = state.carrierDepth ?? def.d
174
+ const gap = state.carrierGap ?? 10 // 평면(xz) cell 간격 — 0 이면 딱 붙음. carrier 크기 대비 충분히 분리되도록 default 10.
175
+
176
+ const geom = this._carrierGeometry(preset, cw, ch, cd)
177
+ // record 별로 색이 다를 수 있어(Legend 매핑) material 을 record 별로 생성.
178
+ // 같은 색은 material 캐시로 공유해 dispose 비용 / drawcall 최적화.
179
+ const presetDefaultColor = PRESET_COLOR[preset] ?? PRESET_COLOR.box
180
+ this._disposeMaterialCache()
181
+ const resolveColor = (record: any): number => {
182
+ const legendColor = (stockpile as any).resolveLegendColor?.(record)
183
+ if (typeof legendColor === 'string' && legendColor.length > 0) {
184
+ try { return new THREE.Color(legendColor).getHex() } catch { /* fallthrough */ }
185
+ }
186
+ return presetDefaultColor
187
+ }
188
+
189
+ const positions = this._computeStackPositions(pattern, count, areaW, areaH, cw, ch, cd, gap, state.stackHeightLimit)
190
+ this._meshByRecordId.clear()
191
+ const records = stockpile.records
192
+ for (let i = 0; i < positions.length; i++) {
193
+ const p = positions[i]
194
+ const record = records[i]
195
+ const colorHex = resolveColor(record)
196
+ const mat = this._getMaterialForColor(colorHex)
197
+ const mesh = new THREE.Mesh(geom, mat)
198
+ mesh.position.set(p.x, p.y, p.z)
199
+ mesh.castShadow = true
200
+ mesh.receiveShadow = true
201
+ // raycast hit → record 식별을 위해 userData 에 recordId 저장 + Map 갱신.
202
+ const rid = records[i]?.id
203
+ if (rid != null) {
204
+ mesh.userData.recordId = String(rid)
205
+ this._meshByRecordId.set(String(rid), mesh)
206
+ }
207
+ g.add(mesh)
208
+ }
209
+ }
210
+
211
+ /** record.id 로 해당 carrier mesh 직접 조회 — popup tether anchor 용. */
212
+ getMeshByRecordId(recordId: string): THREE.Mesh | undefined {
213
+ return this._meshByRecordId.get(recordId)
214
+ }
215
+
216
+ /**
217
+ * stackPattern 별 각 carrier 의 local 좌표(영역 중심 기준). y 는 pad 위에 쌓이는
218
+ * 높이(cd 단위), x/z 는 영역 평면 위 분포.
219
+ */
220
+ private _computeStackPositions(
221
+ pattern: StackPattern,
222
+ count: number,
223
+ areaW: number,
224
+ areaH: number,
225
+ cw: number,
226
+ ch: number,
227
+ cd: number,
228
+ gap: number,
229
+ heightLimit?: number
230
+ ): Array<{ x: number; y: number; z: number }> {
231
+ const out: Array<{ x: number; y: number; z: number }> = []
232
+
233
+ // column — 한 자리에 수직 적층
234
+ if (pattern === 'column') {
235
+ const layers = heightLimit ?? count
236
+ for (let i = 0; i < count; i++) {
237
+ const layer = Math.min(i, layers - 1)
238
+ out.push({ x: 0, y: layer * cd + cd / 2, z: 0 })
239
+ }
240
+ return out
241
+ }
242
+
243
+ // 격자 cell — 행/열 수. cell stride = carrier + gap (인접 carrier 사이 분리).
244
+ const stridX = cw + gap
245
+ const stridZ = ch + gap
246
+ const cols = Math.max(1, Math.floor((areaW + gap) / stridX))
247
+ const rows = Math.max(1, Math.floor((areaH + gap) / stridZ))
248
+ const cellsPerLayer = cols * rows
249
+
250
+ // pyramid — 위로 갈수록 행/열 -1 씩 (사다리꼴)
251
+ if (pattern === 'pyramid') {
252
+ let remaining = count
253
+ let layer = 0
254
+ while (remaining > 0) {
255
+ const lcols = Math.max(1, cols - layer)
256
+ const lrows = Math.max(1, rows - layer)
257
+ const cellsThisLayer = lcols * lrows
258
+ const take = Math.min(remaining, cellsThisLayer)
259
+ for (let i = 0; i < take; i++) {
260
+ const c = i % lcols
261
+ const r = Math.floor(i / lcols)
262
+ // 가운데 정렬 — 줄어든 만큼 offset
263
+ const offsetCol = (cols - lcols) / 2
264
+ const offsetRow = (rows - lrows) / 2
265
+ const x = -areaW / 2 + (c + offsetCol + 0.5) * stridX
266
+ const z = -areaH / 2 + (r + offsetRow + 0.5) * stridZ
267
+ out.push({ x, y: layer * cd + cd / 2, z })
268
+ }
269
+ remaining -= take
270
+ layer++
271
+ if (lcols === 1 && lrows === 1) {
272
+ // 정상에 도달 — 남은 건 column 처럼 위로
273
+ for (let i = 0; i < remaining; i++) {
274
+ out.push({ x: 0, y: (layer + i) * cd + cd / 2, z: 0 })
275
+ }
276
+ break
277
+ }
278
+ if (heightLimit && layer >= heightLimit) break
279
+ }
280
+ return out
281
+ }
282
+
283
+ // pile — 자연 더미. row 와 비슷하되 가장자리는 약간 옆으로 흘러내림 효과
284
+ // (간단 구현 — 단마다 약간 random offset).
285
+ if (pattern === 'pile') {
286
+ // pseudo-random (인덱스 기반, deterministic)
287
+ const rng = (i: number) => ((Math.sin(i * 12.9898) * 43758.5453) % 1 + 1) % 1
288
+ for (let i = 0; i < count; i++) {
289
+ const layer = Math.floor(i / cellsPerLayer)
290
+ const idx = i % cellsPerLayer
291
+ const c = idx % cols
292
+ const r = Math.floor(idx / cols)
293
+ const jitterX = (rng(i * 2) - 0.5) * cw * 0.2
294
+ const jitterZ = (rng(i * 2 + 1) - 0.5) * ch * 0.2
295
+ const x = -areaW / 2 + (c + 0.5) * stridX + jitterX
296
+ const z = -areaH / 2 + (r + 0.5) * stridZ + jitterZ
297
+ out.push({ x, y: layer * cd + cd / 2, z })
298
+ if (heightLimit && layer >= heightLimit - 1 && idx === cellsPerLayer - 1) break
299
+ }
300
+ return out
301
+ }
302
+
303
+ // row (default) / staggered — 격자 적층
304
+ for (let i = 0; i < count; i++) {
305
+ const layer = Math.floor(i / cellsPerLayer)
306
+ if (heightLimit && layer >= heightLimit) break
307
+ const idx = i % cellsPerLayer
308
+ const c = idx % cols
309
+ const r = Math.floor(idx / cols)
310
+ let x = -areaW / 2 + (c + 0.5) * stridX
311
+ const z = -areaH / 2 + (r + 0.5) * stridZ
312
+ // staggered — 홀수 layer 는 1/2 cell offset (벽돌식)
313
+ if (pattern === 'staggered' && layer % 2 === 1) {
314
+ x += stridX / 2
315
+ }
316
+ out.push({ x, y: layer * cd + cd / 2, z })
317
+ }
318
+ return out
319
+ }
320
+
321
+ /** carrierPreset 별 geometry. drum 은 cylinder, 나머지는 box. */
322
+ private _carrierGeometry(preset: CarrierPreset, w: number, h: number, d: number): THREE.BufferGeometry {
323
+ if (preset === 'drum') {
324
+ const radius = Math.min(w, h) / 2
325
+ return new THREE.CylinderGeometry(radius, radius, d, 16)
326
+ }
327
+ // sack/bale — 약간 작게 (자연스러운 형태)
328
+ if (preset === 'sack' || preset === 'bale') {
329
+ return new THREE.BoxGeometry(w * 0.92, d * 0.92, h * 0.92)
330
+ }
331
+ return new THREE.BoxGeometry(w, d, h)
332
+ }
333
+
334
+ /**
335
+ * state.alpha 변경 시 pad + carriers 의 material opacity 즉시 반영.
336
+ * pad 는 원래 0.55 base opacity 위에 alpha 곱 (영역 표시가 너무 진해지지 않도록).
337
+ * carriers 는 alpha 그대로 (단순한 곱은 fully opaque 이 1, 반투명이 0).
338
+ */
339
+ updateAlpha(): void {
340
+ const state = this.component.state as any
341
+ const alpha = typeof state.alpha === 'number' ? state.alpha : 1
342
+ if (this._padMesh) {
343
+ const m = this._padMesh.material as THREE.MeshStandardMaterial
344
+ m.opacity = 0.55 * alpha
345
+ m.transparent = m.opacity < 1
346
+ m.needsUpdate = true
347
+ }
348
+ if (this._carriersGroup) {
349
+ for (const child of this._carriersGroup.children) {
350
+ const mesh = child as THREE.Mesh
351
+ const m = mesh.material as THREE.MeshStandardMaterial
352
+ if (!m) continue
353
+ m.opacity = alpha
354
+ m.transparent = alpha < 1
355
+ m.needsUpdate = true
356
+ }
357
+ }
358
+ }
359
+
360
+ updateDimension(): void {
361
+ // width/height 변경 시 pad geometry + outline + carriers 재계산
362
+ if (this._padMesh) {
363
+ const state = this.component.state as any
364
+ const w = state.width ?? 100
365
+ const h = state.height ?? 100
366
+ this._padMesh.geometry.dispose()
367
+ this._padMesh.geometry = new THREE.BoxGeometry(w, PAD_DEPTH, h)
368
+ if (this._padOutline) {
369
+ this._padOutline.geometry.dispose()
370
+ this._padOutline.geometry = new THREE.EdgesGeometry(this._padMesh.geometry)
371
+ }
372
+ }
373
+ this._rebuildCarriers()
374
+ }
375
+
376
+ /**
377
+ * state 변경 시 즉시 3D 갱신 — property panel 의 수정이 바로 반영되도록.
378
+ * - width/height → pad geometry 재생성 + carriers 재계산 (updateDimension)
379
+ * - 적치/외형 관련 키 → carriers 만 재계산 (_rebuildCarriers)
380
+ */
381
+ onchange(after: Record<string, unknown>, before: Record<string, unknown>): void {
382
+ if ('width' in after || 'height' in after) {
383
+ this.updateDimension()
384
+ return
385
+ }
386
+ if ('alpha' in after) {
387
+ this.updateAlpha()
388
+ return
389
+ }
390
+ if ('fillStyle' in after) {
391
+ this._applyPadColor()
392
+ // fillStyle 변경 시 strokeStyle 미설정이면 outline default 도 같이 갱신
393
+ if ((this.component.state as any).strokeStyle == null) this._applyStrokeColor()
394
+ return
395
+ }
396
+ if ('strokeStyle' in after) {
397
+ this._applyStrokeColor()
398
+ return
399
+ }
400
+ const rebuildKeys = [
401
+ 'data',
402
+ 'stackPattern', 'carrierPreset',
403
+ 'carrierWidth', 'carrierHeight', 'carrierDepth', 'carrierGap',
404
+ 'capacity', 'stackHeightLimit', 'pickPolicy'
405
+ ]
406
+ if (rebuildKeys.some(k => k in after)) {
407
+ this._rebuildCarriers()
408
+ return
409
+ }
410
+ super.onchange?.(after, before)
411
+ }
412
+ }