feat(editor): preset-system primitives (#340) (#341)

* feat(editor): preset-system primitives — presettable, sceneApi subtree round-trip, isolate + setCaptureMode enum, headless exports

Per pascalorg/editor#340 (redesigned: single live canvas, no Viewer scene prop).

Core
- `capabilities.presettable` on `NodeDefinition` + `isPresettable` /
  `isPresettableKind` helpers. Explicit `false` on level / building /
  site / zone / spawn / guide / scan / item; implicit `true` for any
  kind with `def.parametrics`.
- `sceneApi.getSubtreeSnapshot(rootId)` + `materializeSubtree(subtree,
  position, parentId?)` for round-tripping a node subtree through
  catalog storage. Strips id / parentId / absolute root position /
  host refs (`wallId`, `wallT`); fresh IDs minted at materialize time;
  child ordering preserved (FIFO walk).

Viewer
- `<Viewer isolate>` prop + `ViewerHandle.setIsolated(ids | null)`.
  Walks `sceneRegistry`, hides every registered group not in the
  isolated set's ancestor + descendant closure. Building block for
  preset capture + future focus-mode UX.

Editor
- `useEditor.captureMode: CaptureMode` discriminated union
  (`idle` | `standard` | `preset`). `isCaptureMode` stays as a derived
  boolean for the existing read sites; `setCaptureMode` accepts both
  the boolean shape (back-compat) and the enum.
- `preset` capture mode in `SnapshotCaptureOverlay`: drag locked to a
  square, mode-picker hidden, transparent flag forwarded through the
  `camera-controls:generate-thumbnail` emitter event.
- Headless exports: `Inspector` (alias of `ParametricInspector`),
  `FloatingMenu` (alias of `FloatingActionMenu`), `ToolbarLeft` /
  `ToolbarRight` (aliases of `ViewerToolbarLeft` / `ViewerToolbarRight`),
  `useSelection` hook returning `{selectedIds, selectedNode, building/
  level/zone}`, plus re-exports of `useScene` / `useViewer` from core /
  viewer so consumer shells (community, embedders) need only one import.

Out of scope by design (see issue #340 "Out of scope"): a separate
offscreen Viewer rendering an arbitrary subtree. The unified preset
modal captures inside the live canvas via isolation + the existing
snapshot pipeline — no `useScene` factory / React context refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): split snapshot/materialize into pure getSubtree + cloneNodesInto; add def.hostRefFields; auto-stage preset capture square

Per pascalorg/editor#340 redesign discussion: the editor's scene API
should expose *pure* primitives and let the host (community modal,
embedders) own storage shape, position stripping, and host-ref
re-derivation policy.

Editor API delta
- `sceneApi.getSubtreeSnapshot(rootId)` → `sceneApi.getSubtree(rootId)`
  Returns the live subtree verbatim (BFS via `children[]`, no clones,
  no stripping). Callers deep-clone if they need persistence.
- `sceneApi.materializeSubtree(subtree, pos, parent?)`
  → `sceneApi.cloneNodesInto(nodes, { rootId, parentId?, position? })`
  Generic clone-and-insert. Deep-clones via JSON, mints fresh ids
  preserving the prefix, rewires parent/children, stamps position +
  parent if supplied. Host-ref-agnostic — `wallId`/`wallT` etc are
  preserved verbatim.
- New `capabilities.hostRefFields?: string[]` on `NodeDefinition`.
  Declares per kind which schema fields are placement-derived so the
  host strips them at preset-save time. Declared on door (`['wallId']`),
  window (`['wallId']`), item (`['wallId', 'wallT']`).
- New `getHostRefFields(def)` exported from `@pascal-app/core`.

Removed the intermediate token-based payload format (`NodeSubtree`,
`buildSubtreeSnapshot`, `materializeSubtree`, `SubtreeNode`).

UX polish
- `<SnapshotCaptureOverlay>` in `preset` mode now auto-stages a centered
  square crop sized to ~75% of the shorter viewport dimension. The
  user can pan / move / resize within square-aspect, but doesn't have
  to drag from scratch — clicking the capture button works
  immediately on entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): lock preset capture frame; allow item presets

- SnapshotCaptureOverlay: in `preset` mode, the auto-staged centered
  square is now fully locked — corner handles hidden, the dim layer is
  click-through (no drag-to-move, no drag-to-resize). The user just
  adjusts the camera (orbit / pan / zoom) and clicks capture. The
  letterbox + dashed border stay visible as a cosmetic frame.
