* 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>
211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
import { z } from 'zod'
|
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
|
import { nodeRegistry, registerNode } from './registry'
|
|
import { cascadeDirty, collectDescendants, type SpatialQuery } from './relations-resolver'
|
|
import type { AnyNodeDefinition, Relations, SceneApi } from './types'
|
|
|
|
const id = (s: string) => s as AnyNodeId
|
|
|
|
function makeDef(
|
|
kind: string,
|
|
relations?: Relations,
|
|
overrides: Partial<AnyNodeDefinition> = {},
|
|
): AnyNodeDefinition {
|
|
return {
|
|
kind,
|
|
schemaVersion: 1,
|
|
schema: z.object({ type: z.literal(kind) }) as any,
|
|
category: 'utility',
|
|
defaults: () => ({}) as any,
|
|
capabilities: {},
|
|
relations,
|
|
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): AnyNode {
|
|
return {
|
|
id: id(idStr),
|
|
type: kind,
|
|
parentId: null,
|
|
visible: true,
|
|
...extra,
|
|
} as unknown as AnyNode
|
|
}
|
|
|
|
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
|
|
return {
|
|
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
|
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
|
update: () => {},
|
|
upsert: () => id(''),
|
|
delete: () => {},
|
|
restore: () => {},
|
|
restoreAll: () => {},
|
|
markDirty: () => {},
|
|
pauseHistory: () => {},
|
|
resumeHistory: () => {},
|
|
getSubtree: () => null,
|
|
cloneNodesInto: () => null,
|
|
}
|
|
}
|
|
|
|
describe('cascadeDirty', () => {
|
|
beforeEach(() => {
|
|
nodeRegistry._reset()
|
|
})
|
|
|
|
test('starting node alone when registry is empty', () => {
|
|
const scene = makeFakeScene({ a: makeNode('unknown', 'a') })
|
|
const dirty = cascadeDirty(id('a'), { scene })
|
|
expect(Array.from(dirty)).toEqual([id('a')])
|
|
})
|
|
|
|
test('starting node alone when no relations declared', () => {
|
|
registerNode(makeDef('thing'))
|
|
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
|
|
const dirty = cascadeDirty(id('a'), { scene })
|
|
expect(Array.from(dirty)).toEqual([id('a')])
|
|
})
|
|
|
|
test('hosts cascade marks matching children dirty', () => {
|
|
registerNode(makeDef('wall', { hosts: ['door', 'window'] }))
|
|
registerNode(makeDef('door'))
|
|
registerNode(makeDef('window'))
|
|
registerNode(makeDef('lamp'))
|
|
|
|
const wall = makeNode('wall', 'w1', {
|
|
children: [id('d1'), id('w2'), id('l1')],
|
|
} as Partial<AnyNode>)
|
|
const scene = makeFakeScene({
|
|
w1: wall,
|
|
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
|
w2: makeNode('window', 'w2', { parentId: id('w1') }),
|
|
l1: makeNode('lamp', 'l1', { parentId: id('w1') }), // not in hosts list
|
|
})
|
|
|
|
const dirty = cascadeDirty(id('w1'), { scene })
|
|
const ids = Array.from(dirty).sort()
|
|
expect(ids).toEqual([id('d1'), id('w1'), id('w2')]) // l1 excluded
|
|
})
|
|
|
|
test('hosts cascade is recursive but bounded by maxDepth', () => {
|
|
registerNode(makeDef('a', { hosts: ['a'] })) // a hosts more a
|
|
const nodes: Record<string, AnyNode> = {}
|
|
for (let i = 0; i < 25; i++) {
|
|
const childId = i < 24 ? id(`a${i + 1}`) : undefined
|
|
nodes[`a${i}`] = makeNode('a', `a${i}`, {
|
|
children: childId ? [childId] : [],
|
|
} as Partial<AnyNode>)
|
|
}
|
|
const scene = makeFakeScene(nodes)
|
|
const dirty = cascadeDirty(id('a0'), { scene, maxDepth: 5 })
|
|
expect(dirty.size).toBe(6) // a0 + 5 descendants
|
|
})
|
|
|
|
test('affectsSpatial cascade uses spatialQuery to find neighbors', () => {
|
|
registerNode(makeDef('wall', { affectsSpatial: ['slab', 'zone'] }))
|
|
registerNode(makeDef('slab'))
|
|
registerNode(makeDef('zone'))
|
|
|
|
const scene = makeFakeScene({
|
|
w1: makeNode('wall', 'w1'),
|
|
s1: makeNode('slab', 's1'),
|
|
z1: makeNode('zone', 'z1'),
|
|
unrelated: makeNode('door', 'unrelated'),
|
|
})
|
|
|
|
const spatialQuery: SpatialQuery = (node, kinds) => {
|
|
if (node.id !== id('w1')) return []
|
|
const matches: AnyNodeId[] = []
|
|
if (kinds.includes('slab')) matches.push(id('s1'))
|
|
if (kinds.includes('zone')) matches.push(id('z1'))
|
|
return matches
|
|
}
|
|
|
|
const dirty = cascadeDirty(id('w1'), { scene, spatialQuery })
|
|
expect(Array.from(dirty).sort()).toEqual([id('s1'), id('w1'), id('z1')])
|
|
})
|
|
|
|
test('affectsSpatial is a no-op when no spatialQuery is provided', () => {
|
|
registerNode(makeDef('wall', { affectsSpatial: ['slab'] }))
|
|
const scene = makeFakeScene({ w1: makeNode('wall', 'w1') })
|
|
const dirty = cascadeDirty(id('w1'), { scene })
|
|
expect(Array.from(dirty)).toEqual([id('w1')]) // spatial branch silently skipped
|
|
})
|
|
|
|
test('cycle in hosts cascade does not loop forever', () => {
|
|
registerNode(makeDef('a', { hosts: ['a'] }))
|
|
const nodes: Record<string, AnyNode> = {
|
|
a1: makeNode('a', 'a1', { children: [id('a2')] } as Partial<AnyNode>),
|
|
a2: makeNode('a', 'a2', { children: [id('a1')] } as Partial<AnyNode>), // cycle
|
|
}
|
|
const scene = makeFakeScene(nodes)
|
|
const dirty = cascadeDirty(id('a1'), { scene })
|
|
expect(dirty.size).toBe(2)
|
|
expect(dirty.has(id('a1'))).toBe(true)
|
|
expect(dirty.has(id('a2'))).toBe(true)
|
|
})
|
|
|
|
test('custom childQuery overrides the default node.children lookup', () => {
|
|
registerNode(makeDef('wall', { hosts: ['door'] }))
|
|
registerNode(makeDef('door'))
|
|
const scene = makeFakeScene({
|
|
w1: makeNode('wall', 'w1'), // no children field
|
|
d1: makeNode('door', 'd1', { parentId: id('w1') }),
|
|
})
|
|
|
|
// childQuery iterates the scene to find parentId matches — what you would
|
|
// do for kinds that don't carry an explicit children array.
|
|
const childQuery = (node: AnyNode) => {
|
|
const result: AnyNodeId[] = []
|
|
for (const candidate of [id('d1')]) {
|
|
const c = scene.get(candidate)
|
|
if (c && c.parentId === node.id) result.push(c.id)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const dirty = cascadeDirty(id('w1'), { scene, childQuery })
|
|
expect(Array.from(dirty).sort()).toEqual([id('d1'), id('w1')])
|
|
})
|
|
})
|
|
|
|
describe('collectDescendants', () => {
|
|
beforeEach(() => {
|
|
nodeRegistry._reset()
|
|
})
|
|
|
|
test('returns just the start when no children', () => {
|
|
const scene = makeFakeScene({ a: makeNode('thing', 'a') })
|
|
const result = collectDescendants(id('a'), { scene })
|
|
expect(Array.from(result)).toEqual([id('a')])
|
|
})
|
|
|
|
test('returns full subtree regardless of relations declarations', () => {
|
|
// No def registered — descendants still found via the children array.
|
|
const scene = makeFakeScene({
|
|
root: makeNode('thing', 'root', { children: [id('c1'), id('c2')] } as Partial<AnyNode>),
|
|
c1: makeNode('thing', 'c1', { children: [id('g1')] } as Partial<AnyNode>),
|
|
c2: makeNode('thing', 'c2'),
|
|
g1: makeNode('thing', 'g1'),
|
|
})
|
|
|
|
const result = collectDescendants(id('root'), { scene })
|
|
expect(Array.from(result).sort()).toEqual([id('c1'), id('c2'), id('g1'), id('root')])
|
|
})
|
|
|
|
test('respects maxDepth', () => {
|
|
const scene = makeFakeScene({
|
|
a: makeNode('thing', 'a', { children: [id('b')] } as Partial<AnyNode>),
|
|
b: makeNode('thing', 'b', { children: [id('c')] } as Partial<AnyNode>),
|
|
c: makeNode('thing', 'c', { children: [id('d')] } as Partial<AnyNode>),
|
|
d: makeNode('thing', 'd'),
|
|
})
|
|
const result = collectDescendants(id('a'), { scene, maxDepth: 2 })
|
|
expect(Array.from(result).sort()).toEqual([id('a'), id('b'), id('c')]) // d truncated
|
|
})
|
|
})
|