@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
package/src/rack-grid.ts CHANGED
@@ -1,264 +1,105 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * RackGrid — *grid layout 의 컨테이너*.
5
+ *
6
+ * 각 (col, row) 위치마다 *하나의 자식 컴포넌트* 보유 가능:
7
+ * - StorageRack (한 column 의 vertical stack — stock 보관)
8
+ * - Crane / AGV / forklift / 미니로드 등 *operation archetype* (이동/작업 설비)
9
+ * - 또는 비어 있음 (= 통로 / 작업자 공간 / 무엇이든)
10
+ *
11
+ * 외부에는 *location string* (예: "Z-A01-03-1") 으로 API 노출. 내부 cellId 와 양방향
12
+ * 변환은 RackGrid 가 책임.
13
+ *
14
+ * **Performance** — 각 자식 StorageRack 이 자체 InstancedMesh 로 stock 렌더링.
15
+ * storage-rack 의 batched 시각화 성능 그대로 계승. RackGrid 는 *얇은 wrapper*.
16
+ *
17
+ * **Transfer 컨트랙** — SlottedHolder 인터페이스 구현. 내부적으로 *자식 StorageRack*
18
+ * 으로 위임. Mover / Crane 등이 *RackGrid 의 slot target* 으로 인터랙트 가능.
19
+ *
20
+ * **Configuration 생산성** — rack-table 의 *location 룰* (locPattern, sectionDigits 등)
21
+ * 차용. cell-component 없이 *sparse cellOverrides* 만으로 동일 UX 제공.
22
+ *
23
+ * **Aisle/Empty** — `cellOverrides[posKey].isEmpty = true` 로 명시. 의미는
24
+ * *location 미부여 + increaseLocation 의 aisle row 인식* 만. 그 위치의 자식 컴포넌트
25
+ * 종류는 *자유* (Crane/AGV/forklift/그냥 비움 모두 가능).
26
+ *
27
+ * **CellId 컨벤션** (내부):
28
+ * - posKey = `${col-1}-${row-1}` (0-based, 2 segments) — *grid 평면 위치*
29
+ * - cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments) — *grid + shelf*
30
+ * - 자식 StorageRack 내부에서는 `0-0-${shelf-1}` (자체 cellId 형식). RackGrid 가 변환.
3
31
  */
4
-
5
32
  import {
6
- ApplicationContext,
7
- Component,
8
- ComponentNature,
9
- ContainerAbstract,
10
- Control,
11
- Layout,
12
- Model,
13
- POINT,
14
- Properties,
15
- sceneComponent,
16
- RealObject
33
+ ApplicationContext, Component, ComponentNature, ContainerAbstract, Control, Layout,
34
+ Model, POINT, Properties, RealObject, sceneComponent
17
35
  } from '@hatiolab/things-scene'
18
36
  import type { State } from '@hatiolab/things-scene'
19
37
  import * as THREE from 'three'
20
-
21
- /** RackGrid 컴포넌트 state */
22
- export interface RackGridState extends State {
23
- rows?: number
24
- columns?: number
25
- zone?: string
26
- shelves?: number
27
- locPattern?: string
28
- sectionDigits?: number
29
- unitDigits?: number
30
- shelfLocations?: string
31
- stockScale?: number
32
- hideRackFrame?: boolean
33
- legendTarget?: string
34
- hideEmptyStock?: boolean
35
- widths?: number[]
36
- heights?: number[]
37
- }
38
- import { RackGridCell } from './rack-grid-cell.js'
38
+ import {
39
+ Placeable,
40
+ SlotTarget,
41
+ type Alignment,
42
+ type Heights,
43
+ type PlacementArchetype,
44
+ type SlotRecord,
45
+ type SlottedHolder
46
+ } from '@operato/scene-base'
47
+
48
+ import StorageRack from './storage-rack.js'
49
+ import RackGridCell from './rack-grid-cell.js'
39
50
  import { RackGrid3D } from './rack-grid-3d.js'
40
- import { increaseLocation } from './rack-grid-location.js'
41
- import type { StockMaterialProvider } from './stock.js'
42
-
43
- const NATURE: ComponentNature = {
44
- mutable: false,
45
- resizable: true,
46
- rotatable: true,
47
- properties: [
48
- {
49
- type: 'number',
50
- label: 'rows',
51
- name: 'rows',
52
- property: 'rows'
53
- },
54
- {
55
- type: 'number',
56
- label: 'columns',
57
- name: 'columns',
58
- property: 'columns'
59
- },
60
- {
61
- type: 'string',
62
- label: 'zone',
63
- name: 'zone',
64
- property: 'zone'
65
- },
66
- {
67
- type: 'number',
68
- label: 'shelves',
69
- name: 'shelves',
70
- property: 'shelves'
71
- },
72
- {
73
- type: 'number',
74
- label: 'depth',
75
- name: 'depth',
76
- property: 'depth'
77
- },
78
- {
79
- type: 'string',
80
- label: 'location-pattern',
81
- name: 'locPattern',
82
- placeholder: '{z}{s}-{u}-{sh}'
83
- },
84
- {
85
- type: 'number',
86
- label: 'section-digits',
87
- name: 'sectionDigits',
88
- placeholder: '1, 2, 3, ...'
89
- },
90
- {
91
- type: 'number',
92
- label: 'unit-digits',
93
- name: 'unitDigits',
94
- placeholder: '1, 2, 3, ...'
95
- },
96
- {
97
- type: 'string',
98
- label: 'shelf-locations',
99
- name: 'shelfLocations',
100
- placeholder: '1,2,3,... / ,,,04'
101
- },
102
- {
103
- type: 'number',
104
- label: 'stock-scale',
105
- name: 'stockScale',
106
- property: {
107
- step: 0.01,
108
- min: 0,
109
- max: 1
110
- }
111
- },
112
- {
113
- type: 'checkbox',
114
- label: 'hide-rack-frame',
115
- name: 'hideRackFrame'
116
- },
117
- {
118
- type: 'id-input',
119
- label: 'legend-target',
120
- name: 'legendTarget',
121
- property: {
122
- component: 'legend'
123
- }
124
- },
125
- {
126
- type: 'checkbox',
127
- label: 'hide-empty-stock',
128
- name: 'hideEmptyStock'
129
- }
130
- ],
131
- help: 'scene/component/rack-grid'
132
- }
133
-
134
- type SIDE_KEY = 'all' | 'out' | 'left' | 'right' | 'top' | 'bottom' | 'leftright' | 'topbottom'
135
-
136
- const SIDES = {
137
- all: ['top', 'left', 'bottom', 'right'],
138
- out: ['top', 'left', 'bottom', 'right'],
139
- left: ['left'],
140
- right: ['right'],
141
- top: ['top'],
142
- bottom: ['bottom'],
143
- leftright: ['left', 'right'],
144
- topbottom: ['top', 'bottom']
145
- } as { [key: string]: SIDE_KEY[] }
146
-
147
- const CLEAR_STYLE = {
148
- strokeStyle: '',
149
- lineDash: 'solid',
150
- lineWidth: 0
151
- }
152
51
 