- `item.capabilities.presettable` removed (implicit `true` via
  `def.parametrics`). Enables compositions like "table-with-plants",
  "shelf-with-books" where the preset root may be an item and other
  items ride along as descendants. The GLB-kind item catalog is
  unchanged; presets become siblings of GLB rows under the same
  `items` table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): auto-frame camera on preset capture entry; restore on exit

`<CustomCameraControls>` now watches `useEditor.captureMode` and, when
preset capture mode begins, flies the camera to a pose that fits the
union bounds of the isolated subtree inside the locked square crop —
no more hunting for the subject after opening the modal. The
pre-capture pose is stashed and restored on exit so the user lands
exactly where they were.

The user can still pan / orbit / zoom from the auto-staged pose if
they want a different angle before snapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-28 09:49:50 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 350cad9c89
commit 1fd59dd9cd
31 changed files with 1087 additions and 113 deletions
@@ -110,6 +110,8 @@ function makeScene(nodes: Record<string, AnyNode>): SceneApi {
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
+25 -15
View File
@@ -1,6 +1,24 @@
export type {
ArcResizeHandle,
Cursor,
EditorApi,
EndpointMoveHandle,
HandleAnchor,
HandleAxis,
HandleDescriptor,
HandleList,
HandlePlacement,
HandlePortal,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
} from './handles'
export {
discoverPlugins,
getHostRefFields,
getSelectableKinds,
isPresettable,
isPresettableKind,
isRegistryMovable,
isRegistrySelectable,
kindsWithFloorplanScope,
@@ -17,22 +35,14 @@ export {
collectDescendants,
type SpatialQuery,
} from './relations-resolver'
export type {
ArcResizeHandle,
Cursor,
EditorApi,
EndpointMoveHandle,
HandleAnchor,
HandleAxis,
HandleDescriptor,
HandleList,
HandlePlacement,
HandlePortal,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
} from './handles'
export { createSceneApi, type SceneStoreLike } from './scene-api'
export {
type CloneNodesIntoOptions,
type CloneNodesIntoResult,
cloneNodesInto,
collectSubtree,
type Subtree,
} from './subtree'
export type {
Affordance,
AnyNodeDefinition,
+55 -1
View File
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { loadPlugin, nodeRegistry, registerNode } from './registry'
import {
getHostRefFields,
isPresettable,
isPresettableKind,
loadPlugin,
nodeRegistry,
registerNode,
} from './registry'
import type { AnyNodeDefinition, Plugin } from './types'
function makeDefinition(
@@ -70,6 +77,53 @@ describe('nodeRegistry', () => {
})
})
describe('isPresettable', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('explicit true wins', () => {
const def = makeDefinition('explicit-true', { capabilities: { presettable: true } })
expect(isPresettable(def)).toBe(true)
})
test('explicit false wins even with parametrics', () => {
const def = makeDefinition('explicit-false', {
capabilities: { presettable: false },
parametrics: { groups: [] } as any,
})
expect(isPresettable(def)).toBe(false)
})
test('defaults to true when parametrics exists', () => {
const def = makeDefinition('param', { parametrics: { groups: [] } as any })
expect(isPresettable(def)).toBe(true)
})
test('defaults to false without parametrics', () => {
const def = makeDefinition('no-param')
expect(isPresettable(def)).toBe(false)
})
test('isPresettableKind looks up the registry', () => {
registerNode(makeDefinition('shelfy', { parametrics: { groups: [] } as any }))
expect(isPresettableKind('shelfy')).toBe(true)
expect(isPresettableKind('unknown')).toBe(false)
})
})
describe('getHostRefFields', () => {
test('returns the declared hostRefFields verbatim', () => {
const def = makeDefinition('door', { capabilities: { hostRefFields: ['wallId'] } })
expect(getHostRefFields(def)).toEqual(['wallId'])
})
test('defaults to an empty array when none declared', () => {
const def = makeDefinition('shelf')
expect(getHostRefFields(def)).toEqual([])
})
})
describe('loadPlugin', () => {
beforeEach(() => {
nodeRegistry._reset()
+28
View File
@@ -146,6 +146,34 @@ export function isRegistryMovable(kind: string): boolean {
return false
}
/**
* Whether the kind can be saved as a reusable preset. Default: an
* explicit `capabilities.presettable` boolean wins; otherwise the kind
* is presettable iff it declares `def.parametrics`. Read by host apps
* (community shell) to gate "save as preset" UI on a selection.
*/
export function isPresettable(def: AnyNodeDefinition): boolean {
if (typeof def.capabilities.presettable === 'boolean') {
return def.capabilities.presettable
}
return def.parametrics !== undefined
}
export function isPresettableKind(kind: string): boolean {
const def = nodeRegistry.get(kind)
return def ? isPresettable(def) : false
}
/**
* Names of schema fields on `def` that are host references (`wallId`,
* `wallT`, etc.). Read by host apps at preset-save time to strip these
* from the stored payload — see `def.capabilities.hostRefFields` docs.
* Returns an empty array for kinds that don't declare any.
*/
export function getHostRefFields(def: AnyNodeDefinition): ReadonlyArray<string> {
return def.capabilities.hostRefFields ?? []
}
export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error(
@@ -47,6 +47,8 @@ function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
+33
View File
@@ -1,5 +1,10 @@
import type { AnyNode, AnyNodeId } from '../schema/types'
import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control'
import {
type CloneNodesIntoOptions,
collectSubtree,
cloneNodesInto as runCloneNodesInto,
} from './subtree'
import type { SceneApi } from './types'
/**
@@ -14,6 +19,7 @@ export type SceneStoreLike = {
rootNodeIds: AnyNodeId[]
dirtyNodes: Set<AnyNodeId>
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
deleteNode: (id: AnyNodeId) => void
markDirty: (id: AnyNodeId) => void
@@ -104,5 +110,32 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
resumeSceneHistory(store)
snapshot = null
},
getSubtree(rootId) {
return collectSubtree(store.getState().nodes, rootId)
},
cloneNodesInto(nodes, opts: CloneNodesIntoOptions) {
const { rootId, nodes: cloned } = runCloneNodesInto(nodes, opts)
const root = cloned[0]
if (!root) return null
const state = store.getState()
const ops: { node: AnyNode; parentId?: AnyNodeId }[] = []
for (let i = 0; i < cloned.length; i += 1) {
const node = cloned[i]!
if (i === 0) {
ops.push(opts.parentId ? { node, parentId: opts.parentId } : { node })
} else {
ops.push({ node })
}
}
const batch = state.createNodes
if (batch) {
batch(ops)
} else {
for (const op of ops) state.createNode(op.node, op.parentId)
}
return rootId
},
}
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { cloneNodesInto, collectSubtree } from './subtree'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return {
object: 'node',
id,
type,
parentId: null,
visible: true,
metadata: {},
...extra,
} as unknown as AnyNode
}
describe('collectSubtree', () => {
test('returns null for missing root', () => {
expect(collectSubtree({}, 'missing' as AnyNodeId)).toBeNull()
})
test('returns just the root for a leaf node', () => {
const root = makeNode('shelf_1', 'shelf', { width: 1 })
const sub = collectSubtree({ ['shelf_1' as AnyNodeId]: root }, 'shelf_1' as AnyNodeId)
expect(sub?.root).toBe(root)
expect(sub?.descendants).toEqual([])
})
test('walks descendants in BFS / declaration order', () => {
const nodes: Record<AnyNodeId, AnyNode> = {
['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', {
position: [0, 0, 0],
children: ['item_a', 'item_b'],
width: 1,
}),
['item_a' as AnyNodeId]: makeNode('item_a', 'item', {
parentId: 'shelf_1',
position: [0, 0, 0],
}),
['item_b' as AnyNodeId]: makeNode('item_b', 'item', {
parentId: 'shelf_1',
position: [0.3, 0, 0],
}),
}
const sub = collectSubtree(nodes, 'shelf_1' as AnyNodeId)
expect(sub?.descendants.map((n) => n.id)).toEqual(['item_a', 'item_b'])
})
test('returned nodes are live references — no cloning', () => {
const item = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const nodes: Record<AnyNodeId, AnyNode> = {
['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { children: ['item_a'] }),
['item_a' as AnyNodeId]: item,
}
const sub = collectSubtree(nodes, 'shelf_1' as AnyNodeId)
expect(sub?.descendants[0]).toBe(item)
})
})
describe('cloneNodesInto', () => {
test('clones a single root with fresh id and supplied position', () => {
const original = makeNode('door_orig', 'door', {
position: [1, 2, 3],
wallId: 'wall_x',
width: 0.9,
})
const { rootId, nodes } = cloneNodesInto([original], {
rootId: 'door_orig' as AnyNodeId,
position: [10, 0, -4],
})
expect(nodes).toHaveLength(1)
const cloned = nodes[0] as any
expect(cloned.id).toBe(rootId)
expect(cloned.id).not.toBe('door_orig')
expect(cloned.id.startsWith('door_')).toBe(true)
expect(cloned.position).toEqual([10, 0, -4])
expect(cloned.width).toBe(0.9)
// cloneNodesInto is host-ref-agnostic — wallId is preserved
// verbatim. Stripping is the caller's job (see getHostRefFields).
expect(cloned.wallId).toBe('wall_x')
})
test('preserves root position when none is supplied', () => {
const original = makeNode('shelf_orig', 'shelf', { position: [5, 0, 5] })
const { nodes } = cloneNodesInto([original], { rootId: 'shelf_orig' as AnyNodeId })
expect((nodes[0] as any).position).toEqual([5, 0, 5])
})
test('preserves parent/child subtree with remapped ids and relative positions', () => {
const shelf = makeNode('shelf_1', 'shelf', {
position: [5, 0, 5],
children: ['item_a', 'item_b'],
})
const itemA = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const itemB = makeNode('item_b', 'item', { parentId: 'shelf_1', position: [0.3, 0, 0] })
const { rootId, nodes: out } = cloneNodesInto([shelf, itemA, itemB], {
rootId: 'shelf_1' as AnyNodeId,
position: [99, 0, -99],
})
expect(out).toHaveLength(3)
const root = out[0] as any
expect(root.id).toBe(rootId)
expect(root.id).not.toBe('shelf_1')
expect(root.position).toEqual([99, 0, -99])
// Root's children rewritten to fresh ids; descendants' parentIds
// point at the new root id.
const ids = new Set(out.map((n) => (n as any).id))
expect(root.children).toHaveLength(2)
for (const cid of root.children) expect(ids.has(cid)).toBe(true)
for (let i = 1; i < out.length; i += 1) {
const desc = out[i] as any
expect(desc.parentId).toBe(rootId)
expect(Array.isArray(desc.position)).toBe(true)
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
rootId: 'shelf_1' as AnyNodeId,
parentId: 'level_new' as AnyNodeId,
})
expect((nodes[0] as any).parentId).toBe('level_new')
})
test('two clones produce disjoint id sets', () => {
const orig = makeNode('shelf_1', 'shelf', {
position: [0, 0, 0],
children: ['item_a'],
})
const child = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const first = cloneNodesInto([orig, child], { rootId: 'shelf_1' as AnyNodeId })
const second = cloneNodesInto([orig, child], { rootId: 'shelf_1' as AnyNodeId })
const idsA = new Set(first.nodes.map((n) => (n as any).id))
const idsB = new Set(second.nodes.map((n) => (n as any).id))
for (const id of idsA) expect(idsB.has(id)).toBe(false)
})
test('throws if rootId is missing from the input array', () => {
const orig = makeNode('shelf_1', 'shelf', {})
expect(() => cloneNodesInto([orig], { rootId: 'shelf_other' as AnyNodeId })).toThrow(/rootId/)
})
})
+187
View File
@@ -0,0 +1,187 @@
import { generateId } from '../schema/base'
import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement
// catalog / paste / duplicate / preset flows.
//
// Design intent (see pascalorg/editor#340 redesign):
// - The editor exposes a *pure* live-scene walk + a generic clone-and-
// insert helper. It owns nothing about storage shape, position
// re-anchoring policy, or host-ref re-derivation.
// - The host (community-app, embedders, etc.) decides whether to
// persist the subtree as JSON, strip host fields before storage,
// stamp a placement position, re-attach to a wall on drop, etc.
//
// What the editor uniquely knows is which schema fields on each kind
// are *host references* (e.g. `wallId` / `wallT` on a door hosted by a
// wall). That knowledge lives on `def.hostRefFields` — read it via
// `getHostRefFields(def)` and apply it at storage time. See
// `wiki/architecture/node-definitions.md` (host refs section).
/** A flat live-scene subtree rooted at `root`. */
export type Subtree = {
/** The root node, exactly as stored in `useScene.nodes[rootId]`. */
root: AnyNode
/** Every descendant reachable from `root` via the data-model `children` array, in BFS order. */
descendants: AnyNode[]
}
function extractIdPrefix(id: string): string {
const i = id.indexOf('_')
return i === -1 ? 'node' : id.slice(0, i)
}
function getChildIds(node: AnyNode): AnyNodeId[] {
if ('children' in node && Array.isArray((node as { children?: unknown }).children)) {
return (node as { children: AnyNodeId[] }).children
}
return []
}
/**
* Collect the subtree of nodes rooted at `rootId` from the live scene.
*
* - BFS walk via `node.children` arrays — order is stable and matches
* declaration order on container kinds.
* - Returns the live node references (not clones). Cheap; the caller
* chooses whether to deep-clone for persistence.
* - Returns `null` if `rootId` is missing.
*/
export function collectSubtree(
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
rootId: AnyNodeId,
): Subtree | null {
const root = nodes[rootId]
if (!root) return null
const descendants: AnyNode[] = []
const seen = new Set<AnyNodeId>([rootId])
const queue: AnyNodeId[] = [...getChildIds(root)]
let head = 0
while (head < queue.length) {
const id = queue[head++]!
if (seen.has(id)) continue
const node = nodes[id]
if (!node) continue
seen.add(id)
descendants.push(node)
for (const childId of getChildIds(node)) queue.push(childId)
}
return { root, descendants }
}
export type CloneNodesIntoOptions = {
/**
* The id of the root node within `nodes` (i.e. the node whose
* `parentId` becomes `parentId` in the destination instead of being
* remapped to a sibling's fresh id). Required because `nodes` is a
* flat array — there's no other way to mark which one is the root.
*/
rootId: AnyNodeId
/**
* Parent for the cloned root in the destination scene. When omitted,
* the root is inserted as a scene root (its `parentId` becomes the
* preserved value, often `null`).
*/
parentId?: AnyNodeId
/**
* Optional override for the cloned root's `position` (most placement
* flows stamp the cursor / target point here). When omitted, the
* root's own `position` field is preserved verbatim. Descendants
* always keep their original positions — those are local to the root.
*/
position?: readonly [number, number, number]
}
export type CloneNodesIntoResult = {
/** Fresh id assigned to the root in the destination scene. */
rootId: AnyNodeId
/** Every cloned node, root first, ready to feed into `createNodes`. */
nodes: AnyNode[]
/** Original id → fresh id map, mostly useful for tests and host-side bookkeeping. */
idMap: Map<AnyNodeId, AnyNodeId>
}
/**
* Clone a flat array of nodes with fresh IDs and rewired references,
* ready to insert via `useScene.createNodes`.
*
* Transformations applied:
* 1. Deep-clone each node via JSON round-trip (strips three.js refs,
* functions, circular links — same trick `cloneLevelSubtree` uses).
* 2. Mint a fresh id for every node, preserving the prefix
* (`wall_…`, `door_…`, etc.) so logs and lookups stay readable.
* 3. Rewrite `parentId`, `children[]` to use the fresh ids.
* 4. Stamp `position` onto the root if provided.
* 5. Set the root's `parentId` to `opts.parentId` when supplied.
*
* Intentionally generic — no awareness of host refs (`wallId`/`wallT`
* etc.). The caller is responsible for stripping or re-deriving those
* before / after calling this function. See `getHostRefFields(def)`.
*/
export function cloneNodesInto(
nodes: ReadonlyArray<AnyNode>,
opts: CloneNodesIntoOptions,
): CloneNodesIntoResult {
// Phase 1 — mint fresh ids for every node, preserving the prefix.
const idMap = new Map<AnyNodeId, AnyNodeId>()
for (const node of nodes) {
const prefix = extractIdPrefix(node.id)
idMap.set(node.id, generateId(prefix) as AnyNodeId)
}
const rootFreshId = idMap.get(opts.rootId)
if (!rootFreshId) {
throw new Error(`cloneNodesInto: rootId "${opts.rootId}" not found in supplied nodes array`)
}
// Phase 2 — clone each node + rewire references.
const out: AnyNode[] = []
let root: AnyNode | null = null
for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved
// value if not supplied). Descendants point at the remapped parent.
if (original.id === opts.rootId) {
;(cloned as { parentId: AnyNodeId | null }).parentId =
opts.parentId !== undefined
? opts.parentId
: ((cloned as { parentId?: AnyNodeId | null }).parentId ?? null)
} else if (cloned.parentId) {
const parentFresh = idMap.get(cloned.parentId as AnyNodeId)
;(cloned as { parentId: AnyNodeId | null }).parentId = parentFresh ?? null
}
// children[]: remap any internal references, drop external ones
// (a descendant pointing at a sibling that didn't make it into
// `nodes` would dangle — `filter` drops those gracefully).
if ('children' in cloned && Array.isArray((cloned as { children?: unknown }).children)) {
;(cloned as { children: AnyNodeId[] }).children = (
cloned as { children: AnyNodeId[] }
).children
.map((cid) => idMap.get(cid))
.filter((cid): cid is AnyNodeId => cid !== undefined)
}
if (original.id === opts.rootId) {
if (opts.position) {
;(cloned as { position: [number, number, number] }).position = [
opts.position[0],
opts.position[1],
opts.position[2],
]
}
root = cloned
} else {
out.push(cloned)
}
}
if (!root) {
throw new Error('cloneNodesInto: root node missing after clone')
}
return { rootId: rootFreshId, nodes: [root, ...out], idMap }
}
+57
View File
@@ -4,6 +4,7 @@ import type { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
import type { CloneNodesIntoOptions, Subtree } from './subtree'
// ─── GeometryContext ─────────────────────────────────────────────────
//
@@ -970,6 +971,43 @@ export type Capabilities = {
* declaring the same flag.
*/
floorplanLevelContainer?: boolean
/**
* Names of schema fields on this kind that are *host references* —
* values derived from where the node is placed (rather than declared
* by the user as part of the kind's parametric configuration). Read
* by host apps at preset-save time to strip these from the stored
* payload so a placed instance gets fresh host links at the new
* placement site (e.g. a door snapshot loses `wallId`/`wallT`; at
* placement the auto-attach UX re-derives them from the wall under
* the cursor).
*
* Kinds with no host refs omit this field (default `[]`).
*
* Examples:
* - door: `['wallId', 'wallT']` (door hosted on a wall)
* - window: `['wallId', 'wallT']`
* - item with `attachTo`: depends on the asset; the kind's
* `defaults()` or the dragging logic populates it dynamically.
*/
hostRefFields?: string[]
/**
* Whether instances of this kind can be saved as a reusable preset
* (unified `items` catalog, `kind='preset'`). The editor itself does
* not act on this flag — host apps read it to gate "save as preset"
* UI on the selected node. Default resolution (callers should use the
* `isPresettable(def)` helper rather than reading this directly):
*
* - explicit `true` → presettable
* - explicit `false` → not presettable
* - undefined → presettable when `def.parametrics` exists
*
* Structural / utility kinds (level, building, site, zone, spawn,
* guide, scan, item) opt out explicitly because saving them as a
* standalone preset has no meaning — items already have their own
* catalog, scans/guides carry user-uploaded imagery, and the rest
* are non-leaf scene containers.
*/
presettable?: boolean
}
/**
@@ -1282,6 +1320,25 @@ export type SceneApi = {
markDirty: (id: AnyNodeId) => void
pauseHistory: () => void
resumeHistory: () => void
/**
* Collect the subtree of live nodes rooted at `rootId` — `root` plus
* every descendant reachable via `children[]` in BFS order. Returns
* live node references (no clones); the caller decides whether to
* persist by value or pass them straight into {@link cloneNodesInto}.
* Returns `null` if `rootId` is missing.
*/
getSubtree: (rootId: AnyNodeId) => Subtree | null
/**
* Clone a flat array of nodes into the live scene with fresh IDs and
* rewired parent / children references. Intentionally generic — see
* {@link cloneNodesInto} for the transformations applied. Does NOT
* strip or re-derive host references (e.g. `wallId` on a door); the
* caller is responsible for that policy (read {@link Capabilities.hostRefFields}
* on the relevant definition).
*
* Returns the new root id, or `null` if insertion failed.
*/
cloneNodesInto: (nodes: ReadonlyArray<AnyNode>, opts: CloneNodesIntoOptions) => AnyNodeId | null
}
// ─── Registry surface ────────────────────────────────────────────────