@operato/scene-storage 10.0.0-beta.41 → 10.0.0-beta.43

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 (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/MIGRATION-plan-a-slot-api.md +266 -0
  3. package/PLAN-A-rack-as-slot-holder.md +164 -0
  4. package/dist/crane.js +1 -1
  5. package/dist/crane.js.map +1 -1
  6. package/dist/index.d.ts +3 -4
  7. package/dist/index.js +1 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/parcel-3d.js +42 -9
  10. package/dist/parcel-3d.js.map +1 -1
  11. package/dist/rack-grid-3d.d.ts +18 -7
  12. package/dist/rack-grid-3d.js +372 -69
  13. package/dist/rack-grid-3d.js.map +1 -1
  14. package/dist/rack-grid-cell.d.ts +21 -72
  15. package/dist/rack-grid-cell.js +147 -243
  16. package/dist/rack-grid-cell.js.map +1 -1
  17. package/dist/rack-grid.d.ts +277 -56
  18. package/dist/rack-grid.js +1230 -695
  19. package/dist/rack-grid.js.map +1 -1
  20. package/dist/rack-materials.d.ts +9 -0
  21. package/dist/rack-materials.js +55 -0
  22. package/dist/rack-materials.js.map +1 -0
  23. package/dist/storage-rack-3d.d.ts +15 -0
  24. package/dist/storage-rack-3d.js +131 -30
  25. package/dist/storage-rack-3d.js.map +1 -1
  26. package/dist/storage-rack.d.ts +242 -45
  27. package/dist/storage-rack.js +684 -106
  28. package/dist/storage-rack.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/crane.ts +1 -1
  31. package/src/index.ts +3 -4
  32. package/src/parcel-3d.ts +41 -9
  33. package/src/rack-grid-3d.ts +383 -80
  34. package/src/rack-grid-cell.ts +161 -305
  35. package/src/rack-grid.ts +1263 -762
  36. package/src/rack-materials.ts +61 -0
  37. package/src/storage-rack-3d.ts +144 -30
  38. package/src/storage-rack.ts +763 -111
  39. package/test/test-carrier-lifecycle.ts +361 -0
  40. package/test/test-coord-alignment.ts +201 -0
  41. package/test/test-external-to-rack.ts +461 -0
  42. package/test/test-mover-concurrent-bug.ts +304 -0
  43. package/test/test-mover-rollback.ts +290 -0
  44. package/test/test-r19-place-absorb.ts +174 -0
  45. package/test/test-rack-3d-attach-real.ts +301 -0
  46. package/test/test-rack-concurrent.ts +254 -0
  47. package/test/test-rack-edge-cases.ts +323 -0
  48. package/test/test-rack-grid-cell.ts +318 -0
  49. package/test/test-rack-grid-location.ts +657 -0
  50. package/test/test-real-3d-positioning.ts +158 -0
  51. package/test/test-slot-center-convention.ts +116 -0
  52. package/test/test-slot-target.ts +189 -0
  53. package/test/test-storage-rack-batched.ts +606 -0
  54. package/test/test-storage-rack-click.ts +329 -0
  55. package/test/test-storage-rack-slot-api.ts +357 -0
  56. package/test/test-toscene-convention.ts +162 -0
  57. package/test/test-user-scenario-sequential.ts +334 -0
  58. package/translations/en.json +2 -0
  59. package/translations/ja.json +2 -0
  60. package/translations/ko.json +2 -0
  61. package/translations/ms.json +2 -0
  62. package/translations/zh.json +2 -0
  63. package/tsconfig.tsbuildinfo +1 -1
  64. package/dist/rack-column.d.ts +0 -35
  65. package/dist/rack-column.js +0 -258
  66. package/dist/rack-column.js.map +0 -1
  67. package/dist/rack-grid-helpers.d.ts +0 -28
  68. package/dist/rack-grid-helpers.js +0 -71
  69. package/dist/rack-grid-helpers.js.map +0 -1
  70. package/dist/rack-grid-location.d.ts +0 -37
  71. package/dist/rack-grid-location.js +0 -227
  72. package/dist/rack-grid-location.js.map +0 -1
  73. package/dist/storage-cell-3d.d.ts +0 -25
  74. package/dist/storage-cell-3d.js +0 -88
  75. package/dist/storage-cell-3d.js.map +0 -1
  76. package/dist/storage-cell.d.ts +0 -73
  77. package/dist/storage-cell.js +0 -215
  78. package/dist/storage-cell.js.map +0 -1
  79. package/src/rack-column.ts +0 -340
  80. package/src/rack-grid-helpers.ts +0 -77
  81. package/src/rack-grid-location.ts +0 -286
  82. package/src/storage-cell-3d.ts +0 -101
  83. package/src/storage-cell.ts +0 -267
  84. package/test/test-cell-position.ts +0 -105
  85. package/test/test-rack-grid.ts +0 -77
@@ -1,126 +1,429 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * RackGrid3D — *공유 corner posts + bay 별 beams + shelf planes*.
5
+ *
6
+ * Post (수직 기둥):
7
+ * - (cols+1) × (rows+1) 의 grid corner 위치마다 *공유 post* 1 개.
8
+ * - 인접 4 bay 중 *최소 1개 non-empty* 면 post 만듦. 모두 empty 면 skip.
9
+ * - 옆 bay 와 *공유* → 두 post 겹침 X.
10
+ *
11
+ * Beam (수평 부재):
12
+ * - non-empty bay 마다 front + back beam (각 shelf level).
13
+ *
14
+ * Shelf (반투명 판):
15
+ * - non-empty bay 의 각 level 의 frame 안쪽.
16
+ *
17
+ * isEmpty source of truth: RackGrid.isBayEmpty(col, row) — cell.state.isEmpty 우선.
3
18
  */
4
-
5
19
  import * as THREE from 'three'
6
20
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js'
7
- import { Component, RealObjectGroup } from '@hatiolab/things-scene'
8
-
9
- import { Rack } from './rack-column.js'
21
+ import { RealObjectGroup } from '@hatiolab/things-scene'
10
22
  import type RackGrid from './rack-grid.js'
11
-
12
- const DEFAULT_FRAME_COLOR = 0x8a8a8a
23
+ import {
24
+ POST_MATERIAL, BEAM_MATERIAL, SHELF_MATERIAL,
25
+ STOCK_MATERIAL, EMPTY_STOCK_MATERIAL
26
+ } from './rack-materials.js'
13
27
 
14
28
  export class RackGrid3D extends RealObjectGroup {
15
- private _frameMaterial?: THREE.MeshStandardMaterial
29
+ private _frameGroup?: THREE.Group // post + beam 묶음 (hideRackFrame 시 hidden)
30
+ private _beamGroup?: THREE.Group // beam 만 (hideHorizontalFrame 시 hidden)
16
31
 
17
32
  build() {
18
33
  super.build()
19
-
20
- this.createRacks()
34
+ this._buildFrames()
35
+ this._applyFrameVisibility()
21
36
  }
22
37
 
23
- // bottom origin: object3d가 zPos(바닥)에 위치 (내부 mesh는 center-local 좌표로 쌓임)
24
- protected get syncZPosOffset(): number {
25
- return 0
38
+ /** hideRackFrame / hideHorizontalFrame 변경 visibility 즉시 반영. */
39
+ applyFrameVisibility(): void {
40
+ this._applyFrameVisibility()
26
41
  }
27
42
 
28
- override get geometricOffsetY(): number {
29
- return 0
43
+ private _applyFrameVisibility(): void {
44
+ const rs: any = this.component.state
45
+ const hideFrame = !!rs?.hideRackFrame
46
+ const hideBeams = !!rs?.hideHorizontalFrame
47
+ if (this._frameGroup) this._frameGroup.visible = !hideFrame
48
+ // beam group 은 frame group 안에 nested — frame 이 hidden 일 때는 beam 도 자연히 hidden.
49
+ // frame visible + beam toggle 따로
50
+ if (this._beamGroup) this._beamGroup.visible = !hideBeams
30
51
  }
31
52
 
32
- private createFrameMaterial(): THREE.MeshStandardMaterial {
33
- this._frameMaterial?.dispose()
34
-
35
- const { strokeStyle } = this.component.state
36
- const color = strokeStyle && typeof strokeStyle === 'string' ? strokeStyle : DEFAULT_FRAME_COLOR
53
+ private _buildFrames(): void {
54
+ const comp = this.component as unknown as RackGrid
55
+ const rs: any = comp.state
56
+ // frame group post + beamGroup 담음. hideRackFrame 시 frame group.visible=false.
57
+ // beam group 가로 frame (hideHorizontalFrame 따로 토글).
58
+ this._frameGroup = new THREE.Group()
59
+ this._beamGroup = new THREE.Group()
60
+ this._frameGroup.add(this._beamGroup)
61
+ this.object3d.add(this._frameGroup)
62
+
63
+ const width = (rs?.width as number) ?? 400 // 3D X
64
+ const height = (rs?.depth as number) ?? 2000 // 3D Y (floor → ceiling)
65
+ const depth = (rs?.height as number) ?? 200 // 3D Z (front → back)
66
+ const cols = comp.columns
67
+ const rows = comp.rackRows
68
+ const shelves = comp.shelves
69
+ const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
70
+ const shelfZone = height - shelfBase
71
+
72
+ const bayW = width / cols
73
+ const bayD = depth / rows
74
+ const baseY = -height / 2
75
+ const shelfBaseY = baseY + shelfBase
76
+
77
+ // Frame 굵기 — storage-rack 과 동일 비율 (공유 post / 공유 beam 적용 후엔
78
+ // 겹침 없으므로 같은 비율 사용 가능).
79
+ const postW = Math.min(bayW, bayD) * 0.06
80
+ const beamH = postW * 1.2
81
+
82
+ const isEmpty = (col: number, row: number): boolean => {
83
+ if (col < 0 || col >= cols || row < 0 || row >= rows) return true
84
+ return comp.isBayEmpty(col, row)
85
+ }
37
86
 
38
- this._frameMaterial = new THREE.MeshStandardMaterial({
39
- color,
40
- roughness: 0.35,
41
- metalness: 0.85
42
- })
87
+ // ── 1. 공유 corner posts (hideRackFrame 면 skip) ───────
88
+ //
89
+ // (cols+1) × (rows+1) 의 모든 corner 위치. 4 인접 bay 중 *하나라도 non-empty*
90
+ // 이면 post 생성. 인접 bay 가 모두 empty → post skip.
91
+
92
+ const postGeos: THREE.BufferGeometry[] = []
93
+ for (let c = 0; c <= cols; c++) {
94
+ for (let r = 0; r <= rows; r++) {
95
+ // 이 corner 에 인접한 4 bay (없는 위치는 isEmpty 처리)
96
+ const anyActive =
97
+ !isEmpty(c - 1, r - 1) ||
98
+ !isEmpty(c, r - 1) ||
99
+ !isEmpty(c - 1, r ) ||
100
+ !isEmpty(c, r )
101
+ if (!anyActive) continue
102
+
103
+ const x = (c - cols / 2) * bayW
104
+ const z = (r - rows / 2) * bayD
105
+ const post = new THREE.BoxGeometry(postW, height, postW)
106
+ post.translate(x, 0, z)
107
+ postGeos.push(post)
108
+ }
109
+ }
43
110
 
44
- return this._frameMaterial
45
- }
111
+ // ── 2. Horizontal beams (X 축 외곽 wall 만 — storage-rack 일관) ────
112
+ //
113
+ // storage-rack 처럼 *front + back 의 X 축 beam* 만. 내부 행 경계 beam +
114
+ // Z 축 (좌우 side) beam 모두 제거 — *깔끔한 wall-frame 시각*.
115
+ // 각 level 마다 front (zEdge=0) + back (zEdge=rows) 두 줄. 각 줄은 *연속
116
+ // non-empty col 구간* 공유 통합.
117
+
118
+ const beamGeos: THREE.BufferGeometry[] = []
119
+
120
+ // 외곽 wall 의 가로 frame — *연속 non-empty bay 구간*만. isEmpty bay 영역엔
121
+ // frame 없음. *내부 cross frame 은 항상 제외* — 깔끔함 유지.
122
+
123
+ // X 축 beam (front + back, 각 level) — non-empty col segment 별 단일 beam
124
+ for (const zEdge of [0, rows]) {
125
+ const zPos = (zEdge - rows / 2) * bayD
126
+ const adjacentRow = zEdge === 0 ? 0 : rows - 1
127
+ for (let lv = 0; lv <= shelves; lv++) {
128
+ const yFrac = lv / shelves
129
+ const y = shelfBaseY + yFrac * shelfZone - beamH / 2 + (lv === 0 ? beamH : 0)
130
+ let segStart = -1
131
+ for (let col = 0; col <= cols; col++) {
132
+ const active = col < cols && !isEmpty(col, adjacentRow)
133
+ if (active && segStart === -1) segStart = col
134
+ if (!active && segStart !== -1) {
135
+ const startX = (segStart - cols / 2) * bayW
136
+ const endX = (col - cols / 2) * bayW
137
+ const beamLen = endX - startX
138
+ const beamCenterX = (startX + endX) / 2
139
+ const beam = new THREE.BoxGeometry(beamLen, beamH, beamH)
140
+ beam.translate(beamCenterX, y, zPos)
141
+ beamGeos.push(beam)
142
+ segStart = -1
143
+ }
144
+ }
145
+ }
146
+ }
46
147
 
47
- createRacks() {
48
- const { rotation = 0, shelfLocations, shelves = 1 } = this.component.state
148
+ // Z 축 beam (좌우 side) — *모든 level*, isEmpty row 제외 segment.
149
+ for (const xEdge of [0, cols]) {
150
+ const xPos = (xEdge - cols / 2) * bayW
151
+ const adjacentCol = xEdge === 0 ? 0 : cols - 1
152
+ for (let lv = 0; lv <= shelves; lv++) {
153
+ const yFrac = lv / shelves
154
+ const y = shelfBaseY + yFrac * shelfZone - beamH / 2 + (lv === 0 ? beamH : 0)
155
+ let segStart = -1
156
+ for (let row = 0; row <= rows; row++) {
157
+ const active = row < rows && !isEmpty(adjacentCol, row)
158
+ if (active && segStart === -1) segStart = row
159
+ if (!active && segStart !== -1) {
160
+ const startZ = (segStart - rows / 2) * bayD
161
+ const endZ = (row - rows / 2) * bayD
162
+ const beamLen = endZ - startZ
163
+ const beamCenterZ = (startZ + endZ) / 2
164
+ const beam = new THREE.BoxGeometry(beamH, beamH, beamLen)
165
+ beam.translate(xPos, y, beamCenterZ)
166
+ beamGeos.push(beam)
167
+ segStart = -1
168
+ }
169
+ }
170
+ }
171
+ }
49
172
 
50
- this.object3d.rotation.y = -rotation
173
+ // 천장 (lv=shelves) 내부 cross beam — *isEmpty 영역 제외 segment*.
174
+ {
175
+ const lv = shelves
176
+ const yFrac = lv / shelves
177
+ const y = shelfBaseY + yFrac * shelfZone - beamH / 2 + (lv === 0 ? beamH : 0)
178
+
179
+ // 내부 col 경계 의 Z 축 beam (col 사이, Z 방향) — 인접 2 col 중 *해당 row* 가 non-empty
180
+ for (let xEdge = 1; xEdge < cols; xEdge++) {
181
+ const xPos = (xEdge - cols / 2) * bayW
182
+ let segStart = -1
183
+ for (let row = 0; row <= rows; row++) {
184
+ const active = row < rows && (!isEmpty(xEdge - 1, row) || !isEmpty(xEdge, row))
185
+ if (active && segStart === -1) segStart = row
186
+ if (!active && segStart !== -1) {
187
+ const startZ = (segStart - rows / 2) * bayD
188
+ const endZ = (row - rows / 2) * bayD
189
+ const beamLen = endZ - startZ
190
+ const beamCenterZ = (startZ + endZ) / 2
191
+ const beam = new THREE.BoxGeometry(beamH, beamH, beamLen)
192
+ beam.translate(xPos, y, beamCenterZ)
193
+ beamGeos.push(beam)
194
+ segStart = -1
195
+ }
196
+ }
197
+ }
51
198
 
52
- const racks = (this.component as unknown as RackGrid).components
53
- .map((cell: Component) => {
54
- const { shelfLocations: shelfLoc = shelfLocations, isEmpty } = cell.state
199
+ // 내부 row 경계 의 X 축 beam (row 사이, X 방향) — 인접 2 row 중 *해당 col* 가 non-empty
200
+ for (let zEdge = 1; zEdge < rows; zEdge++) {
201
+ const zPos = (zEdge - rows / 2) * bayD
202
+ let segStart = -1
203
+ for (let col = 0; col <= cols; col++) {
204
+ const active = col < cols && (!isEmpty(col, zEdge - 1) || !isEmpty(col, zEdge))
205
+ if (active && segStart === -1) segStart = col
206
+ if (!active && segStart !== -1) {
207
+ const startX = (segStart - cols / 2) * bayW
208
+ const endX = (col - cols / 2) * bayW
209
+ const beamLen = endX - startX
210
+ const beamCenterX = (startX + endX) / 2
211
+ const beam = new THREE.BoxGeometry(beamLen, beamH, beamH)
212
+ beam.translate(beamCenterX, y, zPos)
213
+ beamGeos.push(beam)
214
+ segStart = -1
215
+ }
216
+ }
217
+ }
218
+ }
55
219
 
56
- if (!isEmpty) {
57
- cell.setState('shelfLocations', shelfLoc)
220
+ // ── 3. Shelf planes — *단일 InstancedMesh* (성능). 이전엔 cols × rows × shelves
221
+ // 개별 Mesh ( draw call) — 큰 grid 에서 수천 draw call. 이제 1 mesh / 1 draw call.
222
+
223
+ const shelfW = Math.max(0, bayW - 2 * postW)
224
+ const shelfDD = Math.max(0, bayD - 2 * beamH)
225
+ if (shelfW > 0 && shelfDD > 0) {
226
+ const positions: Array<{ x: number; y: number; z: number }> = []
227
+ for (let col = 0; col < cols; col++) {
228
+ for (let row = 0; row < rows; row++) {
229
+ if (isEmpty(col, row)) continue
230
+ const bayCenterX = (col - cols / 2 + 0.5) * bayW
231
+ const bayCenterZ = (row - rows / 2 + 0.5) * bayD
232
+ for (let lv = 0; lv < shelves; lv++) {
233
+ const yFrac = lv / shelves
234
+ const y = shelfBaseY + yFrac * shelfZone + (lv === 0 ? beamH : 0)
235
+ positions.push({ x: bayCenterX, y, z: bayCenterZ })
236
+ }
237
+ }
238
+ }
239
+ if (positions.length > 0) {
240
+ const shelfGeo = new THREE.PlaneGeometry(shelfW, shelfDD)
241
+ shelfGeo.rotateX(-Math.PI / 2)
242
+ const shelfMesh = new THREE.InstancedMesh(shelfGeo, SHELF_MATERIAL, positions.length)
243
+ shelfMesh.receiveShadow = true
244
+ shelfMesh.frustumCulled = false
245
+ const m = new THREE.Matrix4()
246
+ const pos = new THREE.Vector3()
247
+ const q = new THREE.Quaternion()
248
+ const s = new THREE.Vector3(1, 1, 1)
249
+ for (let i = 0; i < positions.length; i++) {
250
+ pos.set(positions[i].x, positions[i].y, positions[i].z)
251
+ m.compose(pos, q, s)
252
+ shelfMesh.setMatrixAt(i, m)
253
+ }
254
+ shelfMesh.instanceMatrix.needsUpdate = true
255
+ shelfMesh.computeBoundingSphere()
256
+ this.object3d.add(shelfMesh)
257
+ }
258
+ }
58
259
 
59
- const rack = new Rack(cell)
60
- cell._realObject = rack // 중복 생성 방지: addObject 재귀에서 skip
260
+ // ── Merge post + beam ─────────────────────────────────
61
261
 
62
- rack.update()
63
- this.object3d.add(rack.object3d)
262
+ if (postGeos.length > 0) {
263
+ const merged = BufferGeometryUtils.mergeGeometries(postGeos)
264
+ const mesh = new THREE.Mesh(merged, POST_MATERIAL)
265
+ mesh.castShadow = true
266
+ mesh.receiveShadow = true
267
+ this._frameGroup!.add(mesh)
268
+ }
64
269
 
65
- return rack
66
- }
67
- return
68
- })
69
- .filter((rack: any): rack is Rack => !!rack)
270
+ if (beamGeos.length > 0) {
271
+ const merged = BufferGeometryUtils.mergeGeometries(beamGeos)
272
+ const mesh = new THREE.Mesh(merged, BEAM_MATERIAL)
273
+ mesh.castShadow = true
274
+ mesh.receiveShadow = true
275
+ this._beamGroup!.add(mesh)
276
+ }
70
277
 
71
- this.mergeAndAddRackCommonObjects(racks)
278
+ // ── 4. Stock InstancedMesh — state.data 의 record + hideEmptyStock 분기 ────
279
+ this.rebuildStockMesh()
72
280
  }
73
281
 
74
- mergeAndAddRackCommonObjects(racks: Rack[]) {
75
- const framesGeometries: THREE.BufferGeometry[] = []
76
- const boardsGeometries: THREE.BufferGeometry[] = []
282
+ // ── Stock visualization ─────────────────────────────────
77
283
 
78
- if (racks.length > 0) {
79
- racks.forEach(rack => {
80
- const geometry = rack.frame
284
+ private _stockMesh?: THREE.InstancedMesh // record 있는 stock (불투명, 색)
285
+ private _emptyStockMesh?: THREE.InstancedMesh // record 없는 stock (반투명 회색)
81
286
 
82
- if (!geometry) {
83
- return
84
- }
85
-
86
- geometry.translate(rack.position.x, rack.position.y, rack.position.z)
87
- geometry.scale(rack.scale.x, rack.scale.y, rack.scale.z)
88
- framesGeometries.push(geometry)
89
- })
287
+ /** Public — 후속 click 핸들러 사용. */
288
+ get stockMesh(): THREE.InstancedMesh | undefined {
289
+ return this._stockMesh
290
+ }
90
291
 
91
- if (framesGeometries.length > 0) {
92
- const frameMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(framesGeometries), this.createFrameMaterial())
93
- this.object3d.add(frameMesh)
94
- }
292
+ /**
293
+ * Stock 시각화 (Plan A — InstancedMesh batched).
294
+ * - hideEmptyStock=true : state.data 의 record 있는 cell 만 instance
295
+ * - hideEmptyStock=false : *모든 (non-isEmpty bay) cell × shelf* 에 instance.
296
+ * record 있으면 default 색, 없으면 *백색 반투명*.
297
+ */
298
+ rebuildStockMesh(): void {
299
+ // 기존 두 mesh 모두 제거
300
+ if (this._stockMesh) {
301
+ this.object3d.remove(this._stockMesh)
302
+ this._stockMesh.dispose?.()
303
+ this._stockMesh = undefined
304
+ }
305
+ if (this._emptyStockMesh) {
306
+ this.object3d.remove(this._emptyStockMesh)
307
+ this._emptyStockMesh.dispose?.()
308
+ this._emptyStockMesh = undefined
309
+ }
95
310
 
96
- racks.forEach(rack => {
97
- const geometry = rack.board
311
+ const comp = this.component as unknown as RackGrid
312
+ const rs: any = comp.state
313
+ const cols = comp.columns
314
+ const rows = comp.rackRows
315
+ const shelves = comp.shelves
316
+ const width = (rs?.width as number) ?? 400
317
+ const height = (rs?.depth as number) ?? 2000
318
+ const depth = (rs?.height as number) ?? 200
319
+ const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
320
+ const shelfZone = height - shelfBase
321
+ const bayW = width / cols
322
+ const bayD = depth / rows
323
+ const cellY = shelfZone / shelves
324
+ const baseY = -height / 2
325
+ const shelfBaseY = baseY + shelfBase
326
+
327
+ const stockW = bayW * 0.85
328
+ const stockD = cellY * 0.7
329
+ const stockH = bayD * 0.85
330
+
331
+ const records = comp.records
332
+ const recordsByCell = new Map<string, any>()
333
+ for (const r of records) {
334
+ if (r?.cellId) recordsByCell.set(r.cellId, r)
335
+ }
98
336
 
99
- if (!geometry) {
100
- return
337
+ const hideEmpty = !!rs?.hideEmptyStock
338
+
339
+ // 두 그룹 분리: record 있는 stock (불투명, 색) vs empty stock (반투명 회색)
340
+ const filled: Array<{ col: number; row: number; shelf: number; record: any }> = []
341
+ const empties: Array<{ col: number; row: number; shelf: number }> = []
342
+
343
+ for (let col = 0; col < cols; col++) {
344
+ for (let row = 0; row < rows; row++) {
345
+ if (comp.isBayEmpty(col, row)) continue
346
+ for (let shelf = 0; shelf < shelves; shelf++) {
347
+ const cellId = `${col}-${row}-${shelf}`
348
+ const record = recordsByCell.get(cellId)
349
+ if (record) {
350
+ filled.push({ col, row, shelf, record })
351
+ } else if (!hideEmpty) {
352
+ empties.push({ col, row, shelf })
353
+ }
101
354
  }
355
+ }
356
+ }
102
357
 
103
- geometry.translate(rack.position.x, rack.position.y, rack.position.z)
104
- geometry.scale(rack.scale.x, rack.scale.y, rack.scale.z)
105
- boardsGeometries.push(geometry)
106
- })
107
-
108
- if (boardsGeometries.length > 0) {
109
- const material = Rack.boardMaterial
110
- material.opacity = 0.5
111
- material.transparent = true
358
+ const matrixFor = (col: number, row: number, shelf: number, target: THREE.Matrix4) => {
359
+ const cx = (col - cols / 2 + 0.5) * bayW
360
+ const cellBottomY = shelfBaseY + shelf * cellY
361
+ const cy = cellBottomY + stockD / 2
362
+ const cz = (row - rows / 2 + 0.5) * bayD
363
+ target.compose(new THREE.Vector3(cx, cy, cz), new THREE.Quaternion(), new THREE.Vector3(1, 1, 1))
364
+ }
112
365
 
113
- const boardMesh = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(boardsGeometries), material)
366
+ // ── 1. Filled stock — 불투명, legend/default 색 ──────
367
+ if (filled.length > 0) {
368
+ const STOCK_COLOR_DEFAULT = '#c8a878' // cardboard
369
+ const geo = new THREE.BoxGeometry(stockW, stockD, stockH)
370
+ const mesh = new THREE.InstancedMesh(geo, STOCK_MATERIAL, filled.length)
371
+ mesh.frustumCulled = false
372
+ mesh.userData.context = this
373
+ mesh.userData._records = filled.map(i => i.record)
374
+
375
+ const m = new THREE.Matrix4()
376
+ const c = new THREE.Color()
377
+ for (let i = 0; i < filled.length; i++) {
378
+ const { col, row, shelf, record } = filled[i]
379
+ matrixFor(col, row, shelf, m)
380
+ mesh.setMatrixAt(i, m)
381
+ const resolved = (comp as any).resolveLegendColor?.(record) ?? STOCK_COLOR_DEFAULT
382
+ c.set(resolved)
383
+ mesh.setColorAt(i, c)
384
+ }
385
+ mesh.instanceMatrix.needsUpdate = true
386
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
387
+ mesh.computeBoundingSphere()
388
+ mesh.computeBoundingBox?.()
389
+ this.object3d.add(mesh)
390
+ this._stockMesh = mesh
391
+ }
114
392
 
115
- this.object3d.add(boardMesh)
393
+ // ── 2. Empty stock — 반투명 회색 (hideEmptyStock=off 시만) ────
394
+ if (empties.length > 0) {
395
+ const geo = new THREE.BoxGeometry(stockW, stockD, stockH)
396
+ const mesh = new THREE.InstancedMesh(geo, EMPTY_STOCK_MATERIAL, empties.length)
397
+ mesh.frustumCulled = false
398
+ mesh.userData.context = this
399
+
400
+ const m = new THREE.Matrix4()
401
+ for (let i = 0; i < empties.length; i++) {
402
+ const { col, row, shelf } = empties[i]
403
+ matrixFor(col, row, shelf, m)
404
+ mesh.setMatrixAt(i, m)
116
405
  }
406
+ mesh.instanceMatrix.needsUpdate = true
407
+ mesh.computeBoundingSphere()
408
+ mesh.computeBoundingBox?.()
409
+ this.object3d.add(mesh)
410
+ this._emptyStockMesh = mesh
117
411
  }
118
412
  }
119
413
 
120
414
  dispose() {
121
- this._frameMaterial?.dispose()
122
- this._frameMaterial = undefined
123
-
415
+ // Material 은 module-level singleton 이라 dispose 안 함 (전체 application
416
+ // lifecycle 동안 살아있음). InstancedMesh / Group 의 geometry 만 정리.
417
+ if (this._stockMesh) {
418
+ this.object3d.remove(this._stockMesh)
419
+ this._stockMesh.dispose?.()
420
+ this._stockMesh = undefined
421
+ }
422
+ if (this._emptyStockMesh) {
423
+ this.object3d.remove(this._emptyStockMesh)
424
+ this._emptyStockMesh.dispose?.()
425
+ this._emptyStockMesh = undefined
426
+ }
124
427
  super.dispose()
125
428
  }
126
429