* 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>
248 lines
8.9 KiB
TypeScript
248 lines
8.9 KiB
TypeScript
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
import { z } from 'zod'
|
|
import { nodeRegistry, registerNode } from '../registry/registry'
|
|
import type { AnyNodeDefinition, DragAction, Relations, SceneApi } from '../registry/types'
|
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
|
import { createDragSession } from './drag-session'
|
|
|
|
const id = (s: string) => s as AnyNodeId
|
|
|
|
function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
|
|
_calls: {
|
|
pauseHistory: number
|
|
resumeHistory: number
|
|
restoreAll: number
|
|
markedDirty: AnyNodeId[]
|
|
updated: Array<[AnyNodeId, Partial<AnyNode>]>
|
|
}
|
|
} {
|
|
const calls = {
|
|
pauseHistory: 0,
|
|
resumeHistory: 0,
|
|
restoreAll: 0,
|
|
markedDirty: [] as AnyNodeId[],
|
|
updated: [] as Array<[AnyNodeId, Partial<AnyNode>]>,
|
|
}
|
|
const nodes = { ...initial }
|
|
return {
|
|
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
|
|
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
|
|
update: (nid, patch) => {
|
|
calls.updated.push([nid, patch])
|
|
const existing = nodes[nid as string]
|
|
if (existing) nodes[nid as string] = { ...existing, ...patch } as AnyNode
|
|
},
|
|
upsert: (n: AnyNode) => {
|
|
nodes[n.id as string] = n
|
|
return n.id
|
|
},
|
|
delete: (nid) => {
|
|
delete nodes[nid as string]
|
|
},
|
|
restore: () => {},
|
|
restoreAll: () => {
|
|
calls.restoreAll += 1
|
|
},
|
|
markDirty: (nid) => {
|
|
calls.markedDirty.push(nid)
|
|
},
|
|
pauseHistory: () => {
|
|
calls.pauseHistory += 1
|
|
},
|
|
resumeHistory: () => {
|
|
calls.resumeHistory += 1
|
|
},
|
|
getSubtree: () => null,
|
|
cloneNodesInto: () => null,
|
|
_calls: calls,
|
|
}
|
|
}
|
|
|
|
function makeDef(kind: string, relations?: Relations): 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 }) },
|
|
}
|
|
}
|
|
|
|
function makeAction(): DragAction<{ id: AnyNodeId }, { x: number }> {
|
|
return {
|
|
begin: ({ node }) => ({ id: node?.id ?? id('default') }),
|
|
preview: (_ctx, point) => ({ x: point[0] }),
|
|
apply: (_draft, ctx) => [ctx.id],
|
|
cancel: () => {},
|
|
}
|
|
}
|
|
|
|
describe('createDragSession', () => {
|
|
beforeEach(() => {
|
|
nodeRegistry._reset()
|
|
})
|
|
|
|
test('start pauses history; commit resumes', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0] })
|
|
expect(scene._calls.pauseHistory).toBe(1)
|
|
expect(scene._calls.resumeHistory).toBe(0)
|
|
session.commit()
|
|
expect(scene._calls.resumeHistory).toBe(1)
|
|
})
|
|
|
|
test('cancel resumes history and calls restoreAll', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0] })
|
|
session.cancel()
|
|
expect(scene._calls.resumeHistory).toBe(1)
|
|
expect(scene._calls.restoreAll).toBe(1)
|
|
})
|
|
|
|
test('move runs preview + apply and marks the returned id dirty', () => {
|
|
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0], node: { id: id('a') } as any })
|
|
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
expect(session.getDraft()).toEqual({ x: 1 })
|
|
expect(scene._calls.markedDirty).toContain(id('a'))
|
|
})
|
|
|
|
test('snap callback is invoked when defined', () => {
|
|
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
|
|
...makeAction(),
|
|
snap: (draft) => ({ x: Math.round(draft.x) }),
|
|
}
|
|
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
|
|
const session = createDragSession(action, scene)
|
|
session.start({ point: [0, 0], node: { id: id('a') } as any })
|
|
session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
expect(session.getDraft()).toEqual({ x: 1 })
|
|
})
|
|
|
|
test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => {
|
|
const cancelSpy = mock(() => {})
|
|
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
|
|
...makeAction(),
|
|
cancel: cancelSpy,
|
|
commit: () => false,
|
|
}
|
|
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
|
|
const session = createDragSession(action, scene)
|
|
session.start({ point: [0, 0], node: { id: id('a') } as any })
|
|
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
const result = session.commit()
|
|
expect(result).toBe(false)
|
|
expect(cancelSpy).toHaveBeenCalledTimes(1)
|
|
expect(scene._calls.restoreAll).toBe(1)
|
|
})
|
|
|
|
test('move is a no-op when session is not active', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
expect(scene._calls.markedDirty.length).toBe(0)
|
|
})
|
|
|
|
test('repeated start is a no-op (re-entry guard)', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0] })
|
|
session.start({ point: [99, 99] })
|
|
expect(scene._calls.pauseHistory).toBe(1) // only one pause
|
|
})
|
|
|
|
test('dispose mid-drag cancels and cleans up', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0] })
|
|
expect(session.isActive()).toBe(true)
|
|
session.dispose()
|
|
expect(session.isActive()).toBe(false)
|
|
expect(scene._calls.resumeHistory).toBe(1)
|
|
expect(scene._calls.restoreAll).toBe(1)
|
|
})
|
|
|
|
test('dispose when inactive is a no-op', () => {
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.dispose()
|
|
expect(scene._calls.pauseHistory).toBe(0)
|
|
expect(scene._calls.resumeHistory).toBe(0)
|
|
})
|
|
|
|
test('onCommit callback fires on successful commit', () => {
|
|
const onCommit = mock(() => {})
|
|
const onCancel = mock(() => {})
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
|
|
session.start({ point: [0, 0] })
|
|
session.commit()
|
|
expect(onCommit).toHaveBeenCalledTimes(1)
|
|
expect(onCancel).toHaveBeenCalledTimes(0)
|
|
})
|
|
|
|
test('onCancel callback fires on explicit cancel', () => {
|
|
const onCommit = mock(() => {})
|
|
const onCancel = mock(() => {})
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
|
|
session.start({ point: [0, 0] })
|
|
session.cancel()
|
|
expect(onCommit).toHaveBeenCalledTimes(0)
|
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('dispose does NOT fire onCancel (silent cleanup)', () => {
|
|
const onCommit = mock(() => {})
|
|
const onCancel = mock(() => {})
|
|
const scene = makeSpyScene()
|
|
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
|
|
session.start({ point: [0, 0] })
|
|
session.dispose()
|
|
expect(onCommit).toHaveBeenCalledTimes(0)
|
|
expect(onCancel).toHaveBeenCalledTimes(0)
|
|
expect(scene._calls.resumeHistory).toBe(1)
|
|
expect(scene._calls.restoreAll).toBe(1)
|
|
})
|
|
|
|
test('dirty cascade fires once per id even across multiple move ticks', () => {
|
|
// Register a kind with no relations — cascade returns just {startId}.
|
|
registerNode(makeDef('thing'))
|
|
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
|
|
const session = createDragSession(makeAction(), scene)
|
|
session.start({ point: [0, 0], node: { id: id('a') } as any })
|
|
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
// a is marked once, not three times
|
|
expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1)
|
|
})
|
|
|
|
test('dirty cascade follows hosts relations from the registry', () => {
|
|
registerNode(makeDef('wall', { hosts: ['door'] }))
|
|
registerNode(makeDef('door'))
|
|
const scene = makeSpyScene({
|
|
w: { id: id('w'), type: 'wall', children: [id('d')] } as any,
|
|
d: { id: id('d'), type: 'door', parentId: id('w') } as any,
|
|
})
|
|
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
|
|
begin: () => ({ id: id('w') }),
|
|
preview: (_ctx, point) => ({ x: point[0] }),
|
|
apply: (_draft, ctx) => [ctx.id],
|
|
cancel: () => {},
|
|
}
|
|
const session = createDragSession(action, scene)
|
|
session.start({ point: [0, 0], node: { id: id('w') } as any })
|
|
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
|
|
// both wall and door marked dirty
|
|
expect(scene._calls.markedDirty).toContain(id('w'))
|
|
expect(scene._calls.markedDirty).toContain(id('d'))
|
|
})
|
|
})
|