@operato/scene-storage 10.0.0-beta.56 → 10.0.0-beta.58

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
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import * as THREE from 'three'
20
- import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
20
+ import { Component, ComponentNature, ContainerAbstract, Holdable, RealObject, sceneComponent } from '@hatiolab/things-scene'
21
21
  import type { State, Material3D } from '@hatiolab/things-scene'
22
22
  import {
23
23
  CarrierHolder,
@@ -99,7 +99,7 @@ const NATURE: ComponentNature = {
99
99
 
100
100
  @sceneComponent('stockpile-grid')
101
101
  export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
102
- CarrierHolder(Placeable(ContainerAbstract))
102
+ Holdable(CarrierHolder(Placeable(ContainerAbstract)))
103
103
  ) implements SlottedHolder {
104
104
  declare state: StockpileGridState
105
105
  declare _realObject?: StockpileGrid3D
@@ -285,7 +285,7 @@ export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
285
285
  }
286
286
 
287
287
  const carrier = new CarrierClass(carrierState, (this as any)._app)
288
- ;(this as any).addComponent(carrier, { silent: true })
288
+ ;(this as any).addHolding(carrier) // transient carrier _holdings (§1.2)
289
289
  void (carrier as any).realObject
290
290
  ;(carrier as any).applyHolderAttachPoint?.()
291
291
  return carrier
package/src/stockpile.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import * as THREE from 'three'
17
- import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
17
+ import { Component, ComponentNature, ContainerAbstract, Holdable, RealObject, sceneComponent } from '@hatiolab/things-scene'
18
18
  import type { State, Material3D } from '@hatiolab/things-scene'
19
19
  import {
20
20
  CarrierHolder,
@@ -63,6 +63,12 @@ export interface StockpileState extends State {
63
63
  /** 어느 끝에서 빼나. default 'lifo'. */
64
64
  pickPolicy?: PickPolicy
65
65
 
66
+ /**
67
+ * 라우팅 행선지 값 (RoutingTarget). sorter/router 가 carrier.destination 과 string
68
+ * 매칭해 이 StockPile 로 분배. 미설정 시 routingDestination() 가 state.id 로 fallback.
69
+ */
70
+ destination?: string
71
+
66
72
  /** click 시 invoke 할 Popup 컴포넌트 id (StorageRack / RackGrid 와 동일 패턴). */
67
73
  popupRef?: string
68
74
 
@@ -95,6 +101,7 @@ const NATURE: ComponentNature = {
95
101
  { type: 'number', label: 'stack-height-limit', name: 'stackHeightLimit' },
96
102
  { type: 'select', label: 'pick-policy', name: 'pickPolicy',
97
103
  property: { options: ['lifo', 'fifo'] } },
104
+ { type: 'string', label: 'destination', name: 'destination' },
98
105
  { type: 'id-input', label: 'popup-ref', name: 'popupRef',
99
106
  property: { component: 'popup' } },
100
107
  { type: 'id-input', label: 'legend-target', name: 'legendTarget',
@@ -106,7 +113,7 @@ const NATURE: ComponentNature = {
106
113
 
107
114
  @sceneComponent('stockpile')
108
115
  export default class Stockpile extends RecordStorage<StockpileRecord>()(
109
- CarrierHolder(Placeable(ContainerAbstract))
116
+ Holdable(CarrierHolder(Placeable(ContainerAbstract)))
110
117
  ) implements SlottedHolder {
111
118
  declare state: StockpileState
112
119
  declare _realObject?: Stockpile3D
@@ -115,6 +122,22 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
115
122
  static align: Alignment = 'bottom'
116
123
  static defaultDepth = (_h: Heights) => 5 // pad 두께
117
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
+
118
141
  get nature(): ComponentNature { return NATURE }
119
142
  get anchors() { return [] }
120
143
 
@@ -136,6 +159,11 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
136
159
  return true
137
160
  }
138
161
 
162
+ /** Transferable contract — dispatch/Transfer planning 이 capacity 확인에 호출. 단일 slot 위임. */
163
+ canReceive(carrier?: Component): boolean {
164
+ return this.canReceiveAt(SLOT_ID, carrier)
165
+ }
166
+
139
167
  occupiedSlotIds(): ReadonlyArray<string> {
140
168
  return this.inventoryCount > 0 ? [SLOT_ID] : []
141
169
  }
@@ -201,8 +229,11 @@ export default class Stockpile extends RecordStorage<StockpileRecord>()(
201
229
  }
202
230
 
203
231
  const carrier = new CarrierClass(carrierState, (this as any)._app)
204
- // silent: refreshMappings cascade 차단 (transient 매핑 재계산 불필요)
205
- ;(this as any).addComponent(carrier, { silent: true })
232
+ // transient carrier _holdings (모델링 자식 `_components` 아님). Mover.pick
233
+ // reparent 호출 시 ContainerAbstract.addComponent oldContainer._holdings
234
+ // 도 자동 정리 (stale 방지, §1.2 직교). board JSON 직렬화 / Inspector 표시
235
+ // 자동 차단 — Spot 시범 패턴 (2026-06-16).
236
+ ;(this as any).addHolding(carrier)
206
237
  void (carrier as any).realObject
207
238
  ;(carrier as any).applyHolderAttachPoint?.()
208
239
  return carrier
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
- import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
4
+ import { Component, ComponentNature, ContainerAbstract, Holdable, RealObject, sceneComponent } from '@hatiolab/things-scene'
5
5
  import type { State, Material3D } from '@hatiolab/things-scene'
6
6
  import * as THREE from 'three'
7
7
  import {
@@ -168,7 +168,7 @@ function _nextCarrierRefid(): number {
168
168
  @sceneComponent('storage-rack')
169
169
  export default class Rack
170
170
  extends RecordStorage<{ cellId: string; [key: string]: any }>()(
171
- CellContainer(CarrierHolder(Placeable(ContainerAbstract)))
171
+ CellContainer(Holdable(CarrierHolder(Placeable(ContainerAbstract))))
172
172
  )
173
173
  implements SlottedHolder
174
174
  {
@@ -516,7 +516,7 @@ export default class Rack
516
516
  // 만든 게 아니라 *내부적으로 잠시 빌리는 것*. 새 컴포넌트 추가 → root.onadded
517
517
  // → refreshMappings (1s debounce) → 모든 컴포넌트 매핑 재실행 의 cascade 차단.
518
518
  // (silent 모드: _silentAdd 플래그 set → refreshMappings skip)
519
- ;(this as any).addComponent(carrier, { silent: true })
519
+ ;(this as any).addHolding(carrier) // transient carrier _holdings (§1.2)
520
520
 
521
521
  // 3D 강제 빌드 + attach (pipeline tick 없이도 즉시 표시 — Mover 가 곧 pick).
522
522
  void (carrier as any).realObject