@operato/scene-storage 10.0.0-beta.55 → 10.0.0-beta.57

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.
@@ -13,12 +13,79 @@
13
13
 
14
14
  import * as THREE from 'three'
15
15
  import { RealObjectGroup } from '@hatiolab/things-scene'
16
+ import { computeStackPositions } from '@operato/scene-base'
16
17
 
17
18
  import type Stockpile from './stockpile.js'
18
19
  import type { StackPattern, CarrierPreset } from './stockpile.js'
19
20
 
20
21
  const PAD_DEPTH = 2
21
22
 
23
+ /**
24
+ * 그래프(root.__flowGraph)에서 이 stockpile 로 *들어오는* 노드(입구)를 찾아, 적치를 *입구
25
+ * 맞은편부터* 하도록 reverseX/Z 를 산출한다. storage 가 conveyance 를 import 하지 않고
26
+ * 캐시 속성만 일반적으로 읽음(토폴로지 viz 와 동일 방식). 입구/그래프 없으면 undefined →
27
+ * 기본(-x/-z 모서리부터). 입구 방향은 stockpile local frame 기준(회전 반영).
28
+ */
29
+ function inflowFillOpts(stockpile: any): { advanceAlongX?: boolean; reverse?: boolean } | undefined {
30
+ const root = stockpile?.root
31
+ // 로드 직후(sim 전)엔 그래프 캐시가 없을 수 있다 — conveyance 가 globalThis 에 심은
32
+ // 빌더로 즉석 build(토폴로지 viz 와 동일). 없으면(컨베이어 미로드) 기본값.
33
+ if (root && !root.__flowGraph) {
34
+ try { (globalThis as any).__operatoBuildFlowGraph?.(root) } catch { /* ignore */ }
35
+ }
36
+ const cache = root?.__flowGraph
37
+ const out = cache?.graph?.out as Map<string, Array<string | null>> | undefined
38
+ const comps = cache?.components as Map<string, any> | undefined
39
+ // 그래프 빌더(flow-graph-coordinator collect)와 *동일* idOf — refid 는 set('refid') 로
40
+ // state.refid 에 저장되므로 c.refid 와 state.refid 를 둘 다 확인해야 한다. (c.refid 만 보면
41
+ // 명시 id 없는 stockpile 이 전부 undefined → bail → 기본값으로 떨어져 입구 도출 실패.)
42
+ const idOf = (c: any): string | undefined => {
43
+ const raw = c?.state?.id
44
+ if (typeof raw === 'string' && raw.length > 0) return raw
45
+ const refid = c?.refid ?? c?.state?.refid
46
+ return refid != null ? String(refid) : undefined
47
+ }
48
+ const myId = idOf(stockpile)
49
+ if (!out || !comps || !myId) return undefined
50
+ let feeder: any
51
+ out.forEach((targets, srcId) => {
52
+ if (!feeder && Array.isArray(targets) && targets.includes(myId)) feeder = comps.get(srcId)
53
+ })
54
+ const ss = stockpile?.state
55
+ // feeder(입구) 못 찾으면 기본값(undefined) — 마주한 슈트 없는 stockpile.
56
+ if (!feeder || !ss) return undefined
57
+ // 입구 흐름 = *소스 링크의 방향* — feeder(슈트)의 배출 anchor(이 stockpile 로 향하는) direction.
58
+ // (중심-중심 벡터가 아니라 링크 방향면을 써야 정확. 긴 슈트의 중심은 실제 유입면과 다를 수 있음.)
59
+ let dir: { x: number; z: number } | undefined
60
+ try {
61
+ const outs = feeder.getSegmentTopology?.()?.outbounds as Array<{ worldPos?: { x: number; z: number }; direction?: { x: number; z: number } }> | undefined
62
+ if (Array.isArray(outs) && outs.length) {
63
+ const sx = (ss.left ?? 0) + (ss.width ?? 0) / 2
64
+ const sz = (ss.top ?? 0) + (ss.height ?? 0) / 2
65
+ let best = outs[0]
66
+ let bestD = Infinity
67
+ for (const a of outs) {
68
+ if (!a?.worldPos || !a.direction) continue
69
+ const d = (a.worldPos.x - sx) ** 2 + (a.worldPos.z - sz) ** 2
70
+ if (d < bestD) { bestD = d; best = a } // stockpile 에 가장 가까운 배출 anchor
71
+ }
72
+ if (best?.direction) dir = best.direction
73
+ }
74
+ } catch { /* topology 못 얻으면 fallback 없음 → 기본 */ }
75
+ if (!dir) return undefined
76
+ // 흐름 방향(world)을 stockpile local frame 으로 (회전 -rot).
77
+ const rot = typeof ss.rotation === 'number' ? ss.rotation : 0
78
+ const cos = Math.cos(-rot)
79
+ const sin = Math.sin(-rot)
80
+ const lx = dir.x * cos - dir.z * sin
81
+ const lz = dir.x * sin + dir.z * cos
82
+ // 흐름이 x 우세면 x축 진행. items 는 흐름 방향 끝(먼저 닿는 곳)부터 쌓여 입구쪽으로 →
83
+ // 흐름이 + 면 + 끝부터(reverse).
84
+ const advanceAlongX = Math.abs(lx) >= Math.abs(lz)
85
+ const reverse = advanceAlongX ? lx > 0 : lz > 0
86
+ return { advanceAlongX, reverse }
87
+ }
88
+
22
89
  /** carrierPreset 별 default 색 (state.carrierWidth/Height/Depth 미지정 시 크기와 함께). */
