@operato/scene-storage 10.0.0-beta.48 → 10.0.0-beta.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/box.js +2 -2
  3. package/dist/box.js.map +1 -1
  4. package/dist/index.d.ts +9 -0
  5. package/dist/index.js +6 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/pallet.js +2 -2
  8. package/dist/pallet.js.map +1 -1
  9. package/dist/parcel.js +2 -2
  10. package/dist/parcel.js.map +1 -1
  11. package/dist/picking-station-3d.d.ts +20 -0
  12. package/dist/picking-station-3d.js +162 -0
  13. package/dist/picking-station-3d.js.map +1 -0
  14. package/dist/picking-station.d.ts +50 -0
  15. package/dist/picking-station.js +186 -0
  16. package/dist/picking-station.js.map +1 -0
  17. package/dist/rack-capability.d.ts +11 -0
  18. package/dist/rack-capability.js +25 -0
  19. package/dist/rack-capability.js.map +1 -0
  20. package/dist/rack-grid.d.ts +4 -22
  21. package/dist/rack-grid.js +23 -115
  22. package/dist/rack-grid.js.map +1 -1
  23. package/dist/spot.d.ts +1 -0
  24. package/dist/spot.js +6 -2
  25. package/dist/spot.js.map +1 -1
  26. package/dist/stockpile-3d.d.ts +55 -0
  27. package/dist/stockpile-3d.js +387 -0
  28. package/dist/stockpile-3d.js.map +1 -0
  29. package/dist/stockpile-grid-3d.d.ts +30 -0
  30. package/dist/stockpile-grid-3d.js +301 -0
  31. package/dist/stockpile-grid-3d.js.map +1 -0
  32. package/dist/stockpile-grid.d.ts +85 -0
  33. package/dist/stockpile-grid.js +361 -0
  34. package/dist/stockpile-grid.js.map +1 -0
  35. package/dist/stockpile.d.ts +116 -0
  36. package/dist/stockpile.js +345 -0
  37. package/dist/stockpile.js.map +1 -0
  38. package/dist/storage-rack.d.ts +39 -44
  39. package/dist/storage-rack.js +71 -146
  40. package/dist/storage-rack.js.map +1 -1
  41. package/dist/templates/index.d.ts +80 -0
  42. package/dist/templates/index.js +7 -1
  43. package/dist/templates/index.js.map +1 -1
  44. package/dist/templates/picking-station.d.ts +20 -0
  45. package/dist/templates/picking-station.js +22 -0
  46. package/dist/templates/picking-station.js.map +1 -0
  47. package/dist/templates/stockpile-grid.d.ts +37 -0
  48. package/dist/templates/stockpile-grid.js +38 -0
  49. package/dist/templates/stockpile-grid.js.map +1 -0
  50. package/dist/templates/stockpile.d.ts +29 -0
  51. package/dist/templates/stockpile.js +31 -0
  52. package/dist/templates/stockpile.js.map +1 -0
  53. package/package.json +3 -3
  54. package/src/box.ts +2 -1
  55. package/src/index.ts +14 -0
  56. package/src/pallet.ts +2 -1
  57. package/src/parcel.ts +2 -1
  58. package/src/picking-station-3d.ts +164 -0
  59. package/src/picking-station.ts +220 -0
  60. package/src/rack-capability.ts +26 -0
  61. package/src/rack-grid.ts +24 -108
  62. package/src/spot.ts +15 -1
  63. package/src/stockpile-3d.ts +412 -0
  64. package/src/stockpile-grid-3d.ts +327 -0
  65. package/src/stockpile-grid.ts +408 -0
  66. package/src/stockpile.ts +427 -0
  67. package/src/storage-rack.ts +82 -137
  68. package/src/templates/index.ts +7 -1
  69. package/src/templates/picking-station.ts +23 -0
  70. package/src/templates/stockpile-grid.ts +39 -0
  71. package/src/templates/stockpile.ts +32 -0
  72. package/test/test-rack-capability.ts +51 -0
  73. package/translations/en.json +23 -6
  74. package/translations/ja.json +23 -6
  75. package/translations/ko.json +22 -5
  76. package/translations/ms.json +23 -6
  77. package/translations/zh.json +22 -5
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,220 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * PickingStation — 사람(또는 자동화) 작업 위치. carrier 가 도착하면 *_processingTimeMs_*
5
+ * 동안 머문 뒤 status='idle' 로 자동 전환되어 다음 mover 가 가져갈 수 있다.
6
+ *
7
+ * 단일 slot SlottedHolder (Spot 비슷) + 처리 시간/상태 + popupRef + click raycast.
8
+ * 3D 는 pad(영역) + 작업대(가운데 box) — picking/QC 작업 자리의 인지성을 위해.
9
+ */
10
+
11
+ import * as THREE from 'three'
12
+ import { Component, ComponentNature, ContainerAbstract, RealObject, sceneComponent } from '@hatiolab/things-scene'
13
+ import type { State, Material3D } from '@hatiolab/things-scene'
14
+ import {
15
+ CarrierHolder,
16
+ Placeable,
17
+ SingleSlotHolder,
18
+ type AttachFrame,
19
+ type Alignment,
20
+ type Heights,
21
+ type PlacementArchetype,
22
+ type ProcessStep
23
+ } from '@operato/scene-base'
24
+
25
+ import { PickingStation3D } from './picking-station-3d.js'
26
+
27
+ export type PickingStationStatus = 'idle' | 'processing' | 'busy'
28
+
29
+ export interface PickingStationState extends State {
30
+ /** carrier 가 머무는 처리 시간 (ms). 0/미설정이면 즉시 idle 유지. */
31
+ processingTimeMs?: number
32
+ /** 현재 상태 (자동). */
33
+ status?: PickingStationStatus
34
+ /** click 시 invoke 할 Popup 컴포넌트 id. */
35
+ popupRef?: string
36
+ material3d?: Material3D
37
+ }
38
+
39
+ const SLOT_ID = 'station'
40
+
41
+ const NATURE: ComponentNature = {
42
+ mutable: false,
43
+ resizable: true,
44
+ rotatable: true,
45
+ properties: [
46
+ { type: 'number', label: 'processing-time-ms', name: 'processingTimeMs' },
47
+ { type: 'select', label: 'status', name: 'status',
48
+ property: { options: ['idle', 'processing', 'busy'] } },
49
+ { type: 'id-input', label: 'popup-ref', name: 'popupRef',
50
+ property: { component: 'popup' } }
51
+ ],
52
+ help: 'scene/component/picking-station'
53
+ }
54
+
55
+ @sceneComponent('picking-station')
56
+ export default class PickingStation
57
+ extends SingleSlotHolder()(CarrierHolder(Placeable(ContainerAbstract)))
58
+ implements ProcessStep
59
+ {
60
+ declare state: PickingStationState
61
+ declare _realObject?: PickingStation3D
62
+
63
+ static placement: PlacementArchetype = 'floor'
64
+ static align: Alignment = 'bottom'
65
+ static defaultDepth = (h: Heights) => h.operation - h.floor
66
+
67
+ get nature(): ComponentNature { return NATURE }
68
+ get anchors() { return [] }
69
+
70
+ // SingleSlotHolder hook overrides ───────────────────────────────────────────
71
+ _singleSlotId() { return SLOT_ID }
72
+
73
+ /**
74
+ * SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds
75
+ * / emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt
76
+ * / getSlotAttachObject3d) — SingleSlotHolder mixin 제공.
77
+ *
78
+ * receiveAt 후 부가 dwell 동작은 `_onCarrierReceived` hook 으로 위임.
79
+ */
80
+ _onCarrierReceived(_carrier: Component, _options?: any) {
81
+ const procMs = this.state.processingTimeMs ?? 0
82
+ if (procMs <= 0) return
83
+ this.setState({ status: 'processing' as PickingStationStatus })
84
+ setTimeout(() => {
85
+ if ((this.state.status as PickingStationStatus) === 'processing') {
86
+ this.setState({ status: 'idle' as PickingStationStatus })
87
+ }
88
+ }, procMs)
89
+ }
90
+
91
+ // Phase ZT-V PR-6 ProcessStep ─────────────────────────────────────────────
92
+ readonly isProcessStep = true as const
93
+ get processingTimeMs() { return this.state.processingTimeMs ?? 0 }
94
+
95
+ /** carrier 를 작업대(table) 상단에 안착. */
96
+ attachPointFor(carrier: Component): AttachFrame | null {
97
+ const ro = this._realObject
98
+ const frame = ro?.getAttachFrame?.()
99
+ if (!frame) return null
100
+ const carrierDepth = resolveDepth(carrier)
101
+ return { attach: frame, localPosition: { x: 0, y: carrierDepth / 2, z: 0 } }
102
+ }
103
+
104
+ // ── 2D render ───────────────────────────────────────────────
105
+ render(ctx: CanvasRenderingContext2D) {
106
+ const { left = 0, top = 0, width = 100, height = 100 } = this.state
107
+ const fillStyle = (this.state.fillStyle as string) || '#5a8ab8'
108
+ const strokeStyle = (this.state.strokeStyle as string) || '#3d6a8f'
109
+ const status = (this.state.status ?? 'idle') as PickingStationStatus
110
+
111
+ // pad
112
+ ctx.save()
113
+ ctx.fillStyle = fillStyle
114
+ ctx.globalAlpha = 0.15
115
+ ctx.fillRect(left, top, width, height)
116
+ ctx.restore()
117
+
118
+ // outline
119
+ ctx.save()
120
+ ctx.strokeStyle = strokeStyle
121
+ ctx.lineWidth = 1.5
122
+ ctx.strokeRect(left + 0.75, top + 0.75, width - 1.5, height - 1.5)
123
+ ctx.restore()
124
+
125
+ // 작업대 (가운데 작은 사각)
126
+ const tw = width * 0.55, th = height * 0.45
127
+ const tx = left + (width - tw) / 2
128
+ const ty = top + (height - th) / 2
129
+ ctx.save()
130
+ ctx.fillStyle = strokeStyle
131
+ ctx.globalAlpha = 0.35
132
+ ctx.fillRect(tx, ty, tw, th)
133
+ ctx.restore()
134
+
135
+ // 상태
136
+ ctx.save()
137
+ const fontSize = Math.min(width, height) * 0.16
138
+ ctx.fillStyle = '#222'
139
+ ctx.font = `bold ${fontSize}px sans-serif`
140
+ ctx.textAlign = 'center'
141
+ ctx.textBaseline = 'middle'
142
+ ctx.fillText(status.toUpperCase(), left + width / 2, top + height / 2)
143
+ ctx.restore()
144
+ }
145
+
146
+ // ── Popup + click ────────────────────────────────────────────
147
+ get eventMap() {
148
+ return { '(self)': { '(self)': { click: this._onStationClick } } }
149
+ }
150
+
151
+ private _onStationClick = (mouseEvent: MouseEvent) => {
152
+ if (!(this as any).app?.isViewMode) return
153
+ const hit = this._raycastStationHit(mouseEvent)
154
+ if (!hit) return
155
+ this._invokePopup()
156
+ }
157
+
158
+ private _invokePopup(): void {
159
+ const popupRefId = this.state.popupRef
160
+ if (!popupRefId) return
161
+ const popupComp: any = (this as any).root?.findById?.(popupRefId)
162
+ if (!popupComp || typeof popupComp.openPopup !== 'function') return
163
+ const anchor = this.slotTargetAt(SLOT_ID)
164
+ const carrier = this.obtainCarrier(SLOT_ID)
165
+ popupComp.openPopup({
166
+ componentId: (this.state as any).id,
167
+ status: this.state.status ?? 'idle',
168
+ processingTimeMs: this.state.processingTimeMs,
169
+ currentCarrierId: (carrier as any)?.state?.id ?? null
170
+ }, { anchor })
171
+ }
172
+
173
+ private _raycastStationHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
174
+ const ro: any = (this as any)._realObject
175
+ if (!ro?.object3d) return undefined
176
+ const tc: any = ro.threeContainer
177
+ if (!tc) return undefined
178
+ const cap: any = tc._threeCapability ?? tc._capability
179
+ let intersects: THREE.Intersection[] | undefined
180
+ if (cap?.getObjectsByRaycast) intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
181
+ if (!intersects || intersects.length === 0) {
182
+ const scene = tc.scene3d as THREE.Scene | undefined
183
+ const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
184
+ const camera =
185
+ (tc.activeCamera3d as THREE.Camera | undefined) ??
186
+ (cap?.activeCamera as THREE.Camera | undefined) ??
187
+ (cap?.camera as THREE.Camera | undefined)
188
+ const canvas = renderer?.domElement
189
+ if (!scene || !canvas || !camera) return undefined
190
+ const rect = canvas.getBoundingClientRect()
191
+ if (rect.width === 0 || rect.height === 0) return undefined
192
+ const ndc = new THREE.Vector2(
193
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
194
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
195
+ )
196
+ const raycaster = new THREE.Raycaster()
197
+ raycaster.setFromCamera(ndc, camera)
198
+ intersects = raycaster.intersectObjects(scene.children, true)
199
+ }
200
+ if (!intersects || intersects.length === 0) return undefined
201
+ const closest = intersects[0]
202
+ let obj: THREE.Object3D | null = closest.object
203
+ while (obj) {
204
+ if (obj.userData?.context === ro) return closest
205
+ obj = obj.parent
206
+ }
207
+ return undefined
208
+ }
209
+
210
+ buildRealObject(): RealObject | undefined {
211
+ return new PickingStation3D(this)
212
+ }
213
+ }
214
+
215
+ function resolveDepth(c: Component): number {
216
+ const eff = (c as any)._realObject?.effectiveDepth
217
+ if (typeof eff === 'number' && Number.isFinite(eff)) return eff
218
+ const d = (c as any)?.state?.depth
219
+ return typeof d === 'number' && Number.isFinite(d) ? d : 0
220
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ *
4
+ * Rack 적재 mover 능력 판정 — *_순수 로직_* (things-scene 무관). storage-rack 의
5
+ * canAcceptFromMover 가 위임. mocha 환경에서 StorageRack 직접 import 불가하므로
6
+ * 판정 로직만 분리해 단위 검증.
7
+ */
8
+
9
+ /**
10
+ * rack 이 *_이 mover 의 toolType_* 을 선반 적재용으로 수용하는가.
11
+ *
12
+ * rack 선반 적재는 높이 도달 mover (crane / stacker / forklift) 의 몫. 평탄 데크
13
+ * 차량(agv-deck)은 바닥 운반 전용 — 선반 직접 적재 불가 → 거부. 거부된 mover 는
14
+ * transfer planner 가 자동으로 in-port 경유(환승)를 택하게 만든다.
15
+ *
16
+ * @param moverToolType 적재하려는 mover 의 toolType (undefined 면 능력 미상 → 허용)
17
+ * @param blockedTools 거부 toolType 목록 (default ['agv-deck'])
18
+ */
19
+ export function rackAcceptsMoverTool(
20
+ moverToolType: string | undefined | null,
21
+ blockedTools: readonly string[] = ['agv-deck']
22
+ ): boolean {
23
+ if (moverToolType == null) return true
24
+ if (!Array.isArray(blockedTools)) return true
25
+ return !blockedTools.includes(moverToolType)
26
+ }
package/src/rack-grid.ts CHANGED
@@ -38,7 +38,9 @@ import * as THREE from 'three'
38
38
  import {
39
39
  CarrierHolder,
40
40
  Placeable,
41
+ RecordStorage,
41
42
  SlotTarget,
43
+ componentBoundingBox,
42
44
  type AttachFrame,
43
45
  type Alignment,
44
46
  type Heights,
@@ -267,25 +269,33 @@ export interface RackGridState extends State {
267
269
 
268
270
  @sceneComponent('rack-grid')
269
271
  export default class RackGrid
270
- extends CarrierHolder(Placeable(ContainerAbstract))
272
+ extends RecordStorage<{ cellId: string; [key: string]: any }>()(
273
+ CarrierHolder(Placeable(ContainerAbstract))
274
+ )
271
275
  implements SlottedHolder
272
276
  {
273
277
  declare state: RackGridState
274
278
 
279
+ // RecordStorage mixin hook overrides.
280
+ // record.cellId 가 slotId (3-segment '{col}-{row}-{shelf}' format).
281
+ _recordToSlotId(record: { cellId: string }): string {
282
+ return record.cellId ?? ''
283
+ }
284
+ // 3D rebuild — rebuildStockMesh 우선, 없으면 mixin default.
285
+ _rebuildVisual(): void {
286
+ const ro = (this as any)._realObject
287
+ if (ro?.rebuildStockMesh) ro.rebuildStockMesh()
288
+ else if (ro?.update) ro.update()
289
+ }
290
+
275
291
  // Phase Auto-Nav (AN-PR-2) — Obstacle 자격. AMR / mover 의 자동 path planning
276
292
  // 시 회피 대상. `state.isObstacle` 명시 false 시 override (예외 처리).
277
293
  get isObstacle(): boolean {
278
294
  return (this.state as any)?.isObstacle !== false
279
295
  }
280
296
  obstacleBoundingBox(): { left: number; top: number; width: number; height: number; y?: number; zHeight?: number } | null {
281
- const s: any = this.state
282
- if (typeof s?.left !== 'number') return null
283
- return {
284
- left: s.left, top: s.top,
285
- width: s.width, height: s.height,
286
- y: typeof s.zPos === 'number' ? s.zPos : 0,
287
- zHeight: typeof s.depth === 'number' ? s.depth : 0
288
- }
297
+ // scene-base componentBoundingBox 위임 — rotation 적용된 AABB.
298
+ return componentBoundingBox(this)
289
299
  }
290
300
 
291
301
  static placement: PlacementArchetype = 'floor'
@@ -422,63 +432,8 @@ export default class RackGrid
422
432
  ;(this._realObject as any)?.rebuildStockMesh?.()
423
433
  }
424
434
 
425
- /** state.data records Plan A stock 보관소. */
426
- get records(): Array<{ cellId: string; [key: string]: any }> {
427
- return (this.state.data as any) ?? []
428
- }
429
-
430
- // ── Legend integration ──────────────────────────────────
431
-
432
- private _legendTarget?: Component
435
+ // records / legendTarget / _onLegendChanged / resolveLegendColor RecordStorage mixin 제공.
433
436
 
434
- /**
435
- * Legend 컴포넌트 lookup. 우선순위:
436
- * 1) state.legendTarget id 명시
437
- * 2) scene 전체 의 type='legend' 첫 번째 컴포넌트 (자동 발견)
438
- */
439
- get legendTarget(): Component | undefined {
440
- if (this._legendTarget) return this._legendTarget
441
-
442
- const id = this.state.legendTarget
443
- if (id) {
444
- const found = (this.root as any)?.findById?.(id) as Component | undefined
445
- if (found) {
446
- this._legendTarget = found
447
- ;(found as any).on?.('change', this._onLegendChanged, this)
448
- return found
449
- }
450
- }
451
-
452
- const visit = (node: any): Component | undefined => {
453
- if (!node) return undefined
454
- if (node.state?.type === 'legend') return node as Component
455
- const children = node.components as Component[] | undefined
456
- if (children) {
457
- for (const c of children) {
458
- const r = visit(c)
459
- if (r) return r
460
- }
461
- }
462
- return undefined
463
- }
464
- const found = visit(this.root)
465
- if (found) {
466
- this._legendTarget = found
467
- ;(found as any).on?.('change', this._onLegendChanged, this)
468
- }
469
- return found
470
- }
471
-
472
- private _onLegendChanged = (): void => {
473
- ;(this._realObject as any)?.rebuildStockMesh?.()
474
- }
475
-
476
- /**
477
- * record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
478
- * - `range.value === recordValue` (카테고리 일치)
479
- * - `range.min ≤ Number(v) < range.max` (수치 범위)
480
- * - 매칭 없으면 `defaultColor` 또는 undefined
481
- */
482
437
  // ── View-mode click → rack-grid-cell-click event + popup ──
483
438
 
484
439
  get eventMap() {
@@ -521,24 +476,13 @@ export default class RackGrid
521
476
 
522
477
  const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
523
478
  this.trigger('rack-grid-cell-click', payload)
524
- this._invokePopup(cellId, record)
479
+ if (cellId) this._invokePopup(cellId, record ?? { cellId })
525
480
 
526
481
  // popup 외부 click 으로 인식되어 자동 close 되는 회귀 차단
527
482
  mouseEvent.stopPropagation?.()
528
483
  }
529
484
 
530
- /** state.popupRef Popup 컴포넌트 invoke. anchor = 클릭된 cell 의 SlotTarget. */
531
- private _invokePopup(cellId: string | undefined, record: any): void {
532
- const popupRefId = this.state.popupRef
533
- if (!popupRefId || !cellId) return
534
- const popupComp: any = (this.root as any)?.findById?.(popupRefId)
535
- if (!popupComp || typeof popupComp.openPopup !== 'function') {
536
- console.warn(`[rack-grid] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
537
- return
538
- }
539
- const anchor = this.slotTargetAt(cellId)
540
- popupComp.openPopup(record ?? { cellId }, { anchor })
541
- }
485
+ // _invokePopup RecordStorage mixin 제공 (signature 동일: slotId=cellId, payload=record).
542
486
 
543
487
  /** raycast → 우리 RackGrid 의 어떤 mesh 가 closest hit 인지. */
544
488
  private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
@@ -617,35 +561,7 @@ export default class RackGrid
617
561
  return `${col}-${row}-${shelf}`
618
562
  }
619
563
 
620
- resolveLegendColor(record: any): string | undefined {
621
- const legend = this.legendTarget
622
- if (!legend) return undefined
623
- const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
624
- if (!status) return undefined
625
-
626
- const field = status.field as string | undefined
627
- const ranges = status.ranges as any[] | undefined
628
- if (!field || !Array.isArray(ranges)) return undefined
629
-
630
- const value = record?.[field]
631
- if (value === undefined || value === null) return status.defaultColor
632
-
633
- for (const range of ranges) {
634
- if (!range) continue
635
- if (range.value !== undefined) {
636
- if (range.value === value) return range.color
637
- continue
638
- }
639
- const num = Number(value)
640
- if (!Number.isFinite(num)) continue
641
- const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
642
- const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
643
- const minOk = min === undefined || num >= min
644
- const maxOk = max === undefined || num < max
645
- if (minOk && maxOk) return range.color
646
- }
647
- return status.defaultColor as string | undefined
648
- }
564
+ // resolveLegendColor RecordStorage mixin 제공.
649
565
 
650
566
  /**
651
567
  * 새 (rows × columns) 에 맞춰 children 재구성. rack-table.buildCells 정확 클론.
@@ -1256,7 +1172,7 @@ export default class RackGrid
1256
1172
  const cellId = this._resolveToCellId(slotIdOrLocation)
1257
1173
  if (!cellId) return false
1258
1174
  if (this._carrierChildAt(cellId)) return true
1259
- return this.records.some(r => r.cellId === cellId)
1175
+ return this.records.some((r: any) => r.cellId === cellId)
1260
1176
  }
1261
1177
 
1262
1178
  /**
package/src/spot.ts CHANGED
@@ -34,6 +34,7 @@ import type { State, Material3D } from '@hatiolab/things-scene'
34
34
  import {
35
35
  CarrierHolder,
36
36
  Placeable,
37
+ SingleSlotHolder,
37
38
  type AttachFrame,
38
39
  type Alignment,
39
40
  type Heights,
@@ -68,7 +69,11 @@ const NATURE: ComponentNature = {
68
69
  // which forces `isHTMLElement(): true` and trips the 3D pipeline's
69
70
  // addObject DOM-skip gate. Spot is purely 3D.
70
71
  @sceneComponent('spot')
71
- export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
72
+ export default class Spot extends SingleSlotHolder()(
73
+ CarrierHolder(Placeable(ContainerAbstract))
74
+ ) {
75
+ // SingleSlotHolder hook override — Spot 의 slot id 'pad'.
76
+ _singleSlotId() { return SPOT_SLOT_ID }
72
77
  declare state: SpotState
73
78
  declare _realObject?: Spot3D
74
79
 
@@ -167,8 +172,17 @@ export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
167
172
  localPosition: { x: 0, y: carrierDepth / 2, z: 0 }
168
173
  }
169
174
  }
175
+
176
+ // SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds /
177
+ // emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt /
178
+ // getSlotAttachObject3d) — SingleSlotHolder mixin 제공.
179
+ // virtual location handoff buffer 동작: receiveAt 가 reparent (components +
180
+ // 3D) 로 carrier 를 자식으로 유지 → 다음 mover 가 obtainCarrier 로 승계.
170
181
  }
171
182
 
183
+ /** Spot 의 유일 virtual slot id. */
184
+ const SPOT_SLOT_ID = 'pad'
185
+
172
186
  function resolveDepth(c: Component): number {
173
187
  const eff = (c as any)._realObject?.effectiveDepth
174
188
  if (typeof eff === 'number' && Number.isFinite(eff)) return eff