153
- const DEFAULT_STYLE = {
154
- strokeStyle: '#999',
155
- lineDash: 'solid',
156
- lineWidth: 1
52
+ // RackGridCell refid 충돌 회피 — load 시 동적 생성되는 cell 의 refid 가 *부모
53
+ // RackGrid + 모델 안 다른 컴포넌트* refid 와 겹치지 않도록 *높은 시작값 + monotonic
54
+ // counter*. hierarchy override 가 save 시 refid 제거 → 모델 파일엔 안 새겨짐.
55
+ let _cellRefidCounter = 100_000_000
56
+ function _nextCellRefid(): number {
57
+ return _cellRefidCounter++
157
58
  }
158
59
 
159
- const TABLE_LAYOUT = Layout.get('table')
160
-
161
60
  function buildNewCell(app: ApplicationContext) {
162
- return Model.compile(
61
+ const refid = _nextCellRefid()
62
+ const m: any = Model.compile(
163
63
  {
164
64
  type: 'rack-grid-cell',
165
- strokeStyle: 'black',
166
- fillStyle: 'transparent',
167
- left: 0,
168
- top: 0,
169
- width: 1,
170
- height: 1,
171
- textWrap: true,
172
- isEmpty: false,
173
- border: buildBorderStyle(DEFAULT_STYLE, 'all')
65
+ refid,
66
+ left: 0, top: 0, width: 1, height: 1, isEmpty: false
174
67
  },
175
68
  app
176
69
  )
70
+ // Model.compile 이 refid 를 자동 generator 로 덮어쓸 수 있어 *강제 재 set*.
71
+ try {
72
+ if (m.refid !== refid) {
73
+ m.refid = refid
74
+ m.state && (m.state.refid = refid)
75
+ m._state && (m._state.refid = refid)
76
+ }
77
+ } catch {}
78
+ return m
177
79
  }
178
80
 
179
- function buildCopiedCell(copy: any, app: ApplicationContext) {
180
- const obj = JSON.parse(JSON.stringify(copy))
181
- delete obj.text
182
-
183
- return Model.compile(obj, app)
184
- }
185
-
186
- function buildBorderStyle(style: any, where: SIDE_KEY): { [key: string]: any } {
187
- return (SIDES[where] || []).reduce((border: { [key: string]: any }, side: string) => {
188
- border[side] = style
189
- return border
190
- }, {} as { [key: string]: any })
191
- }
192
-
193
- function setCellBorder(cell: RackGridCell, style: any, where: SIDE_KEY) {
194
- if (!cell) {
195
- return
196
- }
197
-
198
- cell.set('border', Object.assign({}, cell.getState('border') || {}, buildBorderStyle(style, where)))
199
- }
200
-
201
- function isLeftMost(total: number, columns: number, indices: number[], i: number) {
202
- return i == 0 || !(i % columns) || indices.indexOf(i - 1) == -1
203
- }
204
-
205
- function isRightMost(total: number, columns: number, indices: number[], i: number) {
206
- return i == total - 1 || i % columns == columns - 1 || indices.indexOf(i + 1) == -1
207
- }
208
-
209
- function isTopMost(total: number, columns: number, indices: number[], i: number) {
210
- return i < columns || indices.indexOf(i - columns) == -1
211
- }
212
-
213
- function isBottomMost(total: number, columns: number, indices: number[], i: number) {
214
- return i > total - columns - 1 || indices.indexOf(i + columns) == -1
215
- }
216
-
217
- function above(columns: number, i: number) {
218
- return i - columns
219
- }
220
-
221
- function below(columns: number, i: number) {
222
- return i + columns
223
- }
224
-
225
- function before(columns: number, i: number) {
226
- return !(i % columns) ? -1 : i - 1
227
- }
228
-
229
- function after(columns: number, i: number) {
230
- return !((i + 1) % columns) ? -1 : i + 1
231
- }
81
+ const TABLE_LAYOUT = Layout.get('table')
232
82
 
233
- function array(value: any, size: number) {
234
- const arr = []
235
- for (let i = 0; i < size; i++) arr.push(1)
83
+ function arrayOf(value: number, size: number): number[] {
84
+ const arr: number[] = []
85
+ for (let i = 0; i < size; i++) arr.push(value)
236
86
  return arr
237
87
  }
238
88
 
239
89
  const columnControlHandler = {
240
- ondragmove: function (point: POINT, index: number, component: RackGrid) {
90
+ ondragmove(point: POINT, index: number, component: RackGrid) {
241
91
  const { left, top, width, height } = component.textBounds
242
92
  const widths_sum = component.widths_sum
243
-
244
93
  const widths = component.widths.slice()
245
94
 
246
- /* 컨트롤의 원래 위치를 구한다. */
247
- const origin_pos_unit = widths.slice(0, index + 1).reduce((sum: number, width: number) => sum + width, 0)
95
+ const origin_pos_unit = widths.slice(0, index + 1).reduce((sum, w) => sum + w, 0)
248
96
  const origin_offset = left + (origin_pos_unit / widths_sum) * width
249
97
 
250
- /*
251
- * point의 좌표는 부모 레이어 기준의 x, y 값이다.
252
- * 따라서, 도형의 회전을 감안한 좌표로의 변환이 필요하다.
253
- * Transcoord시에는 point좌표가 부모까지 transcoord되어있는 상태이므로,
254
- * 컴포넌트자신에 대한 transcoord만 필요하다.(마지막 파라미터를 false로).
255
- */
256
98
  const transcoorded = component.transcoordP2S(point.x, point.y)
257
99
  const diff = transcoorded.x - origin_offset
258
100
 
259
101
  let diff_unit = (diff / width) * widths_sum
260
-
261
- const min_width_unit = (widths_sum / width) * 5 // 5픽셀정도를 최소로
102
+ const min_width_unit = (widths_sum / width) * 5
262
103
 
263
104
  if (diff_unit < 0) diff_unit = -Math.min(widths[index] - min_width_unit, -diff_unit)
264
105
  else diff_unit = Math.min(widths[index + 1] - min_width_unit, diff_unit)
@@ -271,29 +112,20 @@ const columnControlHandler = {
271
112
  }
272
113
 
273
114
  const rowControlHandler = {
274
- ondragmove: function (point: POINT, index: number, component: RackGrid) {
115
+ ondragmove(point: POINT, index: number, component: RackGrid) {
275
116
  const { left, top, width, height } = component.textBounds
276
117
  const heights_sum = component.heights_sum
277
-
278
118
  const heights = component.heights.slice()
279
119
 
280
- /* 컨트롤의 원래 위치를 구한다. */
281
120
  index -= component.columns - 1
282
- const origin_pos_unit = heights.slice(0, index + 1).reduce((sum: number, height: number) => sum + height, 0)
121
+ const origin_pos_unit = heights.slice(0, index + 1).reduce((sum, h) => sum + h, 0)
283
122
  const origin_offset = top + (origin_pos_unit / heights_sum) * height
284
123
 
285
- /*
286
- * point의 좌표는 부모 레이어 기준의 x, y 값이다.
287
- * 따라서, 도형의 회전을 감안한 좌표로의 변환이 필요하다.
288
- * Transcoord시에는 point좌표가 부모까지 transcoord되어있는 상태이므로,
289
- * 컴포넌트자신에 대한 transcoord만 필요하다.(마지막 파라미터를 false로).
290
- */
291
124
  const transcoorded = component.transcoordP2S(point.x, point.y)
292
125
  const diff = transcoorded.y - origin_offset
293
126
 
294
127
  let diff_unit = (diff / height) * heights_sum
295
-
296
- const min_height_unit = (heights_sum / height) * 5 // 5픽셀정도를 최소로
128
+ const min_height_unit = (heights_sum / height) * 5
297
129
 
298
130
  if (diff_unit < 0) diff_unit = -Math.min(heights[index] - min_height_unit, -diff_unit)
299
131
  else diff_unit = Math.min(heights[index + 1] - min_height_unit, diff_unit)
@@ -305,690 +137,1359 @@ const rowControlHandler = {
305
137
  }
306
138
  }
307
139
 
308
- @sceneComponent('rack-grid')
309
- export default class RackGrid extends ContainerAbstract implements StockMaterialProvider {
310
- override get state(): RackGridState {
311
- return super.state as RackGridState
312
- }
140
+ // ─── Cell-level metadata (sparse override) ────────────────────────────────────
141
+
142
+ /** (col, row) 위치의 메타데이터. 명시되지 않은 위치는 *룰의 기본값* 적용 (= location 없음). */
143
+ export interface CellOverride {
144
+ /** Location 의 `{s}` 부분. unit 과 함께 명시되어야 location 부여됨. */
145
+ section?: string
146
+ /** Location 의 `{u}` 부분. section 과 함께 명시되어야 location 부여됨. */
147
+ unit?: string
148
+ /**
149
+ * 명시적 통로/공실 표시. 의미:
150
+ * - location 미부여 (이 cell 로는 외부 location key 매칭 불가)
151
+ * - increaseLocation 의 aisle row 로 인식 (skipNumbering 시 건너뜀)
152
+ * 자식 컴포넌트 종류는 무관 — Crane/AGV/forklift/비움 모두 OK.
153
+ */
154
+ isEmpty?: boolean
155
+ /** 4-side border style (top/left/bottom/right) — modeling 시각 용. */
156
+ border?: any
157
+ /** Grid merge — 시각 표현. */
158
+ merged?: boolean
159
+ rowspan?: number
160
+ colspan?: number
161
+ }
313
162
 
314
- _focused_cell?: RackGridCell
163
+ // ─── State ────────────────────────────────────────────────────────────────────
315
164
 
316
- // StockMaterialProvider 구현
317
- _stock_materials: THREE.Material[] = []
318
- _default_material?: THREE.Material
319
- _empty_material?: THREE.Material
320
- private _legendTarget?: Component
165
+ export interface RackGridState extends State {
166
+ /** Grid 가로 칸 수 (X 축). 칸 ≈ 1 column 의 StorageRack (또는 Crane 등). */
167
+ columns?: number
168
+ /** Grid 세로 칸 수 (Z 축, depth). 통상 1, double-deep rack 등은 2. */
169
+ rows?: number
170
+ /** 각 (col, row) 의 수직 shelf 수 (Y 축, level). 자식 StorageRack 의 levels 와 일치. */
171
+ shelves?: number
321
172
 
322
- get legendTarget(): Component | undefined {
323
- const { legendTarget } = this.state
173
+ /**
174
+ * shelf 시작 높이 (mm, 3D Y 축, 바닥부터). 미명시 0 (바닥 = 첫 shelf).
175
+ * 양수 시 그만큼 위로 올라가 *stocker port / conveyor 같은 컴포넌트가 들어갈 빈 공간*
176
+ * 확보. Frame uprights 는 바닥 ~ 천장 그대로.
177
+ */
178
+ shelfBaseHeight?: number
179
+
180
+ // ── Location 룰 (rack-table 컨벤션) ──
181
+ zone?: string
182
+ /** 기본 `'{z}{s}-{u}-{sh}'`. placeholders: {z} {s} {u} {sh}. */
183
+ locPattern?: string
184
+ /** Section 숫자 자리수 (zero-pad). 기본 2. */
185
+ sectionDigits?: number
186
+ /** Unit 숫자 자리수 (zero-pad). 기본 2. */
187
+ unitDigits?: number
188
+ /** Shelf label CSV. 예: '1,2,3' / ',,,04'. 빈 자리는 1-based 인덱스 default. */
189
+ shelfLocations?: string
190
+
191
+ /** Sparse cell metadata. key = posKey = `${col-1}-${row-1}` (0-based, 2 segments). */
192
+ cellOverrides?: { [posKey: string]: CellOverride }
193
+
194
+ /**
195
+ * Stock records — `{ cellId: 'col-row-shelf', type, ... }`. cellId 가 RackGrid 의
196
+ * cell 위치 (3 segments) 와 매칭되는 record 만 InstancedMesh instance 로 렌더링.
197
+ * Plan A 정신 — sparse 데이터, 자식 컴포넌트 없이 batched 시각화.
198
+ */
199
+ data?: Array<{ cellId: string; [key: string]: any }>
200
+
201
+ /**
202
+ * Popup 컴포넌트 id — 클릭 시 그 popup 을 invoke (anchor = 클릭된 cell 의 SlotTarget).
203
+ * 미명시 시 click event (`rack-grid-cell-click`) 만 emit.
204
+ */
205
+ popupRef?: string
206
+
207
+ // ── 시각화 옵션 ──
208
+ hideRackFrame?: boolean // post + beam 모두 숨김
209
+ hideHorizontalFrame?: boolean // 가로 frame (beam) 만 숨김 (post 유지)
210
+ legendTarget?: string
211
+ hideEmptyStock?: boolean
212
+ widths?: number[]
213
+ heights?: number[]
214
+ }
215
+
216
+ // ─── Nature ───────────────────────────────────────────────────────────────────
217
+ //
218
+ // `nature` 는 *static const* 가 아니라 *get nature()* — properties 의 widget event
219
+ // handler 안 `this` 가 *해당 RackGrid 인스턴스* 를 가리키도록 (화살표 함수가
220
+ // getter 의 `this` 를 capture). rack-table-cell 의 동일 패턴.
324
221
 
325
- if (!this._legendTarget && legendTarget) {
326
- this._legendTarget = this.root.findById?.(legendTarget)
327
- this._legendTarget?.on('change', this._onLegendChanged, this)
222
+ // ─── RackGrid ─────────────────────────────────────────────────────────────────
223
+
224
+ @sceneComponent('rack-grid')
225
+ export default class RackGrid
226
+ extends Placeable(ContainerAbstract)
227
+ implements SlottedHolder
228
+ {
229
+ declare state: RackGridState
230
+
231
+ static placement: PlacementArchetype = 'floor'
232
+ static align: Alignment = 'bottom'
233
+ static defaultDepth = (h: Heights) => h.ceiling - h.floor
234
+
235
+ get nature(): ComponentNature {
236
+ return {
237
+ mutable: false,
238
+ resizable: true,
239
+ rotatable: true,
240
+ properties: [
241
+ { type: 'number', label: 'columns', name: 'columns' },
242
+ { type: 'number', label: 'rows', name: 'rows' },
243
+ { type: 'number', label: 'shelves', name: 'shelves' },
244
+ { type: 'number', label: 'shelf-base-height', name: 'shelfBaseHeight' },
245
+ { type: 'string', label: 'zone', name: 'zone' },
246
+ { type: 'string', label: 'location-pattern', name: 'locPattern', placeholder: '{z}{s}-{u}-{sh}' },
247
+ { type: 'number', label: 'section-digits', name: 'sectionDigits' },
248
+ { type: 'number', label: 'unit-digits', name: 'unitDigits' },
249
+ { type: 'string', label: 'shelf-locations', name: 'shelfLocations', placeholder: '1,2,3 또는 ,,,04' },
250
+ {
251
+ // root.selected 의 RackGridCell 들 또는 *전체 grid* 에 채번 적용.
252
+ type: 'location-increase-pattern',
253
+ label: '',
254
+ name: '',
255
+ property: {
256
+ event: {
257
+ 'increase-location-pattern': (event: CustomEvent) => {
258
+ const { increasingDirection, skipNumbering, startSection, startUnit } = event.detail
259
+ const selected = (this.root as any)?.selected as RackGridCell[] | undefined
260
+ const keys = (selected || [])
261
+ .filter(c => (c as any)?.state?.type === 'rack-grid-cell')
262
+ .map(c => c.state.cellId)
263
+ .filter((k): k is string => !!k)
264
+ this.increaseLocation(keys, increasingDirection, skipNumbering, startSection, startUnit)
265
+ }
266
+ }
267
+ }
268
+ },
269
+ { type: 'checkbox', label: 'hide-rack-frame', name: 'hideRackFrame' },
270
+ { type: 'checkbox', label: 'hide-horizontal-frame', name: 'hideHorizontalFrame' },
271
+ { type: 'id-input', label: 'legend-target', name: 'legendTarget', property: { component: 'legend' } },
272
+ { type: 'checkbox', label: 'hide-empty-stock', name: 'hideEmptyStock' },
273
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef', property: { component: 'popup' } }
274
+ ],
275
+ help: 'scene/component/rack-grid'
328
276
  }
277
+ }
329
278
 
330
- // 하위호환: 자체 legendTarget이 없으면 부모(Visualizer) legendTarget 폴백
331
- if (!this._legendTarget) {
332
- let ancestor = this.parent as any
333
- while (ancestor) {
334
- if (ancestor.legendTarget) return ancestor.legendTarget
335
- ancestor = ancestor.parent
336
- }
337
- // 서비스 레지스트리 폴백: stock-hub의 legendTarget
338
- const stockHub = (this.root as any)?.getService?.('stock')
339
- if (stockHub?.legendTarget) return stockHub.legendTarget
279
+ get anchors() {
280
+ return []
281
+ }
282
+
283
+ /**
284
+ * Serialize 시 자식 RackGridCell 의 redundant 속성 제거:
285
+ * - 좌표/변환 (left/top/width/height/zPos/rotation/scale/translate) — table layout 이
286
+ * runtime 자동 결정. state 저장하면 layout 결과와 불일치 위험.
287
+ * - **refid** *동적 생성* 되는 자식이라 *부모의 refid 가 그대로 들어가면 충돌*
288
+ * ("Refid Index replaced" 경고). 자식의 refid 는 *load 시 새로 자동 부여*. (외부
289
+ * 참조용 *id* 는 사용자 명시 시만 유지)
290
+ *
291
+ * 유지: cellId, section, unit, isEmpty, border, shelfLocations 등 *own 데이터*.
292
+ */
293
+ get hierarchy(): any {
294
+ const base: any = super.hierarchy
295
+ if (base?.components && Array.isArray(base.components)) {
296
+ base.components = base.components.map((c: any) => {
297
+ if (!c || typeof c !== 'object') return c
298
+ const { left, top, width, height, zPos, rotation, scale, translate, refid, ...rest } = c
299
+ return rest
300
+ })
340
301
  }
302
+ return base
303
+ }
341
304
 
342
- return this._legendTarget
305
+ /** Focusible — children (RackGridCell) 도 editor 의 selection 가능 (rack-table 동일). */
306
+ get focusible() {
307
+ return false
343
308
  }
344
309
 
345
- get hideEmptyStock(): boolean {
346
- return !!this.getState('hideEmptyStock')
310
+ /** Lifecycle children 자동 생성. columns × rows 만큼 RackGridCell 자식. */
311
+ created() {
312
+ const tobeSize = this.columns * this.rackRows
313
+ const gap = ((this as any).size?.() ?? this.components.length) - tobeSize
314
+
315
+ if (gap === 0) {
316
+ // 자식 수는 맞음 — cellId 만 sync
317
+ this._syncChildCellIds()
318
+ return
319
+ } else if (gap > 0) {
320
+ ;(this as any).remove?.(this.components.slice(gap))
321
+ } else {
322
+ const newbies: any[] = []
323
+ for (let i = 0; i < -gap; i++) newbies.push(buildNewCell((this as any).app))
324
+ ;(this as any).add?.(newbies)
325
+ }
326
+
327
+ const widths = this.getState('widths')
328
+ const heights = this.getState('heights')
329
+ if (!widths || (widths as number[]).length < this.columns) this.set('widths', this.widths)
330
+ if (!heights || (heights as number[]).length < this.rackRows) this.set('heights', this.heights)
331
+
332
+ this._syncChildCellIds()
347
333
  }
348
334
 
349
- private _onLegendChanged() {
350
- this._resetMaterials()
351
- this.invalidate()
335
+ /** columns/rows 변경 시 children 재구성. rack-table.buildCells 패턴. */
336
+ onchange(after: Properties, _before: Properties) {
337
+ super.onchange?.(after, _before)
338
+ if ('columns' in after || 'rows' in after) {
339
+ const oldCols = (_before as any).columns ?? this.columns
340
+ const oldRows = (_before as any).rows ?? this.rackRows
341
+ this._buildCells(this.rackRows, this.columns, oldRows, oldCols)
342
+ }
343
+ // 룰 관련 변경 → location 역인덱스 무효화
344
+ if ('locPattern' in after || 'sectionDigits' in after || 'unitDigits' in after ||
345
+ 'shelfLocations' in after || 'zone' in after || 'shelves' in after) {
346
+ this.invalidateLocationIndex()
347
+ }
348
+ // stock 시각 영향 속성 → InstancedMesh 재빌드
349
+ if ('data' in after || 'hideEmptyStock' in after || 'shelves' in after ||
350
+ 'shelfBaseHeight' in after || 'width' in after || 'depth' in after ||
351
+ 'height' in after || 'columns' in after || 'rows' in after) {
352
+ ;(this._realObject as any)?.rebuildStockMesh?.()
353
+ }
354
+ // hideRackFrame / hideHorizontalFrame 변경 → frame visibility 즉시 토글
355
+ if ('hideRackFrame' in after || 'hideHorizontalFrame' in after) {
356
+ ;(this._realObject as any)?.applyFrameVisibility?.()
357
+ }
352
358
  }
353
359
 
354
- private _resetMaterials() {
355
- this._stock_materials.forEach(m => m.dispose?.())
356
- this._stock_materials = []
357
- delete this._default_material
358
- delete this._empty_material
360
+ /** state.data 변경 시 호출 (things-scene 의 onchange<PropName>). */
361
+ onchangeData(): void {
362
+ ;(this._realObject as any)?.rebuildStockMesh?.()
359
363
  }
360
- buildRealObject(): RealObject | undefined {
361
- return new RackGrid3D(this)
364
+
365
+ /** state.data 의 records — Plan A 의 stock 보관소. */
366
+ get records(): Array<{ cellId: string; [key: string]: any }> {
367
+ return (this.state.data as any) ?? []
362
368
  }
363
369
 
364
- dispose() {
365
- this._legendTarget?.off('change', this._onLegendChanged, this)
366
- delete this._legendTarget
367
- this._resetMaterials()
370
+ // ── Legend integration ──────────────────────────────────
371
+
372
+ private _legendTarget?: Component
368
373
 
369
- super.dispose()
374
+ /**
375
+ * Legend 컴포넌트 lookup. 우선순위:
376
+ * 1) state.legendTarget id 명시
377
+ * 2) scene 전체 의 type='legend' 첫 번째 컴포넌트 (자동 발견)
378
+ */
379
+ get legendTarget(): Component | undefined {
380
+ if (this._legendTarget) return this._legendTarget
381
+
382
+ const id = this.state.legendTarget
383
+ if (id) {
384
+ const found = (this.root as any)?.findById?.(id) as Component | undefined
385
+ if (found) {
386
+ this._legendTarget = found
387
+ ;(found as any).on?.('change', this._onLegendChanged, this)
388
+ return found
389
+ }
390
+ }
370
391
 
371
- delete this._focused_cell
392
+ const visit = (node: any): Component | undefined => {
393
+ if (!node) return undefined
394
+ if (node.state?.type === 'legend') return node as Component
395
+ const children = node.components as Component[] | undefined
396
+ if (children) {
397
+ for (const c of children) {
398
+ const r = visit(c)
399
+ if (r) return r
400
+ }
401
+ }
402
+ return undefined
403
+ }
404
+ const found = visit(this.root)
405
+ if (found) {
406
+ this._legendTarget = found
407
+ ;(found as any).on?.('change', this._onLegendChanged, this)
408
+ }
409
+ return found
372
410
  }
373
411
 
374
- created() {
375
- const tobeSize = this.rows * this.columns
376
- const gap = this.size() - tobeSize
412
+ private _onLegendChanged = (): void => {
413
+ ;(this._realObject as any)?.rebuildStockMesh?.()
414
+ }
377
415
 
378
- if (gap == 0) {
379
- return
380
- } else if (gap > 0) {
381
- let removals = this.components.slice(gap)
382
- this.remove(removals)
383
- } else {
384
- let newbies = []
416
+ /**
417
+ * record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
418
+ * - `range.value === recordValue` (카테고리 일치)
419
+ * - `range.min Number(v) < range.max` (수치 범위)
420
+ * - 매칭 없으면 `defaultColor` 또는 undefined
421
+ */
422
+ // ── View-mode click → rack-grid-cell-click event + popup ──
385
423
 
386
- for (let i = 0; i < -gap; i++) newbies.push(buildNewCell(this.app))
424
+ get eventMap() {
425
+ return {
426
+ '(self)': {
427
+ '(self)': {
428
+ click: this._onViewClick
429
+ }
430
+ }
431
+ }
432
+ }
387
433
 
388
- this.add(newbies)
434
+ private _onViewClick = (mouseEvent: MouseEvent) => {
435
+ if (!(this as any).app?.isViewMode) return
436
+ const ro: any = (this as any)._realObject
437
+ if (!ro?.object3d) return
438
+
439
+ const hit = this._raycastHit(mouseEvent)
440
+ if (!hit) return
441
+
442
+ const stockMesh: THREE.InstancedMesh | undefined = ro.stockMesh
443
+ let cellId: string | undefined
444
+ let record: any
445
+ let isStock = false
446
+ let instanceId: number | undefined
447
+
448
+ if (hit.object === stockMesh) {
449
+ const records: any[] | undefined = (stockMesh as any).userData?._records
450
+ record = records?.[hit.instanceId ?? -1]
451
+ cellId = record?.cellId
452
+ isStock = true
453
+ instanceId = hit.instanceId
454
+ } else {
455
+ const cid = this._cellIdFromWorldPoint(hit.point)
456
+ if (!cid || cid.startsWith('out-of-bounds')) return
457
+ cellId = cid
458
+ const data = this.state.data as Array<any> | undefined
459
+ record = Array.isArray(data) ? data.find(r => r?.cellId === cid) : undefined
389
460
  }
390
461
 
391
- const widths = this.getState('widths')
392
- const heights = this.getState('heights')
462
+ const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
463
+ this.trigger('rack-grid-cell-click', payload)
464
+ this._invokePopup(cellId, record)
393
465
 
394
- if (!widths || widths.length < this.columns) this.set('widths', this.widths)
395
- if (!heights || heights.length < this.rows) this.set('heights', this.heights)
466
+ // popup 외부 click 으로 인식되어 자동 close 되는 회귀 차단
467
+ mouseEvent.stopPropagation?.()
396
468
  }
397
469
 
398
- // 컴포넌트를 임의로 추가 삭제할 있는 지를 지정하는 속성임.
399
- get focusible() {
400
- return false
470
+ /** state.popupRef Popup 컴포넌트 invoke. anchor = 클릭된 cell SlotTarget. */
471
+ private _invokePopup(cellId: string | undefined, record: any): void {
472
+ const popupRefId = this.state.popupRef
473
+ if (!popupRefId || !cellId) return
474
+ const popupComp: any = (this.root as any)?.findById?.(popupRefId)
475
+ if (!popupComp || typeof popupComp.openPopup !== 'function') {
476
+ console.warn(`[rack-grid] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
477
+ return
478
+ }
479
+ const anchor = this.slotTargetAt(cellId)
480
+ popupComp.openPopup(record ?? { cellId }, { anchor })
401
481
  }
402
482
 
403
- get widths(): number[] {
404
- const widths = this.getState('widths')
483
+ /** raycast 우리 RackGrid 의 어떤 mesh 가 closest hit 인지. */
484
+ private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
485
+ const ro: any = (this as any)._realObject
486
+ if (!ro?.object3d) return undefined
487
+ const tc: any = ro.threeContainer
488
+ if (!tc) return undefined
489
+
490
+ const cap: any = tc._threeCapability ?? tc._capability
491
+ let intersects: THREE.Intersection[] | undefined
492
+ if (cap?.getObjectsByRaycast) {
493
+ intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
494
+ }
405
495
 
406
- if (!widths) return array(1, this.columns)
496
+ if (!intersects || intersects.length === 0) {
497
+ const scene = tc.scene3d as THREE.Scene | undefined
498
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
499
+ const camera =
500
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
501
+ (cap?.activeCamera as THREE.Camera | undefined) ??
502
+ (cap?.camera as THREE.Camera | undefined)
503
+ const canvas = renderer?.domElement
504
+ if (!scene || !canvas || !camera) return undefined
505
+ const rect = canvas.getBoundingClientRect()
506
+ if (rect.width === 0 || rect.height === 0) return undefined
507
+ const ndc = new THREE.Vector2(
508
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
509
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
510
+ )
511
+ const raycaster = new THREE.Raycaster()
512
+ raycaster.setFromCamera(ndc, camera)
513
+ intersects = raycaster.intersectObjects(scene.children, true)
514
+ }
515
+ if (!intersects || intersects.length === 0) return undefined
407
516
 
408
- if (widths.length < this.columns) return widths.concat(array(1, this.columns - widths.length))
409
- else if (widths.length > this.columns) return widths.slice(0, this.columns)
517
+ const closest = intersects[0]
518
+ let obj: THREE.Object3D | null = closest.object
519
+ while (obj) {
520
+ if (obj.userData?.context === ro) return closest
521
+ obj = obj.parent
522
+ }
523
+ return undefined
524
+ }
410
525
 
411
- return widths
526
+ /** world point → cellId (col-row-shelf) 역산. */
527
+ private _cellIdFromWorldPoint(worldPoint: THREE.Vector3): string | null {
528
+ const ro: any = (this as any)._realObject
529
+ if (!ro?.object3d) return null
530
+
531
+ const local = new THREE.Vector3().copy(worldPoint)
532
+ const mInv = new THREE.Matrix4().copy(ro.object3d.matrixWorld).invert()
533
+ local.applyMatrix4(mInv)
534
+
535
+ const cols = this.columns
536
+ const rows = this.rackRows
537
+ const shelves = this.shelves
538
+ const width = (this.state.width as number) || 1000
539
+ const height = (this.state.depth as number) || 2000 // 3D Y
540
+ const depth = (this.state.height as number) || 200 // 3D Z
541
+ const shelfBase = (this.state.shelfBaseHeight as number) || 0
542
+ const shelfZone = height - shelfBase
543
+
544
+ const bayW = width / cols
545
+ const bayD = depth / rows
546
+ const cellY = shelfZone / shelves
547
+
548
+ // center-based: X [-width/2, +width/2], Y [-height/2, +height/2], Z [-depth/2, +depth/2]
549
+ const col = Math.floor((local.x + width / 2) / bayW)
550
+ const yFromBottom = local.y + height / 2 - shelfBase
551
+ const shelf = Math.floor(yFromBottom / cellY)
552
+ const row = Math.floor((local.z + depth / 2) / bayD)
553
+
554
+ if (col < 0 || col >= cols || row < 0 || row >= rows || shelf < 0 || shelf >= shelves) {
555
+ return `out-of-bounds(col=${col},row=${row},shelf=${shelf})`
556
+ }
557
+ return `${col}-${row}-${shelf}`
412
558
  }
413
559
 
414
- get heights(): number[] {
415
- const heights = this.getState('heights')
560
+ resolveLegendColor(record: any): string | undefined {
561
+ const legend = this.legendTarget
562
+ if (!legend) return undefined
563
+ const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
564
+ if (!status) return undefined
416
565
 
417
- if (!heights) return array(1, this.rows)
566
+ const field = status.field as string | undefined
567
+ const ranges = status.ranges as any[] | undefined
568
+ if (!field || !Array.isArray(ranges)) return undefined
418
569
 
419
- if (heights.length < this.rows) return heights.concat(array(1, this.rows - heights.length))
420
- else if (heights.length > this.rows) return heights.slice(0, this.rows)
570
+ const value = record?.[field]
571
+ if (value === undefined || value === null) return status.defaultColor
421
572
 
422
- return heights
573
+ for (const range of ranges) {
574
+ if (!range) continue
575
+ if (range.value !== undefined) {
576
+ if (range.value === value) return range.color
577
+ continue
578
+ }
579
+ const num = Number(value)
580
+ if (!Number.isFinite(num)) continue
581
+ const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
582
+ const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
583
+ const minOk = min === undefined || num >= min
584
+ const maxOk = max === undefined || num < max
585
+ if (minOk && maxOk) return range.color
586
+ }
587
+ return status.defaultColor as string | undefined
423
588
  }
424
589
 
425
- buildCells(newrows: number, newcolumns: number, oldrows: number, oldcolumns: number) {
590
+ /**
591
+ * 새 (rows × columns) 에 맞춰 children 재구성. rack-table.buildCells 정확 클론.
592
+ */
593
+ private _buildCells(newrows: number, newcolumns: number, oldrows: number, oldcolumns: number) {
594
+ const app = (this as any).app
426
595
  if (newrows < oldrows) {
427
- let removals = this.components.slice(oldcolumns * newrows)
428
- this.remove(removals)
596
+ const removals = this.components.slice(oldcolumns * newrows)
597
+ ;(this as any).remove?.(removals)
429
598
  }
430
-
431
599
  const minrows = Math.min(newrows, oldrows)
432
-
433
600
  if (newcolumns > oldcolumns) {
434
601
  for (let r = 0; r < minrows; r++) {
435
602
  for (let c = oldcolumns; c < newcolumns; c++) {
436
- this.insertComponentAt(buildNewCell(this.app), r * newcolumns + c)
603
+ ;(this as any).insertComponentAt?.(buildNewCell(app), r * newcolumns + c)
437
604
  }
438
605
  }
439
606
  } else if (newcolumns < oldcolumns) {
440
- let removals = []
441
-
607
+ const removals: any[] = []
442
608
  for (let r = 0; r < minrows; r++) {
443
609
  for (let c = newcolumns; c < oldcolumns; c++) {
444
610
  removals.push(this.components[r * oldcolumns + c])
445
611
  }
446
612
  }
447
- this.remove(removals)
613
+ ;(this as any).remove?.(removals)
448
614
  }
449
-
450
615
  if (newrows > oldrows) {
451
- let newbies = []
452
-
616
+ const newbies: any[] = []
453
617
  for (let r = oldrows; r < newrows; r++) {
454
- for (let i = 0; i < newcolumns; i++) {
455
- newbies.push(buildNewCell(this.app))
456
- }
618
+ for (let i = 0; i < newcolumns; i++) newbies.push(buildNewCell(app))
457
619
  }
458
- this.add(newbies)
620
+ ;(this as any).add?.(newbies)
459
621
  }
622
+ this.set({ widths: this.widths, heights: this.heights })
623
+ this._syncChildCellIds()
624
+ }
460
625
 
461
- this.set({
462
- widths: this.widths,
463
- heights: this.heights
464
- })
626
+ /** children 의 state.cellId 가 *순서 기반 bayKey* 와 일치하도록 동기화. */
627
+ private _syncChildCellIds() {
628
+ const cols = this.columns
629
+ const children = this.components
630
+ for (let i = 0; i < children.length; i++) {
631
+ const col = i % cols
632
+ const row = Math.floor(i / cols)
633
+ const bayKey = `${col}-${row}`
634
+ const c: any = children[i]
635
+ if (c?.state?.cellId !== bayKey) c?.set?.('cellId', bayKey)
636
+ }
465
637
  }
466
638
 
467
- get layout(): any {
468
- return TABLE_LAYOUT
639
+ /** Row 인덱스의 cell 들. rack-table-cell 의 rowCells 가 사용. */
640
+ getCellsByRow(row: number): Component[] {
641
+ return this.components.slice(row * this.columns, (row + 1) * this.columns)
642
+ }
643
+ /** Column 인덱스의 cell 들. */
644
+ getCellsByColumn(column: number): Component[] {
645
+ const out: Component[] = []
646
+ for (let i = 0; i < this.rackRows; i++) out.push(this.components[this.columns * i + column])
647
+ return out
469
648
  }
470
649
 
471
- get rows() {
472
- return this.getState('rows')
650
+ /** (col, row) 위치의 cell-component. */
651
+ cellAt(col: number, row: number = 0): RackGridCell | null {
652
+ const idx = row * this.columns + col
653
+ return (this.components[idx] as any) ?? null
473
654
  }
474
655
 
475
- setCellsStyle(cells: RackGridCell[], style: any, where: string) {
476
- const components = this.components
477
- const total = components.length
478
- const columns = this.getState('columns')
656
+ /** bayKey cell-component. */
657
+ cellAtBayKey(bayKey: string): RackGridCell | null {
658
+ const parsed = this.parsePosKey(bayKey)
659
+ if (!parsed) return null
660
+ return this.cellAt(parsed.col, parsed.row)
661
+ }
479
662
 
480
- // 병합된 셀도 포함시킨다.
481
- const _cells = [] as RackGridCell[]
482
- cells.forEach(c => {
483
- _cells.push(c)
484
- if (c.colspan || c.rowspan) {
485
- let col = this.getRowColumn(c).column
486
- let row = this.getRowColumn(c).row
487
- for (let i = row; i < row + c.rowspan; i++)
488
- for (let j = col; j < col + c.colspan; j++)
489
- if (i != row || j != col) _cells.push(this.components[i * this.columns + j] as RackGridCell)
490
- }
491
- })
663
+ /**
664
+ * (col, row) bay *isEmpty* 인가. cell-component 의 state.isEmpty 가 source of
665
+ * truth, 없으면 cellOverrides[posKey].isEmpty fallback.
666
+ */
667
+ isBayEmpty(col: number, row: number = 0): boolean {
668
+ const cell = this.cellAt(col, row)
669
+ if (cell) return !!cell.state.isEmpty
670
+ const posKey = `${col}-${row}`
671
+ return !!this.cellOverrides[posKey]?.isEmpty
672
+ }
492
673
 
493
- const indices = _cells.map(cell => components.indexOf(cell))
494
- indices.forEach(i => {
495
- const cell = components[i] as RackGridCell
496
-
497
- switch (where) {
498
- case 'all':
499
- setCellBorder(cell, style, where)
500
-
501
- if (isLeftMost(total, columns, indices, i))
502
- setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
503
- if (isRightMost(total, columns, indices, i))
504
- setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
505
- if (isTopMost(total, columns, indices, i))
506
- setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
507
- if (isBottomMost(total, columns, indices, i))
508
- setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
509
- break
510
- case 'in':
511
- if (!isLeftMost(total, columns, indices, i)) {
512
- setCellBorder(cell, style, 'left')
513
- }
514
- if (!isRightMost(total, columns, indices, i)) {
515
- setCellBorder(cell, style, 'right')
516
- }
517
- if (!isTopMost(total, columns, indices, i)) {
518
- setCellBorder(cell, style, 'top')
519
- }
520
- if (!isBottomMost(total, columns, indices, i)) {
521
- setCellBorder(cell, style, 'bottom')
522
- }
523
- break
524
- case 'out':
525
- if (isLeftMost(total, columns, indices, i)) {
526
- setCellBorder(cell, style, 'left')
527
- setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
528
- }
529
- if (isRightMost(total, columns, indices, i)) {
530
- setCellBorder(cell, style, 'right')
531
- setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
532
- }
533
- if (isTopMost(total, columns, indices, i)) {
534
- setCellBorder(cell, style, 'top')
535
- setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
536
- }
537
- if (isBottomMost(total, columns, indices, i)) {
538
- setCellBorder(cell, style, 'bottom')
539
- setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
540
- }
541
- break
542
- case 'left':
543
- if (isLeftMost(total, columns, indices, i)) {
544
- setCellBorder(cell, style, 'left')
545
- setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
546
- }
547
- break
548
- case 'right':
549
- if (isRightMost(total, columns, indices, i)) {
550
- setCellBorder(cell, style, 'right')
551
- setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
552
- }
553
- break
554
- case 'center':
555
- if (!isLeftMost(total, columns, indices, i)) {
556
- setCellBorder(cell, style, 'left')
557
- }
558
- if (!isRightMost(total, columns, indices, i)) {
559
- setCellBorder(cell, style, 'right')
560
- }
561
- break
562
- case 'middle':
563
- if (!isTopMost(total, columns, indices, i)) {
564
- setCellBorder(cell, style, 'top')
565
- }
566
- if (!isBottomMost(total, columns, indices, i)) {
567
- setCellBorder(cell, style, 'bottom')
568
- }
569
- break
570
- case 'top':
571
- if (isTopMost(total, columns, indices, i)) {
572
- setCellBorder(cell, style, 'top')
573
- setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
574
- }
575
- break
576
- case 'bottom':
577
- if (isBottomMost(total, columns, indices, i)) {
578
- setCellBorder(cell, style, 'bottom')
579
- setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
580
- }
581
- break
582
- case 'clear':
583
- setCellBorder(cell, CLEAR_STYLE, 'all')
584
-
585
- if (isLeftMost(total, columns, indices, i))
586
- setCellBorder(components[before(columns, i)] as RackGridCell, CLEAR_STYLE, 'right')
587
- if (isRightMost(total, columns, indices, i))
588
- setCellBorder(components[after(columns, i)] as RackGridCell, CLEAR_STYLE, 'left')
589
- if (isTopMost(total, columns, indices, i))
590
- setCellBorder(components[above(columns, i)] as RackGridCell, CLEAR_STYLE, 'bottom')
591
- if (isBottomMost(total, columns, indices, i))
592
- setCellBorder(components[below(columns, i)] as RackGridCell, CLEAR_STYLE, 'top')
593
- }
594
- })
674
+ buildRealObject(): RealObject | undefined {
675
+ return new RackGrid3D(this)
595
676
  }
596
677
 
597
- getRowColumn(cell: RackGridCell) {
598
- const idx = this.components.indexOf(cell)
678
+ // ── Table layout — 자식 자동 배치 + columns/rows 의 가변 widths/heights ──
599
679
 
600
- return {
601
- column: idx % this.columns,
602
- row: Math.floor(idx / this.columns)
603
- }
680
+ get layout(): any {
681
+ return TABLE_LAYOUT
604
682
  }
605
683
 
606
- getCellsByRow(row: number) {
607
- return this.components.slice(row * this.columns, (row + 1) * this.columns)
684
+ get widths(): number[] {
685
+ const widths = this.getState('widths') as number[] | undefined
686
+ if (!widths) return arrayOf(1, this.columns)
687
+ if (widths.length < this.columns) return widths.concat(arrayOf(1, this.columns - widths.length))
688
+ if (widths.length > this.columns) return widths.slice(0, this.columns)
689
+ return widths
608
690
  }
609
691
 
610
- getCellsByColumn(column: number) {
611
- const cells = []
612
- for (let i = 0; i < this.rows; i++) cells.push(this.components[this.columns * i + column])
613
-
614
- return cells
692
+ get heights(): number[] {
693
+ const heights = this.getState('heights') as number[] | undefined
694
+ if (!heights) return arrayOf(1, this.rackRows)
695
+ if (heights.length < this.rackRows) return heights.concat(arrayOf(1, this.rackRows - heights.length))
696
+ if (heights.length > this.rackRows) return heights.slice(0, this.rackRows)
697
+ return heights
615
698
  }
616
699
 
617
- deleteRows(cells: RackGridCell[]) {
618
- // 먼저 cells 위치의 행을 구한다.
619
- let rows = [] as number[]
700
+ get widths_sum(): number {
701
+ return this.widths.reduce((sum, w) => sum + w, 0) || this.columns
702
+ }
620
703
 
621
- cells.forEach(cell => {
622
- let row = this.getRowColumn(cell).row
623
- if (-1 == rows.indexOf(row)) {
624
- rows.push(row)
625
- }
626
- })
704
+ get heights_sum(): number {
705
+ return this.heights.reduce((sum, h) => sum + h, 0) || this.rackRows
706
+ }
627
707
 
628
- rows.sort((a, b) => {
629
- return a - b
630
- })
708
+ /** Column / Row 사이 드래그 핸들 — editor 의 widths/heights 가변 조절. */
709
+ get controls(): Array<Control> | undefined {
710
+ const widths = this.widths
711
+ const heights = this.heights
712
+ const inside = this.textBounds
713
+ const width_unit = inside.width / this.widths_sum
714
+ const height_unit = inside.height / this.heights_sum
631
715
 
632
- rows.reverse()
716
+ const controls: Array<Control> = []
633
717
 
634
- const heights = this.heights.slice()
635
- rows.forEach(row => {
636
- this.remove(this.getCellsByRow(row))
718
+ let x = inside.left
719
+ widths.slice(0, this.columns - 1).forEach(w => {
720
+ x += w * width_unit
721
+ controls.push({ x, y: inside.top, handler: columnControlHandler })
637
722
  })
638
723
 
639
- rows.forEach(row => heights.splice(row, 1))
640
-
641
- this.model.rows -= rows.length // 고의적으로, change 이벤트가 발생하지 않도록 set(..)을 사용하지 않음.
642
- this.set('heights', heights)
643
- }
644
-
645
- deleteColumns(cells: RackGridCell[]) {
646
- // 먼저 cells 위치의 열을 구한다.
647
- let columns = [] as number[]
648
- cells.forEach(cell => {
649
- let column = this.getRowColumn(cell).column
650
- if (-1 == columns.indexOf(column)) columns.push(column)
651
- })
652
- columns.sort((a, b) => {
653
- return a - b
654
- })
655
- columns.reverse()
656
-
657
- columns.forEach(column => {
658
- const widths = this.widths.slice()
659
- this.remove(this.getCellsByColumn(column))
660
- widths.splice(column, 1)
661
- this.model.columns -= 1 // 고의적으로, change 이벤트가 발생하지 않도록 set(..)을 사용하지 않음.
662
- this.set('widths', widths)
724
+ let y = inside.top
725
+ heights.slice(0, this.rackRows - 1).forEach(h => {
726
+ y += h * height_unit
727
+ controls.push({ x: inside.left, y, handler: rowControlHandler })
663
728
  })
664
- }
665
729
 
666
- insertCellsAbove(cells: RackGridCell[]) {
667
- // 먼저 cells 위치의 행을 구한다.
668
- let rows = [] as number[]
669
- cells.forEach(cell => {
670
- let row = this.getRowColumn(cell).row
671
- if (-1 == rows.indexOf(row)) rows.push(row)
672
- })
673
- rows.sort((a, b) => {
674
- return a - b
675
- })
676
- rows.reverse()
677
- // 행 2개 이상은 추가 안함. 임시로 막아놓음
678
- if (rows.length >= 2) return false
679
- let insertionRowPosition = rows[0]
680
- let newbieRowHeights = [] as number[]
681
- let newbieCells = [] as RackGridCell[]
682
- rows.forEach(row => {
683
- for (let i = 0; i < this.columns; i++)
684
- newbieCells.push(buildCopiedCell(this.components[row * this.columns + i].model, this.app) as RackGridCell)
685
-
686
- newbieRowHeights.push(this.heights[row])
687
-
688
- newbieCells.reverse().forEach(cell => {
689
- this.insertComponentAt(cell, insertionRowPosition * this.columns)
690
- })
730
+ return controls
731
+ }
691
732
 
692
- let heights = this.heights.slice()
693
- heights.splice(insertionRowPosition, 0, ...newbieRowHeights)
694
- this.set('heights', heights)
733
+ // ── 2D 렌더링 — table layout 의 widths/heights 기반 격자 ─────
734
+ //
735
+ // 자식 컴포넌트 가 자기 렌더링하지만 *비어있는 grid 도 modeling 시 가시화*. 격자
736
+ // 좌표는 widths/heights (table layout 의 가변 분할) 기반. cellOverrides 의
737
+ // isEmpty (사선) + border (4-side) 도 표시.
695
738
 
696
- this.model.rows += rows.length
739
+ render(ctx: CanvasRenderingContext2D) {
740
+ // 2D 에서는 hideRackFrame 이어도 *외곽 + 격자선 + cell 마커* 는 항상 그림.
741
+ // (2D 의 frame 은 시각적 *분할선* 이라 frame mesh 와 의미가 다름)
742
+ const inside = this.textBounds
743
+ const left = inside.left
744
+ const top = inside.top
745
+ const width = inside.width
746
+ const height = inside.height
747
+ const cols = this.columns
748
+ const rows = this.rackRows
749
+ const widths = this.widths
750
+ const heights = this.heights
751
+ const wSum = this.widths_sum
752
+ const hSum = this.heights_sum
753
+ const fill = (this.state.fillStyle as string) || '#e8e8ec'
754
+ const stroke = (this.state.strokeStyle as string) || '#888'
755
+ const lineWidth = (this.state.lineWidth as number) || 1
756
+ const overrides = this.cellOverrides
757
+
758
+ // column 별 X 좌표 (좌→우 누적)
759
+ const xs: number[] = [left]
760
+ for (let c = 0; c < cols; c++) xs.push(xs[c] + (widths[c] / wSum) * width)
761
+ // row 별 Y 좌표 (상→하 누적)
762
+ const ys: number[] = [top]
763
+ for (let r = 0; r < rows; r++) ys.push(ys[r] + (heights[r] / hSum) * height)
764
+
765
+ // Fill (반투명) — 외곽 영역
766
+ ctx.save()
767
+ ctx.fillStyle = fill
768
+ ctx.globalAlpha = 0.2
769
+ ctx.fillRect(left, top, width, height)
770
+ ctx.restore()
771
+
772
+ // isEmpty cell 표시 (사선 + 회색 fill)
773
+ ctx.save()
774
+ for (const [posKey, ov] of Object.entries(overrides)) {
775
+ if (!ov.isEmpty) continue
776
+ const parsed = this.parsePosKey(posKey)
777
+ if (!parsed) continue
778
+ if (parsed.col < 0 || parsed.col >= cols || parsed.row < 0 || parsed.row >= rows) continue
779
+ const cl = xs[parsed.col]
780
+ const ct = ys[parsed.row]
781
+ const cw = xs[parsed.col + 1] - cl
782
+ const ch = ys[parsed.row + 1] - ct
783
+ ctx.fillStyle = '#cccccc'
784
+ ctx.globalAlpha = 0.5
785
+ ctx.fillRect(cl, ct, cw, ch)
786
+ ctx.globalAlpha = 1
787
+ ctx.strokeStyle = '#999'
788
+ ctx.lineWidth = 1
789
+ ctx.beginPath()
790
+ ctx.moveTo(cl, ct); ctx.lineTo(cl + cw, ct + ch)
791
+ ctx.moveTo(cl + cw, ct); ctx.lineTo(cl, ct + ch)
792
+ ctx.stroke()
793
+ }
794
+ ctx.restore()
795
+
796
+ // 외곽 + 격자
797
+ ctx.save()
798
+ ctx.strokeStyle = stroke
799
+ ctx.lineWidth = lineWidth
800
+ ctx.strokeRect(left, top, width, height)
801
+ ctx.beginPath()
802
+ for (let c = 1; c < cols; c++) {
803
+ ctx.moveTo(xs[c], top); ctx.lineTo(xs[c], top + height)
804
+ }
805
+ for (let r = 1; r < rows; r++) {
806
+ ctx.moveTo(left, ys[r]); ctx.lineTo(left + width, ys[r])
807
+ }
808
+ ctx.stroke()
809
+ ctx.restore()
810
+
811
+ // cell 별 명시 border
812
+ for (const [posKey, ov] of Object.entries(overrides)) {
813
+ if (!ov.border) continue
814
+ const parsed = this.parsePosKey(posKey)
815
+ if (!parsed) continue
816
+ if (parsed.col < 0 || parsed.col >= cols || parsed.row < 0 || parsed.row >= rows) continue
817
+ const cl = xs[parsed.col]
818
+ const ct = ys[parsed.row]
819
+ const cr = xs[parsed.col + 1]
820
+ const cb = ys[parsed.row + 1]
821
+ const b = ov.border
822
+ const drawSide = (style: any, x1: number, y1: number, x2: number, y2: number) => {
823
+ if (!style?.strokeStyle) return
824
+ ctx.save()
825
+ ctx.strokeStyle = style.strokeStyle
826
+ ctx.lineWidth = style.lineWidth ?? 1
827
+ ctx.beginPath()
828
+ ctx.moveTo(x1, y1); ctx.lineTo(x2, y2)
829
+ ctx.stroke()
830
+ ctx.restore()
831
+ }
832
+ drawSide(b.top, cl, ct, cr, ct)
833
+ drawSide(b.left, cl, ct, cl, cb)
834
+ drawSide(b.bottom, cl, cb, cr, cb)
835
+ drawSide(b.right, cr, ct, cr, cb)
836
+ }
697
837
 
698
- this.clearCache()
699
- })
838
+ // Selected bay 강조는 *rack-grid-cell 자체* 의 render 가 처리 (framework 의
839
+ // selection 시스템이 cell 의 outline 자동 그림). RackGrid 는 grid 골격만.
700
840
  }
701
841
 
702
- insertCellsBelow(cells: RackGridCell[]) {
703
- // 먼저 cells 위치의 행을 구한다.
704
- let rows = [] as number[]
705
- cells.forEach(cell => {
706
- let row = this.getRowColumn(cell).row
707
- if (-1 == rows.indexOf(row)) rows.push(row)
708
- })
709
- rows.sort((a, b) => {
710
- return a - b
711
- })
712
- rows.reverse()
713
- // 행 2개 이상은 추가 안함. 임시로 막아놓음
714
- if (rows.length >= 2) return false
715
- let insertionRowPosition = rows[rows.length - 1] + 1
716
- let newbieRowHeights = [] as number[]
717
- let newbieCells = [] as RackGridCell[]
718
- rows.forEach(row => {
719
- for (let i = 0; i < this.columns; i++)
720
- newbieCells.push(buildCopiedCell(this.components[row * this.columns + i].model, this.app) as RackGridCell)
721
- newbieRowHeights.push(this.heights[row])
722
-
723
- newbieCells.reverse().forEach(cell => {
724
- this.insertComponentAt(cell, insertionRowPosition * this.columns)
725
- })
726
-
727
- let heights = this.heights.slice()
728
- heights.splice(insertionRowPosition, 0, ...newbieRowHeights)
729
- this.set('heights', heights)
842
+ // ── Grid layout ─────────────────────────────────────────
730
843
 
731
- this.model.rows += 1
844
+ get columns(): number {
845
+ return Math.max(1, Math.floor(this.state.columns ?? 5))
846
+ }
732
847
 
733
- this.clearCache()
734
- })
848
+ get rackRows(): number {
849
+ return Math.max(1, Math.floor(this.state.rows ?? 1))
735
850
  }
736
851
 
737
- insertCellsLeft(cells: RackGridCell[]) {
738
- // 먼저 cells 위치의 열을 구한다.
739
- let columns = [] as number[]
740
- cells.forEach(cell => {
741
- let column = this.getRowColumn(cell).column
742
- if (-1 == columns.indexOf(column)) columns.push(column)
743
- })
744
- columns.sort((a, b) => {
745
- return a - b
746
- })
747
- columns.reverse()
748
- // 열 2개 이상은 추가 안함. 임시로 막아놓음
749
- if (columns.length >= 2) return false
750
- let insertionColumnPosition = columns[0]
751
- let newbieColumnWidths = [] as number[]
752
- let newbieCells = [] as RackGridCell[]
753
- columns.forEach(column => {
754
- for (let i = 0; i < this.rows; i++)
755
- newbieCells.push(buildCopiedCell(this.components[column + this.columns * i].model, this.app) as RackGridCell)
756
- newbieColumnWidths.push(this.widths[column])
757
-
758
- let increasedColumns = this.columns
759
- let index = this.rows
760
- newbieCells.reverse().forEach(cell => {
761
- if (index == 0) {
762
- index = this.rows
763
- increasedColumns++
764
- }
852
+ get shelves(): number {
853
+ return Math.max(1, Math.floor(this.state.shelves ?? 4))
854
+ }
765
855
 
766
- index--
767
- this.insertComponentAt(cell, insertionColumnPosition + index * increasedColumns)
768
- })
856
+ // ── cellId / posKey 변환 ────────────────────────────────
769
857
 
770
- let widths = this.widths.slice()
771
- this.model.columns += columns.length // 고의적으로, change 이벤트가 발생하지 않도록 set(..) 사용하지 않음.
858
+ /** posKey = `${col-1}-${row-1}` (0-based, 2 segments). 1-based input. */
859
+ posKeyOf(col: number, row: number = 1): string {
860
+ return `${col - 1}-${row - 1}`
861
+ }
772
862
 
773
- widths.splice(insertionColumnPosition, 0, ...newbieColumnWidths)
863
+ /** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
864
+ cellIdOf(col: number, row: number = 1, shelf: number = 1): string {
865
+ return `${col - 1}-${row - 1}-${shelf - 1}`
866
+ }
774
867
 
775
- this.set('widths', widths)
776
- })
868
+ parseCellId(cellId: string): { col: number; row: number; shelf: number } | null {
869
+ const parts = cellId.split('-')
870
+ if (parts.length !== 3) return null
871
+ const [c, r, s] = parts.map(Number)
872
+ if (!Number.isFinite(c) || !Number.isFinite(r) || !Number.isFinite(s)) return null
873
+ return { col: c, row: r, shelf: s }
777
874
  }
778
875
 
779
- insertCellsRight(cells: RackGridCell[]) {
780
- // 먼저 cells 위치의 열을 구한다.
781
- let columns = [] as number[]
782
- cells.forEach(cell => {
783
- let column = this.getRowColumn(cell).column
784
- if (-1 == columns.indexOf(column)) columns.push(column)
785
- })
786
- columns.sort((a, b) => {
787
- return a - b
788
- })
789
- columns.reverse()
790
- // 열 2개 이상은 추가 안함. 임시로 막아놓음
791
- if (columns.length >= 2) return false
792
- let insertionColumnPosition = columns[columns.length - 1] + 1
793
- let newbieColumnWidths = [] as number[]
794
- let newbieCells = [] as RackGridCell[]
795
- columns.forEach(column => {
796
- for (let i = 0; i < this.rows; i++)
797
- newbieCells.push(buildCopiedCell(this.components[column + this.columns * i].model, this.app) as RackGridCell)
798
- newbieColumnWidths.push(this.widths[column])
799
-
800
- let increasedColumns = this.columns
801
- let index = this.rows
802
- newbieCells.reverse().forEach(cell => {
803
- if (index == 0) {
804
- index = this.rows
805
- increasedColumns++
806
- }
876
+ parsePosKey(posKey: string): { col: number; row: number } | null {
877
+ const parts = posKey.split('-')
878
+ if (parts.length !== 2) return null
879
+ const [c, r] = parts.map(Number)
880
+ if (!Number.isFinite(c) || !Number.isFinite(r)) return null
881
+ return { col: c, row: r }
882
+ }
807
883
 
808
- index--
809
- this.insertComponentAt(cell, insertionColumnPosition + index * increasedColumns)
810
- })
884
+ // ── cellOverrides accessor ─────────────────────────────
811
885
 
812
- let widths = this.widths.slice()
813
- this.model.columns += columns.length // 고의적으로, change 이벤트가 발생하지 않도록 set(..)을 사용하지 않음.
886
+ get cellOverrides(): { [posKey: string]: CellOverride } {
887
+ return (this.state.cellOverrides ?? {}) as { [posKey: string]: CellOverride }
888
+ }
814
889
 
815
- widths.splice(insertionColumnPosition, 0, ...newbieColumnWidths)
890
+ getCellOverride(posKey: string): CellOverride | undefined {
891
+ return this.cellOverrides[posKey]
892
+ }
816
893
 
817
- this.set('widths', widths)
818
- })
894
+ // ── Shelf labels ───────────────────────────────────────
895
+
896
+ /**
897
+ * Parse `state.shelfLocations` 를 levels 길이 array 로. 빈 슬롯은 1-based index default.
898
+ * '1,2,3' → ['1', '2', '3', ...]
899
+ * ',,,04' → ['1', '2', '3', '04']
900
+ * undefined → ['1', '2', '3', '4', ...]
901
+ */
902
+ get shelfLabels(): string[] {
903
+ const input = this.state.shelfLocations
904
+ const levels = this.shelves
905
+ const parts = (input || '').split(',')
906
+ const out: string[] = []
907
+ for (let i = 0; i < levels; i++) {
908
+ const p = parts[i]
909
+ out[i] = p && p.trim().length > 0 ? p.trim() : String(i + 1)
910
+ }
911
+ return out
819
912
  }
820
913
 
821
- distributeHorizontal(cells: RackGridCell[]) {
822
- const columns = [] as number[]
914
+ // ── Location 변환 (양방향) ──────────────────────────────
915
+
916
+ /**
917
+ * cellId → location string. 우선순위:
918
+ * 1. cell-component (state.section/unit) — source of truth
919
+ * 2. cellOverrides[posKey] — 호환성 fallback
920
+ * section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
921
+ */
922
+ locationOf(cellId: string): string | null {
923
+ const parsed = this.parseCellId(cellId)
924
+ if (!parsed) return null
925
+ const posKey = `${parsed.col}-${parsed.row}`
926
+
927
+ let section: string | undefined
928
+ let unit: string | undefined
929
+ let isEmpty: boolean | undefined
930
+ let cellShelfLocations: string | undefined
931
+
932
+ const cell = this.cellAt(parsed.col, parsed.row)
933
+ if (cell) {
934
+ section = cell.state.section
935
+ unit = cell.state.unit
936
+ isEmpty = cell.state.isEmpty
937
+ cellShelfLocations = cell.state.shelfLocations
938
+ } else {
939
+ const override = this.cellOverrides[posKey]
940
+ section = override?.section
941
+ unit = override?.unit
942
+ isEmpty = override?.isEmpty
943
+ }
823
944
 
824
- cells.forEach(cell => {
825
- let rowcolumn = this.getRowColumn(cell)
945
+ if (!section || !unit || isEmpty) return null
826
946
 
827
- if (-1 == columns.indexOf(rowcolumn.column)) columns.push(rowcolumn.column)
828
- })
947
+ const pattern = this.state.locPattern ?? '{z}{s}-{u}-{sh}'
948
+ const zone = this.state.zone ?? ''
949
+ const shelfLabel = this._shelfLabelsFromCell(cellShelfLocations)[parsed.shelf] ?? String(parsed.shelf + 1)
829
950
 
830
- const sum = columns.reduce((sum, column) => {
831
- return sum + this.widths[column]
832
- }, 0)
951
+ return pattern
952
+ .replace(/\{z\}/g, zone)
953
+ .replace(/\{s\}/g, section)
954
+ .replace(/\{u\}/g, unit)
955
+ .replace(/\{sh\}/g, shelfLabel)
956
+ }
833
957
 
834
- const newval = Math.round((sum / columns.length) * 100) / 100
835
- const widths = this.widths.slice()
836
- columns.forEach(column => {
837
- widths[column] = newval
838
- })
958
+ /** cell.state.shelfLocations 명시 그것, 아니면 RackGrid shelfLabels. */
959
+ private _shelfLabelsFromCell(cellShelfLocations: string | undefined): string[] {
960
+ const input = cellShelfLocations ?? this.state.shelfLocations
961
+ const levels = this.shelves
962
+ const parts = (input || '').split(',')
963
+ const out: string[] = []
964
+ for (let i = 0; i < levels; i++) {
965
+ const p = parts[i]
966
+ out[i] = p && p.trim().length > 0 ? p.trim() : String(i + 1)
967
+ }
968
+ return out
969
+ }
839
970
 
840
- this.set('widths', widths)
971
+ /** location string → cellId. sparse 역인덱스 lookup. 없으면 null. */
972
+ cellIdOfLocation(location: string): string | null {
973
+ return this._locationIndex.get(location) ?? null
841
974
  }
842
975
 
843
- distributeVertical(cells: RackGridCell[]) {
844
- const rows = [] as number[]
976
+ /** 모든 (override 명시된) cell 의 location 목록. inspection 용. */
977
+ get allLocations(): Array<{ cellId: string; location: string }> {
978
+ const result: Array<{ cellId: string; location: string }> = []
979
+ for (const [location, cellId] of this._locationIndex.entries()) {
980
+ result.push({ cellId, location })
981
+ }
982
+ return result
983
+ }
845
984
 
846
- cells.forEach(cell => {
847
- let rowcolumn = this.getRowColumn(cell)
985
+ // ── Location 역인덱스 (sparse cache) ────────────────────
986
+
987
+ private _locationIndexCache?: Map<string, string>
988
+
989
+ private get _locationIndex(): Map<string, string> {
990
+ if (this._locationIndexCache) return this._locationIndexCache
991
+ const map = new Map<string, string>()
992
+ const shelves = this.shelves
993
+
994
+ // 1. cell-component 들 (source of truth) — 모든 bay 순회
995
+ const cells = this.components as Component[] | undefined
996
+ if (cells && cells.length > 0) {
997
+ for (let i = 0; i < cells.length; i++) {
998
+ const cell: any = cells[i]
999
+ if (cell?.state?.type !== 'rack-grid-cell') continue
1000
+ const bayKey = cell.state.cellId
1001
+ if (!bayKey) continue
1002
+ const parsed = this.parsePosKey(bayKey)
1003
+ if (!parsed) continue
1004
+ for (let shelfIdx = 0; shelfIdx < shelves; shelfIdx++) {
1005
+ const cellId = `${parsed.col}-${parsed.row}-${shelfIdx}`
1006
+ const loc = this.locationOf(cellId)
1007
+ if (loc) map.set(loc, cellId)
1008
+ }
1009
+ }
1010
+ }
848
1011
 
849
- if (-1 == rows.indexOf(rowcolumn.row)) rows.push(rowcolumn.row)
850
- })
1012
+ // 2. cellOverrides fallback (cell-component 없는 시점 — 호환)
1013
+ const overrides = this.cellOverrides
1014
+ for (const posKey of Object.keys(overrides)) {
1015
+ const override = overrides[posKey]
1016
+ if (!override.section || !override.unit || override.isEmpty) continue
1017
+ const parsed = this.parsePosKey(posKey)
1018
+ if (!parsed) continue
1019
+ for (let shelfIdx = 0; shelfIdx < shelves; shelfIdx++) {
1020
+ const cellId = `${parsed.col}-${parsed.row}-${shelfIdx}`
1021
+ if (!map.has(this.locationOf(cellId) || '')) {
1022
+ const loc = this.locationOf(cellId)
1023
+ if (loc && !map.has(loc)) map.set(loc, cellId)
1024
+ }
1025
+ }
1026
+ }
851
1027
 
852
- const sum = rows.reduce((sum, row) => {
853
- return sum + this.heights[row]
854
- }, 0)
1028
+ this._locationIndexCache = map
1029
+ return map
1030
+ }
855
1031
 
856
- const newval = Math.round((sum / rows.length) * 100) / 100
857
- const heights = this.heights.slice()
858
- rows.forEach(row => {
859
- heights[row] = newval
860
- })
1032
+ /** / cellOverrides 변경 호출. things-scene 의 onchange* 자동 호출. */
1033
+ invalidateLocationIndex(): void {
1034
+ this._locationIndexCache = undefined
1035
+ }
861
1036
 
862
- this.set('heights', heights)
1037
+ onchangeCellOverrides(): void { this.invalidateLocationIndex() }
1038
+ onchangeLocPattern(): void { this.invalidateLocationIndex() }
1039
+ onchangeShelfLocations(): void { this.invalidateLocationIndex() }
1040
+ onchangeZone(): void { this.invalidateLocationIndex() }
1041
+ onchangeSectionDigits(): void { this.invalidateLocationIndex() }
1042
+ onchangeUnitDigits(): void { this.invalidateLocationIndex() }
1043
+ onchangeShelves(): void { this.invalidateLocationIndex() }
1044
+
1045
+ // ── 자식 lookup ────────────────────────────────────────
1046
+
1047
+ /**
1048
+ * (col, row) 위치의 자식 StorageRack. 자식이 StorageRack 이 아닌 경우 (Crane, AGV 등)
1049
+ * 는 null — Carrier 보관은 StorageRack 만.
1050
+ *
1051
+ * 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
1052
+ * 자식 추가 시 자동 할당 또는 사용자가 명시.
1053
+ */
1054
+ private _childRackAt(posKey: string): StorageRack | null {
1055
+ const parsed = this.parsePosKey(posKey)
1056
+ if (!parsed) return null
1057
+ const children = (this.components as Component[] | undefined) ?? []
1058
+ for (const c of children) {
1059
+ const type = (c.state as any)?.type
1060
+ if (type !== 'storage-rack') continue
1061
+ const childCol = ((c.state as any)?.column ?? 1) - 1
1062
+ const childRow = ((c.state as any)?.row ?? 1) - 1
1063
+ if (childCol === parsed.col && childRow === parsed.row) {
1064
+ return c as unknown as StorageRack
1065
+ }
1066
+ }
1067
+ return null
863
1068
  }
864
1069
 
865
- increaseLocation(type: string, skipNumbering: boolean, startSection: number, startUnit: number) {
866
- const { sectionDigits = 2, unitDigits = 2 } = this.state
867
- const selectedCells = this.root.selected as RackGridCell[]
1070
+ // ── SlottedHolder 컨트랙 자식 StorageRack 으로 위임 ────
868
1071
 
869
- increaseLocation(selectedCells, type, skipNumbering, startSection, startUnit, sectionDigits, unitDigits)
1072
+ hasCarrierAt(slotId: string): boolean {
1073
+ const parsed = this.parseCellId(slotId)
1074
+ if (!parsed) return false
1075
+ const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1076
+ return child?.hasCarrierAt(`0-0-${parsed.shelf}`) ?? false
870
1077
  }
871
1078
 
872
- get columns() {
873
- return this.getState('columns')
1079
+ obtainCarrier(slotIdOrLocation: string): Component | null {
1080
+ const slotId = this._resolveToCellId(slotIdOrLocation)
1081
+ if (!slotId) return null
1082
+ const parsed = this.parseCellId(slotId)
1083
+ if (!parsed) return null
1084
+ const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1085
+ return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null
874
1086
  }
875
1087
 
876
- get lefts() {
877
- return this.components.filter((c: any, i: any) => {
878
- return !(i % this.columns)
879
- })
1088
+ canReceiveAt(slotIdOrLocation: string, carrier?: Component): boolean {
1089
+ const slotId = this._resolveToCellId(slotIdOrLocation)
1090
+ if (!slotId) return false
1091
+ const parsed = this.parseCellId(slotId)
1092
+ if (!parsed) return false
1093
+ // isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
1094
+ const override = this.cellOverrides[`${parsed.col}-${parsed.row}`]
1095
+ if (override?.isEmpty) return false
1096
+ const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1097
+ return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false
880
1098
  }
881
1099
 
882
- get centers() {
883
- return this.components.filter((c: any, i: any) => {
884
- return i % this.columns && (i + 1) % this.columns
885
- })
1100
+ async receiveAt(slotIdOrLocation: string, carrier: Component, options?: any): Promise<void> {
1101
+ const slotId = this._resolveToCellId(slotIdOrLocation)
1102
+ if (!slotId) throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`)
1103
+ const parsed = this.parseCellId(slotId)
1104
+ if (!parsed) throw new Error(`RackGrid.receiveAt: invalid cellId "${slotId}"`)
1105
+ const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
1106
+ if (!child) {
1107
+ throw new Error(`RackGrid.receiveAt: no StorageRack at posKey=${parsed.col}-${parsed.row}`)
1108
+ }
1109
+ return child.receiveAt(`0-0-${parsed.shelf}`, carrier, options)
886
1110
  }
887
1111
 
888
- get rights() {
889
- return this.components.filter((c: any, i: any) => {
890
- return !((i + 1) % this.columns)
891
- })
1112
+ recordFromCarrier(carrier: Component, slotId: string): SlotRecord {
1113
+ const { id, refid, transform, ...rest } = ((carrier.state as any) || {}) as any
1114
+ return { ...rest, cellId: slotId }
892
1115
  }
893
1116
 
894
- get tops() {
895
- return this.components.slice(0, this.columns)
896
- }
1117
+ /**
1118
+ * Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
1119
+ * 위치한 invisible Object3D. popup tether / Carriable.applyHolderAttachPoint 가 이
1120
+ * object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
1121
+ */
1122
+ private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
1123
+
1124
+ getSlotAttachObject3d(slotId: string): THREE.Object3D | undefined {
1125
+ const parsed = this.parseCellId(slotId)
1126
+ if (!parsed) return undefined
1127
+ if (parsed.col < 0 || parsed.col >= this.columns) return undefined
1128
+ if (parsed.row < 0 || parsed.row >= this.rackRows) return undefined
1129
+ if (parsed.shelf < 0 || parsed.shelf >= this.shelves) return undefined
1130
+
1131
+ const ro: any = (this as any)._realObject
1132
+ if (!ro?.object3d) return undefined
1133
+
1134
+ let obj = this._attachAnchorByCell.get(slotId)
1135
+ if (!obj) {
1136
+ obj = new THREE.Object3D()
1137
+ obj.name = `rack-grid-anchor:${slotId}`
1138
+ ro.object3d.add(obj)
1139
+ this._attachAnchorByCell.set(slotId, obj)
1140
+ }
897
1141
 
898
- get middles() {
899
- return this.components.slice(this.columns, this.columns * (this.rows - 1))
1142
+ // 위치 갱신 — Stock InstancedMesh 의 instance 위치 와 동일 공식
1143
+ const rs: any = this.state
1144
+ const cols = this.columns
1145
+ const rows = this.rackRows
1146
+ const shelves = this.shelves
1147
+ const width = (rs?.width as number) ?? 400
1148
+ const height = (rs?.depth as number) ?? 2000
1149
+ const depth = (rs?.height as number) ?? 200
1150
+ const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
1151
+ const shelfZone = height - shelfBase
1152
+ const bayW = width / cols
1153
+ const bayD = depth / rows
1154
+ const cellY = shelfZone / shelves
1155
+ const baseY = -height / 2
1156
+ const shelfBaseY = baseY + shelfBase
1157
+ const stockD = cellY * 0.7
1158
+
1159
+ const cx = (parsed.col - cols / 2 + 0.5) * bayW
1160
+ const cellBottomY = shelfBaseY + parsed.shelf * cellY
1161
+ const cy = cellBottomY + stockD / 2
1162
+ const cz = (parsed.row - rows / 2 + 0.5) * bayD
1163
+ obj.position.set(cx, cy, cz)
1164
+ // *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
1165
+ ro.object3d.updateMatrixWorld(true)
1166
+ obj.updateMatrixWorld(true)
1167
+ return obj
900
1168
  }
901
1169
 
902
- get bottoms() {
903
- return this.components.slice(this.columns * (this.rows - 1))
1170
+ getSlotSize(slotId: string): { width: number; height: number; depth: number } | undefined {
1171
+ const parsed = this.parseCellId(slotId)
1172
+ if (!parsed) return undefined
1173
+ const rs: any = this.state
1174
+ const width = (rs?.width as number) ?? 400
1175
+ const height = (rs?.depth as number) ?? 2000
1176
+ const depth = (rs?.height as number) ?? 200
1177
+ const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
1178
+ const shelfZone = height - shelfBase
1179
+ const cellY = shelfZone / this.shelves
1180
+ return {
1181
+ width: (width / this.columns) * 0.85,
1182
+ height: (depth / this.rackRows) * 0.85, // 2D height = 3D Z 폭
1183
+ depth: cellY * 0.7 // 3D Y 폭 (= stockD)
1184
+ }
904
1185
  }
905
1186
 
906
- get all() {
907
- return this.components
1187
+ cellCenter2D(slotId: string): { x: number; y: number } | null {
1188
+ const parsed = this.parseCellId(slotId)
1189
+ if (!parsed) return null
1190
+ const rs: any = this.state
1191
+ const left = (rs?.left as number) ?? 0
1192
+ const top = (rs?.top as number) ?? 0
1193
+ const width = (rs?.width as number) ?? 400
1194
+ const height = (rs?.height as number) ?? 200
1195
+ // table layout 의 widths/heights 비례 위치
1196
+ const widths = this.widths
1197
+ const heights = this.heights
1198
+ const wSum = this.widths_sum
1199
+ const hSum = this.heights_sum
1200
+ let cumW = 0
1201
+ for (let c = 0; c < parsed.col; c++) cumW += widths[c]
1202
+ let cumH = 0
1203
+ for (let r = 0; r < parsed.row; r++) cumH += heights[r]
1204
+ const x = left + (cumW + widths[parsed.col] / 2) / wSum * width
1205
+ const y = top + (cumH + heights[parsed.row] / 2) / hSum * height
1206
+ return { x, y }
908
1207
  }
909
1208
 
910
- get widths_sum() {
911
- const widths = this.widths
912
- return widths ? widths.filter((width, i) => i < this.columns).reduce((sum, width) => sum + width, 0) : this.columns
1209
+ slotTargetAt(slotIdOrLocation: string): SlotTarget {
1210
+ const slotId = this._resolveToCellId(slotIdOrLocation)
1211
+ if (!slotId) throw new Error(`RackGrid.slotTargetAt: cannot resolve "${slotIdOrLocation}"`)
1212
+ return new SlotTarget(this, slotId)
913
1213
  }
914
1214
 
915
- get heights_sum() {
916
- const heights = this.heights
917
- return heights ? heights.filter((height, i) => i < this.rows).reduce((sum, height) => sum + height, 0) : this.rows
1215
+ // ── Helpers ──────────────────────────────────────────
1216
+
1217
+ /**
1218
+ * 외부 입력 → 내부 cellId 변환:
1219
+ * - "N-N-N" (3 segments, 모두 숫자) → cellId 그대로 (dual-accept)
1220
+ * - 그 외 → location 으로 간주, locationIndex lookup
1221
+ */
1222
+ private _resolveToCellId(input: string): string | null {
1223
+ if (this._isCellIdFormat(input)) return input
1224
+ return this.cellIdOfLocation(input)
918
1225
  }
919
1226
 
920
- get nature() {
921
- return NATURE
1227
+ private _isCellIdFormat(s: string): boolean {
1228
+ const parts = s.split('-')
1229
+ return parts.length === 3 && parts.every(p => /^\d+$/.test(p))
922
1230
  }
923
1231
 
924
- get controls(): Array<Control> | undefined {
925
- const widths = this.widths
926
- const heights = this.heights
927
- const inside = this.textBounds
1232
+ // ── Bulk 편집 API ────────────────────────────────────────
1233
+ //
1234
+ // 모두 *single setState* 로 cellOverrides 갱신 → 단일 'change' 이벤트만 발사.
1235
+ // 매핑 cascade 회피 위해 silent 갱신 메서드 제공 (`_setCellOverridesSilently`).
1236
+
1237
+ /**
1238
+ * 한 위치의 cellOverride 를 갱신 (merge). 기존 필드는 유지, 새 필드만 덮어씀.
1239
+ * `partial` 의 필드가 *undefined* 면 *그 필드 삭제* (override 키에서 제거).
1240
+ */
1241
+ setCellOverride(posKey: string, partial: Partial<CellOverride>): void {
1242
+ const current = this.cellOverrides
1243
+ const existing = current[posKey] ?? {}
1244
+ const merged: any = { ...existing }
1245
+ for (const [k, v] of Object.entries(partial)) {
1246
+ if (v === undefined) delete merged[k]
1247
+ else merged[k] = v
1248
+ }
1249
+ const next = { ...current }
1250
+ if (Object.keys(merged).length === 0) delete next[posKey]
1251
+ else next[posKey] = merged
1252
+ this.setState({ cellOverrides: next })
1253
+ }
928
1254
 
929
- const width_unit = inside.width / this.widths_sum
930
- const height_unit = inside.height / this.heights_sum
1255
+ /** 위치의 override 전체 삭제. */
1256
+ clearCellOverride(posKey: string): void {
1257
+ const current = this.cellOverrides
1258
+ if (!(posKey in current)) return
1259
+ const next = { ...current }
1260
+ delete next[posKey]
1261
+ this.setState({ cellOverrides: next })
1262
+ }
931
1263
 
932
- let x = inside.left
933
- let y = inside.top
1264
+ /**
1265
+ * 여러 위치의 isEmpty 일괄 토글. cell-component 있으면 cell.set(), 없으면 cellOverrides.
1266
+ */
1267
+ setIsEmpty(posKeys: string[], isEmpty: boolean): void {
1268
+ const hasCells = this.components.some((c: Component) => (c as any)?.state?.type === 'rack-grid-cell')
1269
+ if (hasCells) {
1270
+ for (const posKey of posKeys) {
1271
+ const cell = this.cellAtBayKey(posKey)
1272
+ cell?.set?.('isEmpty', isEmpty)
1273
+ }
1274
+ this.invalidateLocationIndex()
1275
+ return
1276
+ }
1277
+ // Fallback: cellOverrides
1278
+ const current = this.cellOverrides
1279
+ const next = { ...current }
1280
+ for (const posKey of posKeys) {
1281
+ const existing = next[posKey] ?? {}
1282
+ const merged: any = { ...existing, isEmpty }
1283
+ if (!isEmpty && !existing.section && !existing.unit && !existing.border && !existing.merged) {
1284
+ delete next[posKey]
1285
+ } else {
1286
+ next[posKey] = merged
1287
+ }
1288
+ }
1289
+ this.setState({ cellOverrides: next })
1290
+ }
934
1291
 
935
- const controls: Array<Control> = []
1292
+ /**
1293
+ * 여러 위치의 border 스타일 일괄 설정. `where` ('top'|'left'|'bottom'|'right'|'all').
1294
+ * - style = null → 해당 side 삭제
1295
+ * - where = 'all' → 4면 모두 동일 style
1296
+ */
1297
+ setBorder(posKeys: string[], style: any, where: 'all' | 'top' | 'left' | 'bottom' | 'right' = 'all'): void {
1298
+ const sides = where === 'all' ? ['top', 'left', 'bottom', 'right'] : [where]
1299
+ const current = this.cellOverrides
1300
+ const next = { ...current }
1301
+ for (const posKey of posKeys) {
1302
+ const existing = next[posKey] ?? {}
1303
+ const border = { ...(existing.border ?? {}) }
1304
+ for (const side of sides) {
1305
+ if (style == null) delete border[side]
1306
+ else border[side] = style
1307
+ }
1308
+ const merged: any = { ...existing, border }
1309
+ if (Object.keys(border).length === 0) delete merged.border
1310
+ next[posKey] = merged
1311
+ }
1312
+ this.setState({ cellOverrides: next })
1313
+ }
936
1314
 
937
- widths.slice(0, this.columns - 1).forEach((width: number) => {
938
- x += width * width_unit
939
- controls.push({
940
- x: x,
941
- y: inside.top,
942
- handler: columnControlHandler
943
- })
944
- })
1315
+ /**
1316
+ * 자동 채번 선택된 cell 들에 section/unit 자동 부여.
1317
+ *
1318
+ * @param posKeys 채번 대상 (선택 영역). 빈 array 면 *전체 grid*.
1319
+ * @param direction 순회 방향:
1320
+ * 'cw' — 한 row 내 col 증가 → 다음 row 는 반대 방향 (boustrophedon)
1321
+ * 'ccw' — 반대 방향
1322
+ * 'zigzag' — col 우선 증가 (cw 의 90° 회전)
1323
+ * 'zigzag-reverse' — 반대
1324
+ * @param skipNumbering true 면 isEmpty cell 건너뜀 (단위 번호 안 증가).
1325
+ * @param startSection 첫 section 번호 (default 1)
1326
+ * @param startUnit 첫 unit 번호 (default 1)
1327
+ *
1328
+ * Aisle row (모든 cell isEmpty) 는 section 경계로 인식 — section 번호가 다음 row 에서 +1.
1329
+ *
1330
+ * setState 한 번 (cellOverrides 일괄 갱신) → location 역인덱스 1회 무효화.
1331
+ */
1332
+ increaseLocation(
1333
+ posKeys: string[],
1334
+ direction: 'cw' | 'ccw' | 'zigzag' | 'zigzag-reverse' = 'cw',
1335
+ skipNumbering: boolean = false,
1336
+ startSection: number = 1,
1337
+ startUnit: number = 1
1338
+ ): void {
1339
+ const sectionDigits = Math.max(1, Math.floor(this.state.sectionDigits ?? 2))
1340
+ const unitDigits = Math.max(1, Math.floor(this.state.unitDigits ?? 2))
1341
+
1342
+ // 대상 결정: posKeys 비었으면 전체 grid
1343
+ const targets: string[] = posKeys.length > 0
1344
+ ? [...posKeys]
1345
+ : this._allPosKeys()
1346
+
1347
+ // (1) row 별 분류 (2D 배열, [row][col] = posKey)
1348
+ const byRow: (string | null)[][] = []
1349
+ let maxRow = -1
1350
+ let maxCol = -1
1351
+ for (const posKey of targets) {
1352
+ const parsed = this.parsePosKey(posKey)
1353
+ if (!parsed) continue
1354
+ if (!byRow[parsed.row]) byRow[parsed.row] = []
1355
+ byRow[parsed.row][parsed.col] = posKey
1356
+ if (parsed.row > maxRow) maxRow = parsed.row
1357
+ if (parsed.col > maxCol) maxCol = parsed.col
1358
+ }
945
1359
 
946
- heights.slice(0, this.rows - 1).forEach((height: number) => {
947
- y += height * height_unit
948
- controls.push({
949
- x: inside.left,
950
- y: y,
951
- handler: rowControlHandler
952
- })
953
- })
1360
+ // (2) aisle row 식별 — row 의 모든 (선택된) cell 이 isEmpty
1361
+ const overrides = this.cellOverrides
1362
+ const isAisleRow = (row: number): boolean => {
1363
+ const cells = byRow[row]
1364
+ if (!cells || cells.length === 0) return true
1365
+ return cells.every(p => p == null || overrides[p]?.isEmpty === true)
1366
+ }
954
1367
 
955
- return controls
956
- }
1368
+ // (3) section 별 묶기 (aisle 사이의 연속 row 들)
1369
+ const sections: number[][] = []
1370
+ let currentSection: number[] = []
1371
+ for (let r = 0; r <= maxRow; r++) {
1372
+ if (!byRow[r]) continue
1373
+ if (isAisleRow(r)) {
1374
+ if (currentSection.length > 0) {
1375
+ sections.push(currentSection)
1376
+ currentSection = []
1377
+ }
1378
+ } else {
1379
+ currentSection.push(r)
1380
+ }
1381
+ }
1382
+ if (currentSection.length > 0) sections.push(currentSection)
1383
+
1384
+ // (4) 방향별 순회로 (section, unit) 부여
1385
+ const hasCells = this.components.some((c: Component) => (c as any)?.state?.type === 'rack-grid-cell')
1386
+ const next = { ...overrides }
1387
+ let sectionNum = Number(startSection) || 1
1388
+
1389
+ const setSU = (posKey: string, section: number, unit: number) => {
1390
+ const sStr = String(section).padStart(sectionDigits, '0')
1391
+ const uStr = String(unit).padStart(unitDigits, '0')
1392
+ if (hasCells) {
1393
+ const cell = this.cellAtBayKey(posKey)
1394
+ cell?.set?.('section', sStr)
1395
+ cell?.set?.('unit', uStr)
1396
+ } else {
1397
+ const existing = next[posKey] ?? {}
1398
+ next[posKey] = { ...existing, section: sStr, unit: uStr }
1399
+ }
1400
+ }
957
1401
 
958
- onchange(after: Properties, before: Properties) {
959
- if ('legendTarget' in after || 'legendTarget' in before) {
960
- this._legendTarget?.off('change', this._onLegendChanged, this)
961
- delete this._legendTarget
962
- this._resetMaterials()
1402
+ const clearSU = (posKey: string) => {
1403
+ if (hasCells) {
1404
+ const cell = this.cellAtBayKey(posKey)
1405
+ cell?.set?.('section', null)
1406
+ cell?.set?.('unit', null)
1407
+ } else {
1408
+ const existing = next[posKey]
1409
+ if (!existing) return
1410
+ const { section, unit, ...rest } = existing
1411
+ if (Object.keys(rest).length === 0) delete next[posKey]
1412
+ else next[posKey] = rest
1413
+ }
963
1414
  }
964
1415
 
965
- if ('rows' in after || 'columns' in after) {
966
- this.buildCells(
967
- this.getState('rows'),
968
- this.getState('columns'),
969
- before.hasOwnProperty('rows') ? before.rows : this.getState('rows'),
970
- before.hasOwnProperty('columns') ? before.columns : this.getState('columns')
971
- )
1416
+ for (const sectionRows of sections) {
1417
+ for (const row of sectionRows) {
1418
+ const cells = byRow[row]
1419
+ if (!cells) continue
1420
+ // 방향별 cell 순회 순서 결정
1421
+ const orderedCols = this._orderCols(row, sectionRows, direction, maxCol)
1422
+ let unitNum = Number(startUnit) || 1
1423
+ for (const col of orderedCols) {
1424
+ const posKey = cells[col]
1425
+ if (!posKey) continue
1426
+ const isEmpty = overrides[posKey]?.isEmpty === true
1427
+ if (isEmpty) {
1428
+ clearSU(posKey)
1429
+ if (!skipNumbering) unitNum++
1430
+ } else {
1431
+ setSU(posKey, sectionNum, unitNum)
1432
+ unitNum++
1433
+ }
1434
+ }
1435
+ // row 마다 section 증가? 아님 — section 은 *aisle 로 구분된 묶음 단위*.
1436
+ // 동일 section 내 다음 row 는 unit 번호 다시 startUnit 부터.
1437
+ // rack-table 의 원본 동작과 동일.
1438
+ }
1439
+ sectionNum++
972
1440
  }
973
1441
 
974
- this.invalidate()
1442
+ // cell-component 가 있으면 cell.set() 으로 이미 갱신됨 (setSU/clearSU 안에서).
1443
+ // 없으면 cellOverrides fallback 적용.
1444
+ if (!hasCells) {
1445
+ this.setState({ cellOverrides: next })
1446
+ } else {
1447
+ this.invalidateLocationIndex()
1448
+ }
975
1449
  }
976
1450
 
977
- get eventMap() {
978
- return {
979
- '(self)': {
980
- '(descendant)': {
981
- change: this.oncellchanged
982
- }
1451
+ /**
1452
+ * 모든 grid 위치 (전체 columns × rows) 의 posKey 목록.
1453
+ */
1454
+ private _allPosKeys(): string[] {
1455
+ const cols = this.columns
1456
+ const rows = this.rackRows
1457
+ const out: string[] = []
1458
+ for (let r = 0; r < rows; r++) {
1459
+ for (let c = 0; c < cols; c++) {
1460
+ out.push(`${c}-${r}`)
983
1461
  }
984
1462
  }
1463
+ return out
985
1464
  }
986
1465
 
987
- oncellchanged(after: Properties, before: Properties) {
988
- this.invalidate()
1466
+ /**
1467
+ * Row 의 col 순회 순서 결정. boustrophedon (S 자형) 패턴 지원.
1468
+ * cw : 짝수 row 는 좌→우, 홀수 row 는 우→좌
1469
+ * ccw : 짝수 row 는 우→좌, 홀수 row 는 좌→우
1470
+ * zigzag : 모든 row 좌→우 (단순)
1471
+ * zigzag-reverse : 모든 row 우→좌
1472
+ */
1473
+ private _orderCols(
1474
+ row: number,
1475
+ sectionRows: number[],
1476
+ direction: 'cw' | 'ccw' | 'zigzag' | 'zigzag-reverse',
1477
+ maxCol: number
1478
+ ): number[] {
1479
+ // section 내 row 의 *상대 인덱스* — section 경계에서 패턴이 리셋되도록.
1480
+ const idxInSection = sectionRows.indexOf(row)
1481
+ const cols: number[] = []
1482
+ for (let c = 0; c <= maxCol; c++) cols.push(c)
1483
+
1484
+ switch (direction) {
1485
+ case 'cw':
1486
+ return idxInSection % 2 === 0 ? cols : cols.slice().reverse()
1487
+ case 'ccw':
1488
+ return idxInSection % 2 === 0 ? cols.slice().reverse() : cols
1489
+ case 'zigzag':
1490
+ return cols
1491
+ case 'zigzag-reverse':
1492
+ return cols.slice().reverse()
1493
+ }
989
1494
  }
990
1495
  }
991
-
992
- ;['rows', 'columns', 'widths', 'heights', 'widths_sum', 'heights_sum', 'controls'].forEach(getter =>
993
- Component.memoize(RackGrid.prototype, getter, false)
994
- )