23
90
  const PRESET_COLOR: Record<CarrierPreset, number> = {
24
91
  box: 0xb87333, // kraft brown
@@ -186,7 +253,15 @@ export class Stockpile3D extends RealObjectGroup {
186
253
  return presetDefaultColor
187
254
  }
188
255
 
189
- const positions = this._computeStackPositions(pattern, count, areaW, areaH, cw, ch, cd, gap, state.stackHeightLimit)
256
+ // stackHeightLimit = *총 적치 높이*(scene unit) 단(layer) 개수가 아니다. 아이템
257
+ // 한 단 높이(cd)로 나눠 최대 단 수로 환산해 전달. 예: 한도 60 + 아이템높이 10 → 6단.
258
+ const heightLimit = state.stackHeightLimit
259
+ const maxLayers = typeof heightLimit === 'number' && heightLimit > 0 && cd > 0
260
+ ? Math.max(1, Math.floor(heightLimit / cd))
261
+ : undefined
262
+ // 입구 맞은편부터 채우기 — 그래프에서 이 stockpile 로 들어오는 노드(입구)를 도출.
263
+ const fillOpts = inflowFillOpts(stockpile)
264
+ const positions = computeStackPositions(pattern, count, { w: areaW, h: areaH }, { w: cw, h: ch, d: cd }, gap, maxLayers, fillOpts)
190
265
  this._meshByRecordId.clear()
191
266
  const records = stockpile.records
192
267
  for (let i = 0; i < positions.length; i++) {
@@ -213,111 +288,6 @@ export class Stockpile3D extends RealObjectGroup {
213
288
  return this._meshByRecordId.get(recordId)
214
289
  }
215
290
 
216
- /**
217
- * stackPattern 별 각 carrier 의 local 좌표(영역 중심 기준). y 는 pad 위에 쌓이는
218
- * 높이(cd 단위), x/z 는 영역 평면 위 분포.
219
- */
220
- private _computeStackPositions(
221
- pattern: StackPattern,
222
- count: number,
223
- areaW: number,
224
- areaH: number,
225
- cw: number,
226
- ch: number,
227
- cd: number,
228
- gap: number,
229
- heightLimit?: number
230
- ): Array<{ x: number; y: number; z: number }> {
231
- const out: Array<{ x: number; y: number; z: number }> = []
232
-
233
- // column — 한 자리에 수직 적층
234
- if (pattern === 'column') {
235
- const layers = heightLimit ?? count
236
- for (let i = 0; i < count; i++) {
237
- const layer = Math.min(i, layers - 1)
238
- out.push({ x: 0, y: layer * cd + cd / 2, z: 0 })
239
- }
240
- return out
241
- }
242
-
243
- // 격자 cell — 행/열 수. cell stride = carrier + gap (인접 carrier 사이 분리).
244
- const stridX = cw + gap
245
- const stridZ = ch + gap
246
- const cols = Math.max(1, Math.floor((areaW + gap) / stridX))
247
- const rows = Math.max(1, Math.floor((areaH + gap) / stridZ))
248
- const cellsPerLayer = cols * rows
249
-
250
- // pyramid — 위로 갈수록 행/열 -1 씩 (사다리꼴)
251
- if (pattern === 'pyramid') {
252
- let remaining = count
253
- let layer = 0
254
- while (remaining > 0) {
255
- const lcols = Math.max(1, cols - layer)
256
- const lrows = Math.max(1, rows - layer)
257
- const cellsThisLayer = lcols * lrows
258
- const take = Math.min(remaining, cellsThisLayer)
259
- for (let i = 0; i < take; i++) {
260
- const c = i % lcols
261
- const r = Math.floor(i / lcols)
262
- // 가운데 정렬 — 줄어든 만큼 offset
263
- const offsetCol = (cols - lcols) / 2
264
- const offsetRow = (rows - lrows) / 2
265
- const x = -areaW / 2 + (c + offsetCol + 0.5) * stridX
266
- const z = -areaH / 2 + (r + offsetRow + 0.5) * stridZ
267
- out.push({ x, y: layer * cd + cd / 2, z })
268
- }
269
- remaining -= take
270
- layer++
271
- if (lcols === 1 && lrows === 1) {
272
- // 정상에 도달 — 남은 건 column 처럼 위로
273
- for (let i = 0; i < remaining; i++) {
274
- out.push({ x: 0, y: (layer + i) * cd + cd / 2, z: 0 })
275
- }
276
- break
277
- }
278
- if (heightLimit && layer >= heightLimit) break
279
- }
280
- return out
281
- }
282
-
283
- // pile — 자연 더미. row 와 비슷하되 가장자리는 약간 옆으로 흘러내림 효과
284
- // (간단 구현 — 단마다 약간 random offset).
285
- if (pattern === 'pile') {
286
- // pseudo-random (인덱스 기반, deterministic)
287
- const rng = (i: number) => ((Math.sin(i * 12.9898) * 43758.5453) % 1 + 1) % 1
288
- for (let i = 0; i < count; i++) {
289
- const layer = Math.floor(i / cellsPerLayer)
290
- const idx = i % cellsPerLayer
291
- const c = idx % cols
292
- const r = Math.floor(idx / cols)
293
- const jitterX = (rng(i * 2) - 0.5) * cw * 0.2
294
- const jitterZ = (rng(i * 2 + 1) - 0.5) * ch * 0.2
295
- const x = -areaW / 2 + (c + 0.5) * stridX + jitterX
296
- const z = -areaH / 2 + (r + 0.5) * stridZ + jitterZ
297
- out.push({ x, y: layer * cd + cd / 2, z })
298
- if (heightLimit && layer >= heightLimit - 1 && idx === cellsPerLayer - 1) break
299
- }
300
- return out
301
- }
302
-
303
- // row (default) / staggered — 격자 적층
304
- for (let i = 0; i < count; i++) {
305
- const layer = Math.floor(i / cellsPerLayer)
306
- if (heightLimit && layer >= heightLimit) break
307
- const idx = i % cellsPerLayer
308
- const c = idx % cols
309
- const r = Math.floor(idx / cols)
310
- let x = -areaW / 2 + (c + 0.5) * stridX
311
- const z = -areaH / 2 + (r + 0.5) * stridZ
312
- // staggered — 홀수 layer 는 1/2 cell offset (벽돌식)
313
- if (pattern === 'staggered' && layer % 2 === 1) {
314
- x += stridX / 2
315
- }
316
- out.push({ x, y: layer * cd + cd / 2, z })
317
- }
318
- return out
319
- }
320
-
321
291
  /** carrierPreset 별 geometry. drum 은 cylinder, 나머지는 box. */
322
292
  private _carrierGeometry(preset: CarrierPreset, w: number, h: number, d: number): THREE.BufferGeometry {
323
293
  if (preset === 'drum') {
@@ -162,7 +162,11 @@ export class StockpileGrid3D extends RealObjectGroup {
162
162
  const ch = state.carrierHeight ?? def.h
163
163
  const cd = state.carrierDepth ?? def.d
164
164
  const gap = state.carrierGap ?? 10
165
- const heightLimit = state.stackHeightLimit as number | undefined
165
+ // stackHeightLimit = *총 적치 높이*(scene unit) → 단(layer) 수로 환산 (아이템 한 단 = cd).
166
+ const rawHeightLimit = state.stackHeightLimit as number | undefined
167
+ const heightLimit = typeof rawHeightLimit === 'number' && rawHeightLimit > 0 && cd > 0
168
+ ? Math.max(1, Math.floor(rawHeightLimit / cd))
169
+ : undefined
166
170
 
167
171
  const cellW = grid.cellW
168
172
  const cellH = grid.cellH
@@ -27,7 +27,8 @@ import {
27
27
  type AttachFrame,
28
28
  type Alignment,
29
29
  type Heights,
30
- type PlacementArchetype
30
+ type PlacementArchetype,
31
+ type SlottedHolder
31
32
  } from '@operato/scene-base'
32
33
 
33
34
  import { StockpileGrid3D } from './stockpile-grid-3d.js'
@@ -99,7 +100,7 @@ const NATURE: ComponentNature = {
99
100
  @sceneComponent('stockpile-grid')
100
101
  export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
101
102
  CarrierHolder(Placeable(ContainerAbstract))
102
- ) {
103
+ ) implements SlottedHolder {
103
104
  declare state: StockpileGridState
104
105
  declare _realObject?: StockpileGrid3D
105
106
 
@@ -192,18 +193,28 @@ export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
192
193
  return this._materializeCarrier(record, p.col, p.row)
193
194
  }
194
195
 
195
- async receiveAt(slotId: string, carrier: Component, _options?: any): Promise<void> {
196
- const p = this.parseCellId(slotId)
197
- if (!p) return
196
+ /**
197
+ * carrier StockpileRecord 추출. id 누락 시 `stkg-` prefix 자동 생성.
198
+ * SlottedHolder duck 의 표준 entry — receiveAt 안에서 호출. cell 정보 (col/row)
199
+ * slotId 의 parseCellId 로 receiveAt 가 처리 — record 자체에는 cell 좌표 미포함
200
+ * (cell array 가 그 컨텍스트 보유).
201
+ */
202
+ recordFromCarrier(carrier: Component, _slotId: string): StockpileRecord {
198
203
  const cstate: any = (carrier as any)?.state ?? {}
199
204
  const cid = cstate.id ?? `stkg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
200
- const record: StockpileRecord = {
205
+ return {
201
206
  id: String(cid),
202
207
  ...(cstate.type ? { type: cstate.type } : {}),
203
208
  ...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
204
209
  ...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
205
210
  ...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
206
211
  }
212
+ }
213
+
214
+ async receiveAt(slotId: string, carrier: Component, _options?: any): Promise<void> {
215
+ const p = this.parseCellId(slotId)
216
+ if (!p) return
217
+ const record = this.recordFromCarrier(carrier, slotId)
207
218
  const records = [...this.recordsOf(p.col, p.row), record]
208
219
  this._setCellRecords(p.col, p.row, records)
209
220
  ;(carrier as any)?.dispose?.()
package/src/stockpile.ts CHANGED
@@ -24,7 +24,8 @@ import {
24
24
  type AttachFrame,
25
25
  type Alignment,
26
26
  type Heights,
27
- type PlacementArchetype
27
+ type PlacementArchetype,
28
+ type SlottedHolder
28
29
  } from '@operato/scene-base'
29
30
 
30
31
  import { Stockpile3D } from './stockpile-3d.js'
@@ -62,6 +63,12 @@ export interface StockpileState extends State {
62
63
  /** 어느 끝에서 빼나. default 'lifo'. */
63
64
  pickPolicy?: PickPolicy
64
65
 
66
+ /**
67
+ * 라우팅 행선지 값 (RoutingTarget). sorter/router 가 carrier.destination 과 string
68
+ * 매칭해 이 StockPile 로 분배. 미설정 시 routingDestination() 가 state.id 로 fallback.
69
+ */
70
+ destination?: string
71
+
65
72
  /** click 시 invoke 할 Popup 컴포넌트 id (StorageRack / RackGrid 와 동일 패턴). */
66
73
  popupRef?: string
67
74
 
@@ -94,6 +101,7 @@ const NATURE: ComponentNature = {
94
101
  { type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
95
102
  { type: 'select', label: 'pick-policy', name: 'pickPolicy',
96
103
  property: { options: ['lifo', 'fifo'] } },
104
+ { type: 'string', label: 'destination', name: 'destination' },
97
105
  { type: 'id-input', label: 'popup-ref', name: 'popupRef',
98
106
  property: { component: 'popup' } },
99
107
  { type: 'id-input', label: 'legend-target', name: 'legendTarget',
@@ -106,7 +114,7 @@ const NATURE: ComponentNature = {
106
114
  @sceneComponent('stockpile')
107
115
  export default class Stockpile extends RecordStorage<StockpileRecord>()(
108
116
  CarrierHolder(Placeable(ContainerAbstract))
109
- ) {
117
+ ) implements SlottedHolder {
110
118
  declare state: StockpileState
111
119
  declare _realObject?: Stockpile3D
112
120
 
@@ -114,6 +122,22 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
114
122
  static align: Alignment = 'bottom'
115
123
  static defaultDepth = (_h: Heights) => 5 // pad 두께
116
124
 
125
+ /**
126
+ * RoutingTarget — 자기 행선지 값 publish. sorter 가 carrier.destination 과 매칭.
127
+ * 미설정 시 자기 id 로 (단일 sink 직접 지정 fallback). chute 와 동일 규약.
128
+ */
129
+ routingDestination(): string {
130
+ return (this.state.destination as string | undefined) ?? (this.state.id as string)
131
+ }
132
+
133
+ // ── 그래프 참여 ─────────────────────────────────────────────────────────────
134
+ // StockPile 은 *방향 없는 받는 footprint*. 별도 면 anchor 를 publish 하지 않는다 —
135
+ // 어느 면으로 받는지는 의미 없고, 슈트/컨베이어의 *출구 anchor 가 자기 footprint 에
136
+ // 닿으면*(matchOutbounds 의 deliversInto, bounds 기반) 자동 연결된다. (사용자 명시
137
+ // 2026-06-14: "스톡파일은 슈트의 출구와 면한 부분이 있으면 그곳을 링크".) RoutingTarget
138
+ // (routingDestination) 만으로 그래프 노드로 수집되고, bounds 는 coordinator 가 채운다.
139
+ // 멀리 떨어진 타겟은 명시 링크(nextRef/RoutingLinks)로 연결.
140
+
117
141
  get nature(): ComponentNature { return NATURE }
118
142
  get anchors() { return [] }
119
143
 
@@ -135,6 +159,11 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
135
159
  return true
136
160
  }
137
161
 
162
+ /** Transferable contract — dispatch/Transfer planning 이 capacity 확인에 호출. 단일 slot 위임. */
163
+ canReceive(carrier?: Component): boolean {
164
+ return this.canReceiveAt(SLOT_ID, carrier)
165
+ }
166
+
138
167
  occupiedSlotIds(): ReadonlyArray<string> {
139
168
  return this.inventoryCount > 0 ? [SLOT_ID] : []
140
169
  }
@@ -208,19 +237,28 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
208
237
  }
209
238
 
210
239
  /**
211
- * carrier 받아 record push, carrier 객체는 dispose (시각은 _realObject
212
- * records 길이 기준 자동 갱신). capacity 초과 시도는 canReceiveAt 이미 차단.
240
+ * carrier state StockpileRecord 추출. id 누락 `stk-` prefix 자동 생성.
241
+ * transform/refid *_물리적 위치 필드_* *_제외_* record *_논리적 식별_*
242
+ * 만. SlottedHolder duck 의 표준 entry point — receiveAt 안에서 호출.
213
243
  */
214
- async receiveAt(_slotId: string, carrier: Component, _options?: any): Promise<void> {
244
+ recordFromCarrier(carrier: Component, _slotId: string): StockpileRecord {
215
245
  const cstate: any = (carrier as any)?.state ?? {}
216
246
  const cid = cstate.id ?? `stk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
217
- const record: StockpileRecord = {
247
+ return {
218
248
  id: String(cid),
219
249
  ...(cstate.type ? { type: cstate.type } : {}),
220
250
  ...(typeof cstate.width === 'number' ? { width: cstate.width } : {}),
221
251
  ...(typeof cstate.height === 'number' ? { height: cstate.height } : {}),
222
252
  ...(typeof cstate.depth === 'number' ? { depth: cstate.depth } : {})
223
253
  }
254
+ }
255
+
256
+ /**
257
+ * carrier 를 받아 record 로 push, carrier 객체는 dispose (시각은 _realObject 가
258
+ * records 길이 기준 자동 갱신). capacity 초과 시도는 canReceiveAt 가 이미 차단.
259
+ */
260
+ async receiveAt(slotId: string, carrier: Component, _options?: any): Promise<void> {
261
+ const record = this.recordFromCarrier(carrier, slotId)
224
262
  const records = [...this.records, record]
225
263
  this._setDataSilently(records)
226
264
  ;(carrier as any)?.dispose?.()