* 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>
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
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'
|
|
|
|
/**
|
|
* Minimal store shape this module depends on.
|
|
*
|
|
* Decoupled from `useScene` directly so the production singleton and tests can
|
|
* share one factory. The full store implements a superset.
|
|
*/
|
|
export type SceneStoreLike = {
|
|
getState: () => {
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
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
|
|
}
|
|
temporal: {
|
|
getState: () => { pause: () => void; resume: () => void }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a {@link SceneApi} backed by a store.
|
|
*
|
|
* Snapshot semantics:
|
|
* - `pauseHistory()` starts a copy-on-write window. The first time `update`,
|
|
* `upsert`, or `delete` touches a node id, the pre-change value is captured.
|
|
* - `restore(id)` and `restoreAll()` apply the captured value back. Either is
|
|
* safe to call only while a pause window is active.
|
|
* - `resumeHistory()` drops the snapshot.
|
|
*
|
|
* Snapshots are lazy and bounded by the number of nodes touched during the
|
|
* pause window — never an upfront clone of the entire scene.
|
|
*/
|
|
export function createSceneApi(store: SceneStoreLike): SceneApi {
|
|
let snapshot: Map<AnyNodeId, AnyNode | null> | null = null
|
|
|
|
function captureIfNeeded(id: AnyNodeId): void {
|
|
if (!snapshot || snapshot.has(id)) return
|
|
const existing = store.getState().nodes[id]
|
|
snapshot.set(id, existing ?? null)
|
|
}
|
|
|
|
return {
|
|
get<N extends AnyNode = AnyNode>(id: AnyNodeId): N | undefined {
|
|
return store.getState().nodes[id] as N | undefined
|
|
},
|
|
|
|
nodes() {
|
|
return store.getState().nodes
|
|
},
|
|
|
|
update(id, patch) {
|
|
captureIfNeeded(id)
|
|
store.getState().updateNode(id, patch)
|
|
},
|
|
|
|
upsert(node, parentId) {
|
|
captureIfNeeded(node.id)
|
|
store.getState().createNode(node, parentId)
|
|
return node.id
|
|
},
|
|
|
|
delete(id) {
|
|
captureIfNeeded(id)
|
|
store.getState().deleteNode(id)
|
|
},
|
|
|
|
restore(id) {
|
|
if (!snapshot) return
|
|
const original = snapshot.get(id)
|
|
if (original === undefined) return
|
|
const current = store.getState().nodes[id]
|
|
if (original === null) {
|
|
if (current) store.getState().deleteNode(id)
|
|
} else if (!current) {
|
|
store.getState().createNode(original)
|
|
} else {
|
|
store.getState().updateNode(id, original)
|
|
}
|
|
},
|
|
|
|
restoreAll() {
|
|
if (!snapshot) return
|
|
for (const id of snapshot.keys()) {
|
|
this.restore(id)
|
|
}
|
|
},
|
|
|
|
markDirty(id) {
|
|
store.getState().markDirty(id)
|
|
},
|
|
|
|
pauseHistory() {
|
|
pauseSceneHistory(store)
|
|
if (!snapshot) snapshot = new Map()
|
|
},
|
|
|
|
resumeHistory() {
|
|
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
|
|
},
|
|
}
|
|
}
|