From 0e1bca37beefbb3557367cd884f4be2c7d9bebc7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 27 May 2026 20:43:48 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(editor):=20preset-system=20primitives?= =?UTF-8?q?=20=E2=80=94=20presettable,=20sceneApi=20subtree=20round-trip,?= =?UTF-8?q?=20isolate=20+=20setCaptureMode=20enum,=20headless=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - `` 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) --- packages/core/src/events/bus.ts | 7 + .../__bench__/relations-resolver.bench.ts | 2 + packages/core/src/registry/index.ts | 39 ++- packages/core/src/registry/registry.test.ts | 43 ++- packages/core/src/registry/registry.ts | 18 ++ .../src/registry/relations-resolver.test.ts | 2 + packages/core/src/registry/scene-api.ts | 36 +++ packages/core/src/registry/subtree.test.ts | 164 +++++++++++ packages/core/src/registry/subtree.ts | 278 ++++++++++++++++++ packages/core/src/registry/types.ts | 37 +++ .../core/src/services/drag-session.test.ts | 2 + packages/core/src/services/hosting.test.ts | 2 + .../editor/snapshot-capture-overlay.tsx | 66 ++++- .../components/editor/thumbnail-generator.tsx | 5 + packages/editor/src/hooks/use-selection.ts | 64 ++++ packages/editor/src/index.tsx | 29 +- packages/editor/src/store/use-editor.tsx | 34 ++- packages/nodes/src/building/definition.ts | 1 + packages/nodes/src/guide/definition.ts | 3 + packages/nodes/src/item/definition.ts | 4 + packages/nodes/src/level/definition.ts | 3 + packages/nodes/src/scan/definition.ts | 3 + packages/nodes/src/site/definition.ts | 1 + packages/nodes/src/spawn/definition.ts | 2 + packages/nodes/src/zone/definition.ts | 3 + .../viewer/src/components/viewer/index.tsx | 71 ++++- packages/viewer/src/index.ts | 7 +- packages/viewer/src/lib/isolation.ts | 96 ++++++ 28 files changed, 974 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/registry/subtree.test.ts create mode 100644 packages/core/src/registry/subtree.ts create mode 100644 packages/editor/src/hooks/use-selection.ts create mode 100644 packages/viewer/src/lib/isolation.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 1466770f1..143b17c48 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -130,6 +130,13 @@ export interface ThumbnailGenerateEvent { * that should fire immediately from the current camera pose. */ snapLevels?: boolean + /** + * When true, keep the rendered alpha channel — emits a transparent PNG + * without baking the scene background into the output. Used by the + * preset capture flow so saved preset thumbnails composite cleanly on + * any palette background. + */ + transparent?: boolean } export interface CameraControlFitSceneEvent { diff --git a/packages/core/src/registry/__bench__/relations-resolver.bench.ts b/packages/core/src/registry/__bench__/relations-resolver.bench.ts index 562a6150e..cc90af35d 100644 --- a/packages/core/src/registry/__bench__/relations-resolver.bench.ts +++ b/packages/core/src/registry/__bench__/relations-resolver.bench.ts @@ -110,6 +110,8 @@ function makeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, + getSubtreeSnapshot: () => null, + materializeSubtree: () => null, } } diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 69ff7ebe9..4968ba6eb 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,6 +1,23 @@ +export type { + ArcResizeHandle, + Cursor, + EditorApi, + EndpointMoveHandle, + HandleAnchor, + HandleAxis, + HandleDescriptor, + HandleList, + HandlePlacement, + HandlePortal, + LinearResizeHandle, + RadialResizeHandle, + TapActionHandle, +} from './handles' export { discoverPlugins, getSelectableKinds, + isPresettable, + isPresettableKind, isRegistryMovable, isRegistrySelectable, kindsWithFloorplanScope, @@ -17,22 +34,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 { + buildSubtreeSnapshot, + type MaterializedSubtree, + materializeSubtree, + type NodeSubtree, + type SubtreeNode, +} from './subtree' export type { Affordance, AnyNodeDefinition, diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 0e1abc0f9..79ab46df7 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { z } from 'zod' -import { loadPlugin, nodeRegistry, registerNode } from './registry' +import { + isPresettable, + isPresettableKind, + loadPlugin, + nodeRegistry, + registerNode, +} from './registry' import type { AnyNodeDefinition, Plugin } from './types' function makeDefinition( @@ -70,6 +76,41 @@ 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('loadPlugin', () => { beforeEach(() => { nodeRegistry._reset() diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 8cfbea8c9..c6ce27f2f 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -146,6 +146,24 @@ 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 +} + export async function loadPlugin(plugin: Plugin): Promise { if (plugin.apiVersion !== HOST_API_VERSION) { throw new Error( diff --git a/packages/core/src/registry/relations-resolver.test.ts b/packages/core/src/registry/relations-resolver.test.ts index 2d33139c0..21a4ff1c7 100644 --- a/packages/core/src/registry/relations-resolver.test.ts +++ b/packages/core/src/registry/relations-resolver.test.ts @@ -47,6 +47,8 @@ function makeFakeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, + getSubtreeSnapshot: () => null, + materializeSubtree: () => null, } } diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts index 1858a336f..7d7631b53 100644 --- a/packages/core/src/registry/scene-api.ts +++ b/packages/core/src/registry/scene-api.ts @@ -1,5 +1,6 @@ import type { AnyNode, AnyNodeId } from '../schema/types' import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' +import { buildSubtreeSnapshot, materializeSubtree as runMaterializeSubtree } from './subtree' import type { SceneApi } from './types' /** @@ -14,6 +15,7 @@ export type SceneStoreLike = { rootNodeIds: AnyNodeId[] dirtyNodes: Set createNode: (node: AnyNode, parentId?: AnyNodeId) => void + createNodes?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void updateNode: (id: AnyNodeId, data: Partial) => void deleteNode: (id: AnyNodeId) => void markDirty: (id: AnyNodeId) => void @@ -104,5 +106,39 @@ export function createSceneApi(store: SceneStoreLike): SceneApi { resumeSceneHistory(store) snapshot = null }, + + getSubtreeSnapshot(rootId) { + return buildSubtreeSnapshot(store.getState().nodes, rootId) + }, + + materializeSubtree(subtree, position, parentId) { + const { rootId, nodes } = runMaterializeSubtree(subtree, position) + const state = store.getState() + // Prefer batched `createNodes` when the store exposes it — keeps + // children-array writes and dirty-marking in one tick, identical + // to how `applyNodeChanges` lands a multi-node paste. The + // minimal `SceneStoreLike` does not require it, so the test + // store can fall back to per-node `createNode` calls. + const root = nodes[0] + if (!root) return null + const ops: { node: AnyNode; parentId?: AnyNodeId }[] = [] + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]! + if (i === 0) { + ops.push(parentId ? { node, parentId } : { node }) + } else { + ops.push({ node }) + } + } + const createNodes = state.createNodes + if (createNodes) { + createNodes(ops) + } else { + for (const op of ops) { + state.createNode(op.node, op.parentId) + } + } + return rootId + }, } } diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts new file mode 100644 index 000000000..726d10c70 --- /dev/null +++ b/packages/core/src/registry/subtree.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { buildSubtreeSnapshot, materializeSubtree } from './subtree' + +function makeNode(id: string, type: string, extra: Record = {}): AnyNode { + return { + object: 'node', + id, + type, + parentId: null, + visible: true, + metadata: {}, + ...extra, + } as unknown as AnyNode +} + +describe('buildSubtreeSnapshot', () => { + test('returns null for missing root', () => { + expect(buildSubtreeSnapshot({}, 'missing' as AnyNodeId)).toBeNull() + }) + + test('strips id / parentId / position / wallId from the root', () => { + const nodes: Record = { + ['door_1' as AnyNodeId]: makeNode('door_1', 'door', { + parentId: 'level_1', + position: [1, 2, 3], + wallId: 'wall_x', + wallT: 0.4, + width: 0.9, + height: 2.1, + }), + } + const snap = buildSubtreeSnapshot(nodes, 'door_1' as AnyNodeId) + expect(snap).not.toBeNull() + expect((snap?.root as any).id).toBeUndefined() + expect((snap?.root as any).parentId).toBeUndefined() + expect((snap?.root as any).position).toBeUndefined() + expect((snap?.root as any).wallId).toBeUndefined() + expect((snap?.root as any).wallT).toBeUndefined() + expect((snap?.root as any).width).toBe(0.9) + expect((snap?.root as any).height).toBe(2.1) + expect(snap?.rootKind).toBe('door') + }) + + test('captures descendants via the children array', () => { + const nodes: Record = { + ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { + position: [5, 0, 5], + 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 snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId) + expect(snap?.descendants).toHaveLength(2) + // Descendants keep their local positions. + expect((snap?.descendants[0] as any).position).toEqual([0, 0, 0]) + expect((snap?.descendants[1] as any).position).toEqual([0.3, 0, 0]) + // Internal child references rewritten to tokens, not original ids. + const childTokens = (snap?.root as any).children as string[] + expect(childTokens).toHaveLength(2) + for (const t of childTokens) { + expect(t.includes('::')).toBe(true) + expect(t.startsWith('item::')).toBe(true) + } + }) +}) + +describe('materializeSubtree', () => { + test('round-trips a single-node snapshot at a new position with fresh ids', () => { + const original = makeNode('door_orig', 'door', { + position: [1, 2, 3], + wallId: 'wall_x', + width: 0.9, + height: 2.1, + }) + const snap = buildSubtreeSnapshot( + { ['door_orig' as AnyNodeId]: original }, + 'door_orig' as AnyNodeId, + ) + if (!snap) throw new Error('snap') + + const { rootId, nodes } = materializeSubtree(snap, [10, 0, -4]) + expect(nodes).toHaveLength(1) + const newRoot = nodes[0] as any + expect(newRoot.id).toBe(rootId) + expect(newRoot.id).not.toBe('door_orig') + expect(newRoot.id.startsWith('door_')).toBe(true) + expect(newRoot.parentId).toBeNull() + expect(newRoot.position).toEqual([10, 0, -4]) + // Host ref was stripped at snapshot time; materialize doesn't re-add it. + expect(newRoot.wallId).toBeUndefined() + // Parametric fields preserved verbatim. + expect(newRoot.width).toBe(0.9) + expect(newRoot.height).toBe(2.1) + }) + + test('preserves a parent/child subtree with remapped ids and relative positions', () => { + const nodes: Record = { + ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { + position: [5, 0, 5], + 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 snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId)! + const { rootId, nodes: out } = materializeSubtree(snap, [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]) + // Children point at fresh ids that exist in the output. + 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) + // Descendant parentIds point at the new root id. + for (let i = 1; i < out.length; i += 1) { + const desc = out[i] as any + expect(desc.parentId).toBe(rootId) + // Position preserved. + expect(Array.isArray(desc.position)).toBe(true) + } + // Internal-token metadata is gone. + for (const node of out) { + const md = (node as any).metadata ?? {} + expect(md.__subtreeKey).toBeUndefined() + } + }) + + test('two materializations yield disjoint id sets', () => { + const nodes: Record = { + ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { + position: [0, 0, 0], + children: ['item_a'], + }), + ['item_a' as AnyNodeId]: makeNode('item_a', 'item', { + parentId: 'shelf_1', + position: [0, 0, 0], + }), + } + const snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId)! + const first = materializeSubtree(snap, [0, 0, 0]) + const second = materializeSubtree(snap, [1, 0, 0]) + 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) + }) +}) diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts new file mode 100644 index 000000000..7c9aac3aa --- /dev/null +++ b/packages/core/src/registry/subtree.ts @@ -0,0 +1,278 @@ +import { generateId } from '../schema/base' +import type { AnyNode, AnyNodeId } from '../schema/types' + +// A serializable, location-independent snapshot of a single-root node +// subtree — designed to round-trip through JSON storage (the unified +// `items` catalog's `node_data` column) and re-materialize at a new +// position with fresh IDs. +// +// Stripping rules applied at snapshot time: +// 1. `id` is removed from the root and every descendant; the host's +// `parentId` on the root is removed too. Fresh IDs are minted at +// materialize time, and parent / child references are rewritten +// with the new IDs. +// 2. The root's absolute world `position` is stripped — the placement +// site decides where the preset lands. Descendants keep their +// positions verbatim because those are local-to-parent (still valid +// after the root is repositioned). +// 3. Host anchor fields on the root are stripped: `wallId` (doors, +// windows, items hosted on walls) and `wallT` (linear parameter +// along the wall). They are re-derived at materialize time by the +// consumer's auto-attach UX (drop a door on a wall → re-anchor). +// 4. Every other field — `rotation`, parametric fields, `children`, +// `metadata`, schema-defined defaults — is preserved verbatim. +// +// The shape is intentionally a plain `AnyNode`-shaped record (with the +// id/parentId-bearing properties optional) plus a flat descendants +// array, rather than a recursive tree, so consumers parsing it through +// a kind's Zod schema land in the same world as `createNode` (one node +// per registry entry, container fields holding ID arrays). + +export type SubtreeNode = Omit & { + // Children on container kinds are kept as either an array of legacy + // descendant IDs (subtree-relative — re-mapped at materialize time) + // or an array of fresh prefixes the materializer turns into IDs. We + // preserve the original strings verbatim and rebuild a fresh ID map + // at materialize time. + children?: AnyNodeId[] +} + +export type NodeSubtree = { + /** Kind of the root node — duplicated from `root.type` for cheap lookups before parsing. */ + rootKind: string + /** Root node, with id / parentId / absolute position / host refs stripped. */ + root: SubtreeNode + /** Flat list of descendants. Each carries its `parentId` pointing inside the subtree. */ + descendants: SubtreeNode[] + /** + * Stable internal IDs (UUID-free, only valid within this snapshot) so + * `parentId` / `children` arrays inside `descendants` can reference + * each other. The materialize step minted fresh real IDs and remaps + * these tokens to them. + */ + internalIds: { + rootKey: string + /** Per-descendant token. `descendants[i].metadata.__subtreeKey` carries the same string. */ + descendantKeys: string[] + } +} + +const SUBTREE_KEY = '__subtreeKey' + +function getDescendantIds(node: AnyNode): AnyNodeId[] { + if ('children' in node && Array.isArray((node as { children?: unknown }).children)) { + return (node as { children: AnyNodeId[] }).children + } + return [] +} + +function stripRootFields(node: AnyNode): SubtreeNode { + // Doors / windows / wall-hosted items carry `wallId`; doors / windows + // also carry `side`, but `side` is a logical wall-side declaration the + // re-attach UX can re-derive from cursor + hit normal. Keeping it on + // descendants is fine — only the root is ever re-anchored. + const { + id: _id, + parentId: _parentId, + position: _position, + wallId: _wallId, + wallT: _wallT, + ...rest + } = node as AnyNode & { + position?: unknown + wallId?: unknown + wallT?: unknown + } + return rest as SubtreeNode +} + +function stripDescendantFields(node: AnyNode): SubtreeNode { + // Descendants keep their `parentId` — it's been rewritten to point at + // the parent's internal token by the caller, and `materializeSubtree` + // reads it to re-anchor the descendant under the freshly-minted root. + // Only `id` is stripped (a fresh one is minted at materialize time). + const { id: _id, ...rest } = node + return rest as SubtreeNode +} + +/** + * Build a {@link NodeSubtree} snapshot rooted at `rootId`. Walks the + * `children` array recursively, so any kind that participates in the + * scene-graph's containment model is captured (slab → ceiling holes + * stay on the slab, stair → segments, roof → segments, shelf → items). + * + * Returns `null` if `rootId` is missing from `nodes`. + */ +export function buildSubtreeSnapshot( + nodes: Readonly>, + rootId: AnyNodeId, +): NodeSubtree | null { + const rootNode = nodes[rootId] + if (!rootNode) return null + + // Collect every node id reachable from the root via `children`. + // FIFO walk so siblings keep their original `children` array order + // in `descendants` — important for kinds where order is semantic + // (stair segments, roof segments). The root lands at index 0. + const subtreeIds: AnyNodeId[] = [] + const seen = new Set() + const queue: AnyNodeId[] = [rootId] + 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) + subtreeIds.push(id) + for (const childId of getDescendantIds(node)) queue.push(childId) + } + + // Assign internal tokens. Mirror the existing id prefix so the + // generated IDs at materialize time keep the same `wall_…`, `door_…` + // shape — helpful for debugging and lookup heuristics. + const idToKey = new Map() + let counter = 0 + for (const id of subtreeIds) { + const prefix = id.includes('_') ? id.slice(0, id.indexOf('_')) : 'node' + idToKey.set(id, `${prefix}::${counter++}`) + } + const rootKey = idToKey.get(rootId)! + + // Clone + rewrite each node so internal references point at tokens + // instead of original ids. Tokens land in `metadata.__subtreeKey` + // for descendants so we can re-discover them at materialize time. + const descendants: SubtreeNode[] = [] + let rootStripped: SubtreeNode | null = null + + for (const id of subtreeIds) { + const original = nodes[id] + if (!original) continue + // Deep-clone via JSON: strips three.js refs / functions / circular + // links (same trick `cloneLevelSubtree` uses for runtime nodes). + const cloned = JSON.parse(JSON.stringify(original)) as AnyNode + // Rewrite children to tokens. + if ('children' in cloned && Array.isArray((cloned as { children?: unknown }).children)) { + ;(cloned as { children: unknown }).children = (cloned as { children: AnyNodeId[] }).children + .map((cid) => idToKey.get(cid)) + .filter((key): key is string => key !== undefined) as unknown as AnyNodeId[] + } + // Rewrite parentId on descendants to point at the parent's token. + if (id !== rootId && cloned.parentId) { + const parentKey = idToKey.get(cloned.parentId as AnyNodeId) + ;(cloned as { parentId: string | null }).parentId = parentKey ?? null + } + if (id === rootId) { + rootStripped = stripRootFields(cloned) + } else { + const stripped = stripDescendantFields(cloned) + const meta = (stripped as { metadata?: Record }).metadata + ;(stripped as { metadata?: Record }).metadata = { + ...(meta ?? {}), + [SUBTREE_KEY]: idToKey.get(id), + } + descendants.push(stripped) + } + } + + if (!rootStripped) return null + + return { + rootKind: rootNode.type, + root: rootStripped, + descendants, + internalIds: { + rootKey, + descendantKeys: descendants.map((d) => { + const meta = (d as { metadata?: Record }).metadata + return (meta?.[SUBTREE_KEY] as string) ?? '' + }), + }, + } +} + +export type MaterializedSubtree = { + /** Fresh id assigned to the root. */ + rootId: AnyNodeId + /** Every materialized node, root first, ready to feed into `createNodes`. */ + nodes: AnyNode[] + /** Internal-token → fresh-id map, mostly useful for tests. */ + idMap: Map +} + +/** + * Re-hydrate a {@link NodeSubtree} into a flat list of real nodes with + * fresh IDs. The caller decides where to insert them — typically by + * passing `nodes[0]` as the root op to `createNodes`, with `parentId` + * set to the active level / wall / parent surface — and `position` is + * stamped onto the root before materializing. + * + * Stripping is reversed: the root receives the supplied `position`; + * host anchor fields (wallId / wallT) stay absent and must be filled + * by the caller's auto-attach pass when applicable. + * + * The returned `nodes` are NOT parsed through the Zod schemas — the + * caller is responsible for `def.schema.parse(...)` before insertion + * if it wants schema-default merging. `createNode` re-validates via + * the registry, so unsafe payloads can't slip into the scene store. + */ +export function materializeSubtree( + subtree: NodeSubtree, + position: readonly [number, number, number], +): MaterializedSubtree { + const idMap = new Map() + + function tokenToId(token: string): AnyNodeId { + const existing = idMap.get(token) + if (existing) return existing + const prefix = token.includes('::') ? token.slice(0, token.indexOf('::')) : 'node' + const fresh = generateId(prefix) as AnyNodeId + idMap.set(token, fresh) + return fresh + } + + const rootId = tokenToId(subtree.internalIds.rootKey) + + // Reserve fresh IDs for all descendants up-front so children arrays + // resolve regardless of declaration order. + for (const key of subtree.internalIds.descendantKeys) tokenToId(key) + + function remap(node: SubtreeNode, freshId: AnyNodeId, parentId: AnyNodeId | null): AnyNode { + const remapped = JSON.parse(JSON.stringify(node)) as AnyNode + ;(remapped as { id: AnyNodeId }).id = freshId + ;(remapped as { parentId: AnyNodeId | null }).parentId = parentId + if ('children' in remapped && Array.isArray((remapped as { children?: unknown }).children)) { + ;(remapped as { children: AnyNodeId[] }).children = ( + remapped as { children: string[] } + ).children + .map((token) => idMap.get(token)) + .filter((id): id is AnyNodeId => id !== undefined) + } + // Strip the internal-token marker — irrelevant once materialized. + const meta = (remapped as { metadata?: Record }).metadata + if (meta && SUBTREE_KEY in meta) { + const { [SUBTREE_KEY]: _drop, ...rest } = meta + ;(remapped as { metadata: Record }).metadata = rest + } + return remapped + } + + const rootNode = remap(subtree.root, rootId, null) + ;(rootNode as { position: [number, number, number] }).position = [ + position[0], + position[1], + position[2], + ] + + const out: AnyNode[] = [rootNode] + for (let i = 0; i < subtree.descendants.length; i += 1) { + const descendant = subtree.descendants[i]! + const token = subtree.internalIds.descendantKeys[i]! + const freshId = tokenToId(token) + const parentToken = (descendant as { parentId?: string | null }).parentId ?? null + const parentFreshId = parentToken ? (idMap.get(parentToken) ?? null) : null + out.push(remap(descendant, freshId, parentFreshId)) + } + + return { rootId, nodes: out, idMap } +} diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6f80cfdc3..41d75f4db 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -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 { NodeSubtree } from './subtree' // ─── GeometryContext ───────────────────────────────────────────────── // @@ -970,6 +971,24 @@ export type Capabilities = { * declaring the same flag. */ floorplanLevelContainer?: boolean + /** + * 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 +1301,24 @@ export type SceneApi = { markDirty: (id: AnyNodeId) => void pauseHistory: () => void resumeHistory: () => void + /** + * Build a {@link NodeSubtree} snapshot rooted at `rootId` — a + * serializable, location-independent payload suitable for storage + * in the unified `items` catalog. See {@link buildSubtreeSnapshot} + * for the stripping rules. Returns `null` if `rootId` is missing. + */ + getSubtreeSnapshot: (rootId: AnyNodeId) => NodeSubtree | null + /** + * Re-hydrate a {@link NodeSubtree} into the scene at `position`. The + * root and every descendant get fresh IDs; the root is parented to + * `parentId` (when provided) or becomes a scene root. Returns the + * new root id, or `null` if the subtree had no root node. + */ + materializeSubtree: ( + subtree: NodeSubtree, + position: readonly [number, number, number], + parentId?: AnyNodeId, + ) => AnyNodeId | null } // ─── Registry surface ──────────────────────────────────────────────── diff --git a/packages/core/src/services/drag-session.test.ts b/packages/core/src/services/drag-session.test.ts index 29869f81b..129a4b85c 100644 --- a/packages/core/src/services/drag-session.test.ts +++ b/packages/core/src/services/drag-session.test.ts @@ -52,6 +52,8 @@ function makeSpyScene(initial: Record = {}): SceneApi & { resumeHistory: () => { calls.resumeHistory += 1 }, + getSubtreeSnapshot: () => null, + materializeSubtree: () => null, _calls: calls, } } diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts index e3ae6cb20..9a4ba8087 100644 --- a/packages/core/src/services/hosting.test.ts +++ b/packages/core/src/services/hosting.test.ts @@ -52,6 +52,8 @@ function makeFakeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, + getSubtreeSnapshot: () => null, + materializeSubtree: () => null, } } diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index d86325959..99532a909 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -7,7 +7,11 @@ import { useIsMobile } from '../../hooks/use-mobile' import { triggerSFX } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' -type CaptureMode = 'standard' | 'viewport' | 'area' +// Local crop-mode enum — distinct from `useEditor.captureMode` (which +// describes *why* a capture is happening, e.g. `preset`). This one says +// HOW the captured pixels are cropped: full-frame 16:9 (`standard`), +// raw canvas viewport, or user-dragged area. +type CropMode = 'standard' | 'viewport' | 'area' type CaptureState = 'idle' | 'capturing' | 'saved' interface DragPoint { @@ -21,7 +25,7 @@ interface Drag { } function getResolution( - mode: CaptureMode, + mode: CropMode, overlayEl: HTMLDivElement | null, drag: Drag | null, ): { w: number; h: number } | null { @@ -47,10 +51,15 @@ function getResolution( export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const isCaptureMode = useEditor((s) => s.isCaptureMode) + const captureMode = useEditor((s) => s.captureMode) const setCaptureMode = useEditor((s) => s.setCaptureMode) const isMobile = useIsMobile() + // `preset` capture mode locks the overlay to a square area crop with + // a transparent background — the user picks framing but not the + // crop shape. Matches the unified preset-thumbnail capture flow. + const isPreset = captureMode.mode === 'preset' - const [mode, setMode] = useState('standard') + const [mode, setMode] = useState('standard') const [drag, setDrag] = useState(null) const [isDragging, setIsDragging] = useState(false) const [captureState, setCaptureState] = useState('idle') @@ -66,15 +75,16 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return () => window.removeEventListener('keydown', onKey) }, [isCaptureMode, setCaptureMode]) - // Reset local state when entering capture mode + // Reset local state when entering capture mode. Preset mode forces + // `area` so the overlay shows the square selection rect immediately. useEffect(() => { if (isCaptureMode) { - setMode('standard') + setMode(isPreset ? 'area' : 'standard') setDrag(null) setIsDragging(false) setCaptureState('idle') } - }, [isCaptureMode]) + }, [isCaptureMode, isPreset]) // Listen for snapshot saved to show feedback then exit useEffect(() => { @@ -142,11 +152,28 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy }, end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy }, }) + } else if (isPreset) { + // Preset mode locks the rect to a square — use the smaller + // axis to keep the drag predictable, sign-correct so the user + // can still drag in any quadrant. + setDrag((d) => { + if (!d) return null + const dx = pt.x - d.start.x + const dy = pt.y - d.start.y + const side = Math.min(Math.abs(dx), Math.abs(dy)) + return { + start: d.start, + end: { + x: d.start.x + Math.sign(dx || 1) * side, + y: d.start.y + Math.sign(dy || 1) * side, + }, + } + }) } else { setDrag((d) => (d ? { start: d.start, end: pt } : null)) } }, - [isDragging], + [isDragging, isPreset], ) const onPointerUp = useCallback(() => { @@ -225,8 +252,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { projectId, captureMode: mode, cropRegion, + // In preset mode, the ThumbnailGenerator should keep the alpha + // channel transparent so the saved preset thumbnail composes + // cleanly onto any palette background. + transparent: isPreset, }) - }, [captureState, mode, drag, projectId]) + }, [captureState, mode, drag, projectId, isPreset]) if (!isCaptureMode) return null @@ -343,7 +374,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { {/* Bottom-center mode toolbar */}
{(() => { - const modeButtons = ( + // Preset capture mode locks both the crop shape (square) and + // the transparent-background output — hide the per-shape + // mode buttons so the user has nothing to second-guess. + const modeButtons = isPreset ? null : ( <> -
{modeButtons}
-
+ {modeButtons && ( +
{modeButtons}
+ )} +
{resolutionDisplay} {captureButton}
@@ -420,7 +462,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return (
{modeButtons} -
+ {modeButtons &&
} {resolutionDisplay}
{captureButton} diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index d23812f27..e81cde62d 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -461,6 +461,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro captureMode?: 'standard' | 'viewport' | 'area' cropRegion?: { x: number; y: number; width: number; height: number } snapLevels?: boolean + // `transparent` is informational here — the render pipeline already + // captures with alpha (see `setClearAlpha(0)` above) — the flag is + // forwarded so future tweaks (suppressing the ground occluder, theme + // background bits) can branch on it without touching the emitter. + transparent?: boolean }) => { await generate(event.snapLevels === true, event.captureMode, event.cropRegion) } diff --git a/packages/editor/src/hooks/use-selection.ts b/packages/editor/src/hooks/use-selection.ts new file mode 100644 index 000000000..0a118f8a4 --- /dev/null +++ b/packages/editor/src/hooks/use-selection.ts @@ -0,0 +1,64 @@ +'use client' + +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' + +/** + * Resolved current selection — selected node IDs plus a convenience + * lookup into the live scene store. Returned shape is intentionally + * narrow: hosts that need richer per-node state can compose this with + * `useScene()` themselves. + */ +export type Selection = { + /** Multi-select node IDs (drives the rest of the editor's UI). */ + selectedIds: AnyNodeId[] + /** Currently active building context — surfaces / palettes filter on this. */ + buildingId: AnyNodeId | null + /** Currently active level context. */ + levelId: AnyNodeId | null + /** Currently active zone context. */ + zoneId: AnyNodeId | null + /** + * Resolved nodes for `selectedIds`. Missing entries (deleted nodes) are + * filtered out, so the array length may be ≤ `selectedIds.length`. + */ + selectedNodes: AnyNode[] + /** + * The single selected node, or `null` when zero or multiple nodes are + * selected. Useful for "save as preset" / inspector gating where the + * UI only makes sense for a unique selection. + */ + selectedNode: AnyNode | null +} + +/** + * Subscribe to the current selection. Equivalent to reading from + * `useViewer().selection` plus a live `useScene()` lookup, packaged as + * a single hook so consumers building their own shells (community, + * standalone editor app, embedders) don't have to learn the two + * separate stores. + * + * Selection state intentionally lives in `useViewer` (it tracks the + * camera / visibility hierarchy: building → level → zone → nodes), not + * `useScene` — see `wiki/architecture/scene-registry.md`. + */ +export function useSelection(): Selection { + const selection = useViewer((s) => s.selection) + const nodes = useScene((s) => s.nodes) + + const selectedIds = selection.selectedIds as AnyNodeId[] + const selectedNodes = selectedIds + .map((id) => nodes[id]) + .filter((n): n is AnyNode => n !== undefined) + const selectedNode = selectedNodes.length === 1 ? (selectedNodes[0] ?? null) : null + + return { + selectedIds, + buildingId: (selection.buildingId ?? null) as AnyNodeId | null, + levelId: (selection.levelId ?? null) as AnyNodeId | null, + zoneId: (selection.zoneId ?? null) as AnyNodeId | null, + selectedNodes, + selectedNode, + } +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c4ead4eed..476bf94c4 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -1,5 +1,17 @@ +// Re-exports of the scene / viewer hooks so consumers composing their +// own shells on top of `@pascal-app/editor` (community-app, embedders) +// don't have to learn three separate package imports. The canonical +// definitions still live in `@pascal-app/core` / `@pascal-app/viewer`. +export { useScene } from '@pascal-app/core' +export { useViewer } from '@pascal-app/viewer' export type { EditorProps } from './components/editor' export { default as Editor } from './components/editor' +// Headless component aliases: the implementation files keep their +// internal names (`ParametricInspector`, `FloatingActionMenu`) because +// they're referenced throughout the editor's own internals; the public +// surface uses the shorter, shell-friendly names from the unified +// preset-system spec. +export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' export { type SnapshotCameraData, ThumbnailGenerator, @@ -89,8 +101,19 @@ export { WALL_FINE_GRID_STEP, type WallPlanPoint, } from './components/tools/wall/wall-drafting' -export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' -export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' +// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the +// existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the +// underlying components are the same; the alias just matches the names +// used in `pascalorg/private-editor:plans/community-preset-system.md` +// so consumer code stays close to the spec vocabulary. +export { + CameraActions as ToolbarRight, + CameraActions as ViewerToolbarRight, +} from './components/ui/action-menu/camera-actions' +export { + ViewToggles as ToolbarLeft, + ViewToggles as ViewerToolbarLeft, +} from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' export { ActionButton, ActionGroup } from './components/ui/controls/action-button' export { MaterialPicker } from './components/ui/controls/material-picker' @@ -107,6 +130,7 @@ export { CollectionsPopover } from './components/ui/panels/collections/collectio // ceiling height presets, etc.) use `parametrics.customPanel` to mount // a kind-owned panel and need PanelWrapper for the chrome. export { PanelWrapper } from './components/ui/panels/panel-wrapper' +export { ParametricInspector as Inspector } from './components/ui/panels/parametric-inspector' // Presets popover — used by kind-owned door / window panels for their // hardware / type / opening presets. export { PresetsPopover } from './components/ui/panels/presets/presets-popover' @@ -138,6 +162,7 @@ export type { SaveStatus } from './hooks/use-auto-save' export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). export { markToolCancelConsumed } from './hooks/use-keyboard' +export { type Selection, useSelection } from './hooks/use-selection' export { EDITOR_LAYER } from './lib/constants' // Helper libs used by the kind-owned roof / stair / elevator panels. export { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0b0d74248..b1b37adc0 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -48,6 +48,20 @@ const MAX_FLOORPLAN_PANE_RATIO = 0.85 export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' +// Snapshot capture is invoked from two surfaces with different policies. +// `standard` mirrors the existing user-driven UX — pick region / viewport / +// area, save the blob as a project thumbnail. `preset` is the constrained +// variant for the unified preset capture flow (community save-as-preset +// modal): the overlay locks to a square crop, the renderer clears alpha +// (transparent background), and the rendered set is locked to `isolated` +// — `ThumbnailGenerator` consults `captureMode.mode === 'preset'` and +// applies those constraints. Keeping it a discriminated union lets us +// add future modes without surfacing the choice to end users. +export type CaptureMode = + | { mode: 'idle' } + | { mode: 'standard' } + | { mode: 'preset'; isolated: AnyNodeId[] } + export type Phase = 'site' | 'structure' | 'furnish' export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' @@ -253,9 +267,16 @@ type EditorState = { // Preview mode (viewer-like experience inside the editor) isPreviewMode: boolean setPreviewMode: (preview: boolean) => void - // Capture mode (snapshot toolbar — hides panels for clean framing) + // Capture mode (snapshot toolbar — hides panels for clean framing). + // `captureMode` is the canonical discriminated-union state; the boolean + // `isCaptureMode` is kept synced as a derived convenience for the many + // existing read sites that just gate chrome visibility on "is capture + // active". New write sites should pass a `CaptureMode` shape; passing a + // boolean is accepted as a back-compat shim (`true` → `'standard'`, + // `false` → `'idle'`). + captureMode: CaptureMode isCaptureMode: boolean - setCaptureMode: (active: boolean) => void + setCaptureMode: (next: boolean | CaptureMode) => void // View mode (3D only, 2D only, or split 2D+3D) viewMode: ViewMode setViewMode: (mode: ViewMode) => void @@ -287,7 +308,6 @@ type EditorState = { setFirstPersonMode: (enabled: boolean) => void activeSidebarPanel: string setActiveSidebarPanel: (id: string) => void - setIsCaptureMode: (enabled: boolean) => void floorplanPaneRatio: number setFloorplanPaneRatio: (ratio: number) => void // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed). @@ -739,8 +759,13 @@ const useEditor = create()( set({ isPreviewMode: false }) } }, + captureMode: { mode: 'idle' } as CaptureMode, isCaptureMode: false, - setCaptureMode: (active) => set({ isCaptureMode: active }), + setCaptureMode: (next) => { + const resolved: CaptureMode = + typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next + set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' }) + }, viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode, setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }), splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation, @@ -795,7 +820,6 @@ const useEditor = create()( }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }), - setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }), floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio, setFloorplanPaneRatio: (ratio) => set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }), diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index 4f050ce38..207733a14 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -28,6 +28,7 @@ export const buildingDefinition: NodeDefinition = { duplicable: false, deletable: false, floorplanLevelContainer: true, + presettable: false, }, parametrics: buildingParametrics, diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index 5eff55a8f..49985f837 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -24,6 +24,9 @@ export const guideDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: false, deletable: true, + // Guides are scene-specific measurement annotations — saving them + // as reusable catalog items has no meaning. + presettable: false, }, parametrics: guideParametrics, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 3f54947f1..400bd1ba4 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -72,6 +72,10 @@ export const itemDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // The GLB-kind item already has its own catalog — the unified + // preset system treats `kind='preset'` (parametric subtree + // snapshots) and `kind='glb'` (this) as siblings, not duplicates. + presettable: false, // Floor items get lifted by slabs underneath via the generic // ``. Wall- / ceiling-attached items live in // their parent's local frame and skip the lift via `applies`. diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index c502a32c9..e7623f684 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -30,6 +30,9 @@ export const levelDefinition: NodeDefinition = { // mirror that. duplicable: false, deletable: true, + // Container kind — saving a level as a standalone preset has no + // meaning (its contents make sense only inside a building). + presettable: false, }, parametrics: levelParametrics, diff --git a/packages/nodes/src/scan/definition.ts b/packages/nodes/src/scan/definition.ts index 1edd6d0a8..f97ee2568 100644 --- a/packages/nodes/src/scan/definition.ts +++ b/packages/nodes/src/scan/definition.ts @@ -23,6 +23,9 @@ export const scanDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: false, deletable: true, + // Scans carry user-uploaded imagery — cataloging them as + // reusable presets is out of scope. + presettable: false, }, parametrics: scanParametrics, diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index c8c2f956b..70dd62b0c 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -26,6 +26,7 @@ export const siteDefinition: NodeDefinition = { // override their selection). Same reasoning as `level`. duplicable: false, deletable: false, + presettable: false, }, parametrics: siteParametrics, diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 45e430acc..543f98699 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -27,6 +27,8 @@ export const spawnDefinition: NodeDefinition = { duplicable: false, // singleton per level deletable: true, selectable: { hitVolume: 'bbox' }, + // Spawn is a singleton anchor — no meaning as a reusable preset. + presettable: false, // Slab elevation lift via the generic ``. The // spawn marker is a 1.8m-tall figure with a ~0.6m ring footprint. floorPlaced: { diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index 01c3dc728..a1e44d2e8 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -30,6 +30,9 @@ export const zoneDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // Zones describe regions of a site — they don't translate as + // reusable presets independent of their site context. + presettable: false, }, parametrics: zoneParametrics, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 8601c0e9b..0c0e69089 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,10 +1,11 @@ 'use client' -import { StairOpeningSystem } from '@pascal-app/core' +import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' -import { useEffect } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import * as THREE from 'three/webgpu' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' +import { applyIsolation, clearIsolation } from '../../lib/isolation' import type { ColorPreset, RenderShading } from '../../lib/materials' import { getSceneTheme } from '../../lib/scene-themes' import useViewer, { type RenderContext } from '../../store/use-viewer' @@ -130,17 +131,63 @@ interface ViewerProps { textures?: boolean colorPreset?: ColorPreset } + /** + * Visibility filter on the live canvas. When non-null, every registered + * node group whose id is not in `isolate` (or in the isolated set's + * ancestor / descendant closure) is hidden. Pass `null` (or omit) to + * clear. Powers the unified preset-capture flow (community modal sets + * this to the subtree it wants to thumbnail) and is the building block + * for a future focus-mode UX. + */ + isolate?: AnyNodeId[] | null } -const Viewer: React.FC = ({ - children, - hoverStyles = DEFAULT_HOVER_STYLES, - selectionManager = 'default', - perf = false, - useBvh = true, - renderContext = 'editor', - defaultRender, -}) => { +/** Imperative handle exposed via `ref` on ``. */ +export type ViewerHandle = { + /** + * Apply / clear the same visibility filter as the `isolate` prop. Useful + * for transient cases (a temporary hover-to-isolate UX) where holding + * the value in React state would be over-engineering. Passing `null` + * clears. + */ + setIsolated(ids: AnyNodeId[] | null): void +} + +const Viewer = forwardRef(function Viewer( + { + children, + hoverStyles = DEFAULT_HOVER_STYLES, + selectionManager = 'default', + perf = false, + useBvh = true, + renderContext = 'editor', + defaultRender, + isolate, + }, + ref, +) { + useImperativeHandle( + ref, + () => ({ + setIsolated: (ids) => applyIsolation(ids), + }), + [], + ) + + // Track the most recently-applied isolation so the cleanup path can + // restore visibility even if the prop is removed while the component is + // still mounted. `clearIsolation()` is a no-op when nothing was applied. + const isolateRef = useRef(undefined) + useEffect(() => { + isolateRef.current = isolate ?? null + applyIsolation(isolate ?? null) + return () => { + // Only clear if this effect was the one that applied — protects + // against a parent unmount racing with a setIsolated() consumer. + if (isolateRef.current === isolate) clearIsolation() + } + }, [isolate]) + const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') useEffect(() => { const ctx = renderContext @@ -261,7 +308,7 @@ const Viewer: React.FC = ({ ) -} +}) const DebugRenderer = () => { useFrame(({ gl, scene, camera }) => { diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5cdbc7b1f..fa034da39 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,7 +12,7 @@ export { ErrorBoundary } from './components/error-boundary' // `@pascal-app/nodes//renderer.tsx` and are loaded by the registry // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' -export { default as Viewer } from './components/viewer' +export { default as Viewer, type ViewerHandle } from './components/viewer' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, @@ -37,6 +37,11 @@ export { SUBTRACTION, } from './lib/csg-utils' export type { EdgeMode } from './lib/edge-style' +export { + applyIsolation, + clearIsolation, + computeIsolationVisibleSet, +} from './lib/isolation' export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { applyMaterialPresetToMaterials, diff --git a/packages/viewer/src/lib/isolation.ts b/packages/viewer/src/lib/isolation.ts new file mode 100644 index 000000000..83f4d88ae --- /dev/null +++ b/packages/viewer/src/lib/isolation.ts @@ -0,0 +1,96 @@ +'use client' + +import type { AnyNodeId } from '@pascal-app/core' +import { sceneRegistry, useScene } from '@pascal-app/core' +import type { Object3D } from 'three' + +// Marker stashed on each Object3D we touch during isolation so we can +// restore the original `.visible` flag. Stored under a `Symbol` so it +// can't collide with any kind's own userData fields. +const ORIGINAL_VISIBLE = Symbol('isolation:original-visible') + +type IsolationCarrier = Object3D & { [ORIGINAL_VISIBLE]?: boolean } + +/** + * Build the set of node IDs that must remain visible to "isolate" the + * provided ids — the ids themselves, every ancestor along the parent + * chain (so containers stay rendered, otherwise the scene root would go + * dark), and every descendant (so children of the isolated nodes still + * render even after we explicitly toggle individual visibility flags). + * + * Pure / no I/O — exported for testing. + */ +export function computeIsolationVisibleSet( + ids: ReadonlyArray, + nodes: Readonly>, +): Set { + const visible = new Set(ids) + + for (const id of ids) { + let parentId = nodes[id]?.parentId + while (parentId) { + visible.add(parentId) + parentId = nodes[parentId]?.parentId + } + } + + const stack: string[] = [...ids] + while (stack.length > 0) { + const current = stack.pop()! + const node = nodes[current] + const children = node && Array.isArray(node.children) ? (node.children as string[]) : [] + for (const child of children) { + if (!visible.has(child)) { + visible.add(child) + stack.push(child) + } + } + } + + return visible +} + +/** + * Imperative visibility filter on the live `sceneRegistry`. Walks every + * registered (id, Object3D) pair and toggles `obj.visible` so only nodes + * inside the isolation set remain rendered. Stashes the original visible + * flag under a private Symbol so {@link clearIsolation} can restore the + * exact prior state — important because nodes may have been hidden by + * other features (`useScene.nodes[id].visible === false`). + * + * Composite-visibility note: setting an ancestor group to `visible=true` + * is necessary because Three.js culls every descendant when an ancestor + * is hidden. The pre-image of the isolation set therefore includes both + * the ancestor chain and the descendant tree of the requested IDs. + * + * Pass `null` to clear isolation (equivalent to calling + * {@link clearIsolation}). + */ +export function applyIsolation(ids: ReadonlyArray | null): void { + if (ids == null || ids.length === 0) { + clearIsolation() + return + } + + const visible = computeIsolationVisibleSet( + ids as ReadonlyArray, + useScene.getState().nodes, + ) + for (const [id, obj] of sceneRegistry.nodes) { + const carrier = obj as IsolationCarrier + if (carrier[ORIGINAL_VISIBLE] === undefined) { + carrier[ORIGINAL_VISIBLE] = carrier.visible + } + carrier.visible = visible.has(id) + } +} + +export function clearIsolation(): void { + for (const [, obj] of sceneRegistry.nodes) { + const carrier = obj as IsolationCarrier + if (carrier[ORIGINAL_VISIBLE] !== undefined) { + carrier.visible = carrier[ORIGINAL_VISIBLE] + delete carrier[ORIGINAL_VISIBLE] + } + } +} From f206b0ed82bc0fd4f84b8775dbd9476acee25411 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 28 May 2026 08:11:46 -0400 Subject: [PATCH 2/4] refactor(editor): split snapshot/materialize into pure getSubtree + cloneNodesInto; add def.hostRefFields; auto-stage preset capture square MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - `` 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) --- .../__bench__/relations-resolver.bench.ts | 4 +- packages/core/src/registry/index.ts | 11 +- packages/core/src/registry/registry.test.ts | 13 + packages/core/src/registry/registry.ts | 10 + .../src/registry/relations-resolver.test.ts | 4 +- packages/core/src/registry/scene-api.ts | 39 +- packages/core/src/registry/subtree.test.ts | 176 ++++----- packages/core/src/registry/subtree.ts | 361 +++++++----------- packages/core/src/registry/types.ts | 50 ++- .../core/src/services/drag-session.test.ts | 4 +- packages/core/src/services/hosting.test.ts | 4 +- .../editor/snapshot-capture-overlay.tsx | 25 +- packages/nodes/src/door/definition.ts | 18 +- packages/nodes/src/item/definition.ts | 6 + packages/nodes/src/window/definition.ts | 18 +- packages/viewer/src/index.ts | 6 +- packages/viewer/src/lib/isolation.ts | 129 ++++--- 17 files changed, 417 insertions(+), 461 deletions(-) diff --git a/packages/core/src/registry/__bench__/relations-resolver.bench.ts b/packages/core/src/registry/__bench__/relations-resolver.bench.ts index cc90af35d..b8a1f274e 100644 --- a/packages/core/src/registry/__bench__/relations-resolver.bench.ts +++ b/packages/core/src/registry/__bench__/relations-resolver.bench.ts @@ -110,8 +110,8 @@ function makeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, - getSubtreeSnapshot: () => null, - materializeSubtree: () => null, + getSubtree: () => null, + cloneNodesInto: () => null, } } diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 4968ba6eb..d3c8e8833 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -15,6 +15,7 @@ export type { } from './handles' export { discoverPlugins, + getHostRefFields, getSelectableKinds, isPresettable, isPresettableKind, @@ -36,11 +37,11 @@ export { } from './relations-resolver' export { createSceneApi, type SceneStoreLike } from './scene-api' export { - buildSubtreeSnapshot, - type MaterializedSubtree, - materializeSubtree, - type NodeSubtree, - type SubtreeNode, + type CloneNodesIntoOptions, + type CloneNodesIntoResult, + cloneNodesInto, + collectSubtree, + type Subtree, } from './subtree' export type { Affordance, diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 79ab46df7..48f42ffa1 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { z } from 'zod' import { + getHostRefFields, isPresettable, isPresettableKind, loadPlugin, @@ -111,6 +112,18 @@ describe('isPresettable', () => { }) }) +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() diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index c6ce27f2f..9968e5906 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -164,6 +164,16 @@ export function isPresettableKind(kind: string): boolean { 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 { + return def.capabilities.hostRefFields ?? [] +} + export async function loadPlugin(plugin: Plugin): Promise { if (plugin.apiVersion !== HOST_API_VERSION) { throw new Error( diff --git a/packages/core/src/registry/relations-resolver.test.ts b/packages/core/src/registry/relations-resolver.test.ts index 21a4ff1c7..b323d1a4b 100644 --- a/packages/core/src/registry/relations-resolver.test.ts +++ b/packages/core/src/registry/relations-resolver.test.ts @@ -47,8 +47,8 @@ function makeFakeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, - getSubtreeSnapshot: () => null, - materializeSubtree: () => null, + getSubtree: () => null, + cloneNodesInto: () => null, } } diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts index 7d7631b53..0c4ed37f1 100644 --- a/packages/core/src/registry/scene-api.ts +++ b/packages/core/src/registry/scene-api.ts @@ -1,6 +1,10 @@ import type { AnyNode, AnyNodeId } from '../schema/types' import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' -import { buildSubtreeSnapshot, materializeSubtree as runMaterializeSubtree } from './subtree' +import { + type CloneNodesIntoOptions, + collectSubtree, + cloneNodesInto as runCloneNodesInto, +} from './subtree' import type { SceneApi } from './types' /** @@ -107,36 +111,29 @@ export function createSceneApi(store: SceneStoreLike): SceneApi { snapshot = null }, - getSubtreeSnapshot(rootId) { - return buildSubtreeSnapshot(store.getState().nodes, rootId) + getSubtree(rootId) { + return collectSubtree(store.getState().nodes, rootId) }, - materializeSubtree(subtree, position, parentId) { - const { rootId, nodes } = runMaterializeSubtree(subtree, position) - const state = store.getState() - // Prefer batched `createNodes` when the store exposes it — keeps - // children-array writes and dirty-marking in one tick, identical - // to how `applyNodeChanges` lands a multi-node paste. The - // minimal `SceneStoreLike` does not require it, so the test - // store can fall back to per-node `createNode` calls. - const root = nodes[0] + 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 < nodes.length; i += 1) { - const node = nodes[i]! + for (let i = 0; i < cloned.length; i += 1) { + const node = cloned[i]! if (i === 0) { - ops.push(parentId ? { node, parentId } : { node }) + ops.push(opts.parentId ? { node, parentId: opts.parentId } : { node }) } else { ops.push({ node }) } } - const createNodes = state.createNodes - if (createNodes) { - createNodes(ops) + const batch = state.createNodes + if (batch) { + batch(ops) } else { - for (const op of ops) { - state.createNode(op.node, op.parentId) - } + for (const op of ops) state.createNode(op.node, op.parentId) } return rootId }, diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts index 726d10c70..cc23d849b 100644 --- a/packages/core/src/registry/subtree.test.ts +++ b/packages/core/src/registry/subtree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId } from '../schema/types' -import { buildSubtreeSnapshot, materializeSubtree } from './subtree' +import { cloneNodesInto, collectSubtree } from './subtree' function makeNode(id: string, type: string, extra: Record = {}): AnyNode { return { @@ -14,38 +14,22 @@ function makeNode(id: string, type: string, extra: Record = {}) } as unknown as AnyNode } -describe('buildSubtreeSnapshot', () => { +describe('collectSubtree', () => { test('returns null for missing root', () => { - expect(buildSubtreeSnapshot({}, 'missing' as AnyNodeId)).toBeNull() + expect(collectSubtree({}, 'missing' as AnyNodeId)).toBeNull() }) - test('strips id / parentId / position / wallId from the root', () => { - const nodes: Record = { - ['door_1' as AnyNodeId]: makeNode('door_1', 'door', { - parentId: 'level_1', - position: [1, 2, 3], - wallId: 'wall_x', - wallT: 0.4, - width: 0.9, - height: 2.1, - }), - } - const snap = buildSubtreeSnapshot(nodes, 'door_1' as AnyNodeId) - expect(snap).not.toBeNull() - expect((snap?.root as any).id).toBeUndefined() - expect((snap?.root as any).parentId).toBeUndefined() - expect((snap?.root as any).position).toBeUndefined() - expect((snap?.root as any).wallId).toBeUndefined() - expect((snap?.root as any).wallT).toBeUndefined() - expect((snap?.root as any).width).toBe(0.9) - expect((snap?.root as any).height).toBe(2.1) - expect(snap?.rootKind).toBe('door') + 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('captures descendants via the children array', () => { + test('walks descendants in BFS / declaration order', () => { const nodes: Record = { ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { - position: [5, 0, 5], + position: [0, 0, 0], children: ['item_a', 'item_b'], width: 1, }), @@ -58,107 +42,103 @@ describe('buildSubtreeSnapshot', () => { position: [0.3, 0, 0], }), } - const snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId) - expect(snap?.descendants).toHaveLength(2) - // Descendants keep their local positions. - expect((snap?.descendants[0] as any).position).toEqual([0, 0, 0]) - expect((snap?.descendants[1] as any).position).toEqual([0.3, 0, 0]) - // Internal child references rewritten to tokens, not original ids. - const childTokens = (snap?.root as any).children as string[] - expect(childTokens).toHaveLength(2) - for (const t of childTokens) { - expect(t.includes('::')).toBe(true) - expect(t.startsWith('item::')).toBe(true) + 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 = { + ['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('materializeSubtree', () => { - test('round-trips a single-node snapshot at a new position with fresh ids', () => { +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, - height: 2.1, }) - const snap = buildSubtreeSnapshot( - { ['door_orig' as AnyNodeId]: original }, - 'door_orig' as AnyNodeId, - ) - if (!snap) throw new Error('snap') - - const { rootId, nodes } = materializeSubtree(snap, [10, 0, -4]) + const { rootId, nodes } = cloneNodesInto([original], { + rootId: 'door_orig' as AnyNodeId, + position: [10, 0, -4], + }) expect(nodes).toHaveLength(1) - const newRoot = nodes[0] as any - expect(newRoot.id).toBe(rootId) - expect(newRoot.id).not.toBe('door_orig') - expect(newRoot.id.startsWith('door_')).toBe(true) - expect(newRoot.parentId).toBeNull() - expect(newRoot.position).toEqual([10, 0, -4]) - // Host ref was stripped at snapshot time; materialize doesn't re-add it. - expect(newRoot.wallId).toBeUndefined() - // Parametric fields preserved verbatim. - expect(newRoot.width).toBe(0.9) - expect(newRoot.height).toBe(2.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 a parent/child subtree with remapped ids and relative positions', () => { - const nodes: Record = { - ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { - position: [5, 0, 5], - 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 snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId)! - const { rootId, nodes: out } = materializeSubtree(snap, [99, 0, -99]) + 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]) - // Children point at fresh ids that exist in the output. + // 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) - // Descendant parentIds point at the new root id. for (let i = 1; i < out.length; i += 1) { const desc = out[i] as any expect(desc.parentId).toBe(rootId) - // Position preserved. expect(Array.isArray(desc.position)).toBe(true) } - // Internal-token metadata is gone. - for (const node of out) { - const md = (node as any).metadata ?? {} - expect(md.__subtreeKey).toBeUndefined() - } }) - test('two materializations yield disjoint id sets', () => { - const nodes: Record = { - ['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { - position: [0, 0, 0], - children: ['item_a'], - }), - ['item_a' as AnyNodeId]: makeNode('item_a', 'item', { - parentId: 'shelf_1', - position: [0, 0, 0], - }), - } - const snap = buildSubtreeSnapshot(nodes, 'shelf_1' as AnyNodeId)! - const first = materializeSubtree(snap, [0, 0, 0]) - const second = materializeSubtree(snap, [1, 0, 0]) + 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/) + }) }) diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index 7c9aac3aa..11be553e2 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -1,122 +1,62 @@ import { generateId } from '../schema/base' import type { AnyNode, AnyNodeId } from '../schema/types' -// A serializable, location-independent snapshot of a single-root node -// subtree — designed to round-trip through JSON storage (the unified -// `items` catalog's `node_data` column) and re-materialize at a new -// position with fresh IDs. +// Generic, opinion-free primitives the host app composes to implement +// catalog / paste / duplicate / preset flows. // -// Stripping rules applied at snapshot time: -// 1. `id` is removed from the root and every descendant; the host's -// `parentId` on the root is removed too. Fresh IDs are minted at -// materialize time, and parent / child references are rewritten -// with the new IDs. -// 2. The root's absolute world `position` is stripped — the placement -// site decides where the preset lands. Descendants keep their -// positions verbatim because those are local-to-parent (still valid -// after the root is repositioned). -// 3. Host anchor fields on the root are stripped: `wallId` (doors, -// windows, items hosted on walls) and `wallT` (linear parameter -// along the wall). They are re-derived at materialize time by the -// consumer's auto-attach UX (drop a door on a wall → re-anchor). -// 4. Every other field — `rotation`, parametric fields, `children`, -// `metadata`, schema-defined defaults — is preserved verbatim. +// 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. // -// The shape is intentionally a plain `AnyNode`-shaped record (with the -// id/parentId-bearing properties optional) plus a flat descendants -// array, rather than a recursive tree, so consumers parsing it through -// a kind's Zod schema land in the same world as `createNode` (one node -// per registry entry, container fields holding ID arrays). - -export type SubtreeNode = Omit & { - // Children on container kinds are kept as either an array of legacy - // descendant IDs (subtree-relative — re-mapped at materialize time) - // or an array of fresh prefixes the materializer turns into IDs. We - // preserve the original strings verbatim and rebuild a fresh ID map - // at materialize time. - children?: AnyNodeId[] +// 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[] } -export type NodeSubtree = { - /** Kind of the root node — duplicated from `root.type` for cheap lookups before parsing. */ - rootKind: string - /** Root node, with id / parentId / absolute position / host refs stripped. */ - root: SubtreeNode - /** Flat list of descendants. Each carries its `parentId` pointing inside the subtree. */ - descendants: SubtreeNode[] - /** - * Stable internal IDs (UUID-free, only valid within this snapshot) so - * `parentId` / `children` arrays inside `descendants` can reference - * each other. The materialize step minted fresh real IDs and remaps - * these tokens to them. - */ - internalIds: { - rootKey: string - /** Per-descendant token. `descendants[i].metadata.__subtreeKey` carries the same string. */ - descendantKeys: string[] - } +function extractIdPrefix(id: string): string { + const i = id.indexOf('_') + return i === -1 ? 'node' : id.slice(0, i) } -const SUBTREE_KEY = '__subtreeKey' - -function getDescendantIds(node: AnyNode): AnyNodeId[] { +function getChildIds(node: AnyNode): AnyNodeId[] { if ('children' in node && Array.isArray((node as { children?: unknown }).children)) { return (node as { children: AnyNodeId[] }).children } return [] } -function stripRootFields(node: AnyNode): SubtreeNode { - // Doors / windows / wall-hosted items carry `wallId`; doors / windows - // also carry `side`, but `side` is a logical wall-side declaration the - // re-attach UX can re-derive from cursor + hit normal. Keeping it on - // descendants is fine — only the root is ever re-anchored. - const { - id: _id, - parentId: _parentId, - position: _position, - wallId: _wallId, - wallT: _wallT, - ...rest - } = node as AnyNode & { - position?: unknown - wallId?: unknown - wallT?: unknown - } - return rest as SubtreeNode -} - -function stripDescendantFields(node: AnyNode): SubtreeNode { - // Descendants keep their `parentId` — it's been rewritten to point at - // the parent's internal token by the caller, and `materializeSubtree` - // reads it to re-anchor the descendant under the freshly-minted root. - // Only `id` is stripped (a fresh one is minted at materialize time). - const { id: _id, ...rest } = node - return rest as SubtreeNode -} - /** - * Build a {@link NodeSubtree} snapshot rooted at `rootId`. Walks the - * `children` array recursively, so any kind that participates in the - * scene-graph's containment model is captured (slab → ceiling holes - * stay on the slab, stair → segments, roof → segments, shelf → items). + * Collect the subtree of nodes rooted at `rootId` from the live scene. * - * Returns `null` if `rootId` is missing from `nodes`. + * - 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 buildSubtreeSnapshot( +export function collectSubtree( nodes: Readonly>, rootId: AnyNodeId, -): NodeSubtree | null { - const rootNode = nodes[rootId] - if (!rootNode) return null +): Subtree | null { + const root = nodes[rootId] + if (!root) return null - // Collect every node id reachable from the root via `children`. - // FIFO walk so siblings keep their original `children` array order - // in `descendants` — important for kinds where order is semantic - // (stair segments, roof segments). The root lands at index 0. - const subtreeIds: AnyNodeId[] = [] - const seen = new Set() - const queue: AnyNodeId[] = [rootId] + const descendants: AnyNode[] = [] + const seen = new Set([rootId]) + const queue: AnyNodeId[] = [...getChildIds(root)] let head = 0 while (head < queue.length) { const id = queue[head++]! @@ -124,155 +64,124 @@ export function buildSubtreeSnapshot( const node = nodes[id] if (!node) continue seen.add(id) - subtreeIds.push(id) - for (const childId of getDescendantIds(node)) queue.push(childId) + descendants.push(node) + for (const childId of getChildIds(node)) queue.push(childId) } - // Assign internal tokens. Mirror the existing id prefix so the - // generated IDs at materialize time keep the same `wall_…`, `door_…` - // shape — helpful for debugging and lookup heuristics. - const idToKey = new Map() - let counter = 0 - for (const id of subtreeIds) { - const prefix = id.includes('_') ? id.slice(0, id.indexOf('_')) : 'node' - idToKey.set(id, `${prefix}::${counter++}`) - } - const rootKey = idToKey.get(rootId)! - - // Clone + rewrite each node so internal references point at tokens - // instead of original ids. Tokens land in `metadata.__subtreeKey` - // for descendants so we can re-discover them at materialize time. - const descendants: SubtreeNode[] = [] - let rootStripped: SubtreeNode | null = null - - for (const id of subtreeIds) { - const original = nodes[id] - if (!original) continue - // Deep-clone via JSON: strips three.js refs / functions / circular - // links (same trick `cloneLevelSubtree` uses for runtime nodes). - const cloned = JSON.parse(JSON.stringify(original)) as AnyNode - // Rewrite children to tokens. - if ('children' in cloned && Array.isArray((cloned as { children?: unknown }).children)) { - ;(cloned as { children: unknown }).children = (cloned as { children: AnyNodeId[] }).children - .map((cid) => idToKey.get(cid)) - .filter((key): key is string => key !== undefined) as unknown as AnyNodeId[] - } - // Rewrite parentId on descendants to point at the parent's token. - if (id !== rootId && cloned.parentId) { - const parentKey = idToKey.get(cloned.parentId as AnyNodeId) - ;(cloned as { parentId: string | null }).parentId = parentKey ?? null - } - if (id === rootId) { - rootStripped = stripRootFields(cloned) - } else { - const stripped = stripDescendantFields(cloned) - const meta = (stripped as { metadata?: Record }).metadata - ;(stripped as { metadata?: Record }).metadata = { - ...(meta ?? {}), - [SUBTREE_KEY]: idToKey.get(id), - } - descendants.push(stripped) - } - } - - if (!rootStripped) return null + return { root, descendants } +} - return { - rootKind: rootNode.type, - root: rootStripped, - descendants, - internalIds: { - rootKey, - descendantKeys: descendants.map((d) => { - const meta = (d as { metadata?: Record }).metadata - return (meta?.[SUBTREE_KEY] as string) ?? '' - }), - }, - } +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 MaterializedSubtree = { - /** Fresh id assigned to the root. */ +export type CloneNodesIntoResult = { + /** Fresh id assigned to the root in the destination scene. */ rootId: AnyNodeId - /** Every materialized node, root first, ready to feed into `createNodes`. */ + /** Every cloned node, root first, ready to feed into `createNodes`. */ nodes: AnyNode[] - /** Internal-token → fresh-id map, mostly useful for tests. */ - idMap: Map + /** Original id → fresh id map, mostly useful for tests and host-side bookkeeping. */ + idMap: Map } /** - * Re-hydrate a {@link NodeSubtree} into a flat list of real nodes with - * fresh IDs. The caller decides where to insert them — typically by - * passing `nodes[0]` as the root op to `createNodes`, with `parentId` - * set to the active level / wall / parent surface — and `position` is - * stamped onto the root before materializing. + * Clone a flat array of nodes with fresh IDs and rewired references, + * ready to insert via `useScene.createNodes`. * - * Stripping is reversed: the root receives the supplied `position`; - * host anchor fields (wallId / wallT) stay absent and must be filled - * by the caller's auto-attach pass when applicable. + * 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. * - * The returned `nodes` are NOT parsed through the Zod schemas — the - * caller is responsible for `def.schema.parse(...)` before insertion - * if it wants schema-default merging. `createNode` re-validates via - * the registry, so unsafe payloads can't slip into the scene store. + * 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 materializeSubtree( - subtree: NodeSubtree, - position: readonly [number, number, number], -): MaterializedSubtree { - const idMap = new Map() - - function tokenToId(token: string): AnyNodeId { - const existing = idMap.get(token) - if (existing) return existing - const prefix = token.includes('::') ? token.slice(0, token.indexOf('::')) : 'node' - const fresh = generateId(prefix) as AnyNodeId - idMap.set(token, fresh) - return fresh +export function cloneNodesInto( + nodes: ReadonlyArray, + opts: CloneNodesIntoOptions, +): CloneNodesIntoResult { + // Phase 1 — mint fresh ids for every node, preserving the prefix. + const idMap = new Map() + for (const node of nodes) { + const prefix = extractIdPrefix(node.id) + idMap.set(node.id, generateId(prefix) as AnyNodeId) } - const rootId = tokenToId(subtree.internalIds.rootKey) - - // Reserve fresh IDs for all descendants up-front so children arrays - // resolve regardless of declaration order. - for (const key of subtree.internalIds.descendantKeys) tokenToId(key) + const rootFreshId = idMap.get(opts.rootId) + if (!rootFreshId) { + throw new Error(`cloneNodesInto: rootId "${opts.rootId}" not found in supplied nodes array`) + } - function remap(node: SubtreeNode, freshId: AnyNodeId, parentId: AnyNodeId | null): AnyNode { - const remapped = JSON.parse(JSON.stringify(node)) as AnyNode - ;(remapped as { id: AnyNodeId }).id = freshId - ;(remapped as { parentId: AnyNodeId | null }).parentId = parentId - if ('children' in remapped && Array.isArray((remapped as { children?: unknown }).children)) { - ;(remapped as { children: AnyNodeId[] }).children = ( - remapped as { children: string[] } + // 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((token) => idMap.get(token)) - .filter((id): id is AnyNodeId => id !== undefined) + .map((cid) => idMap.get(cid)) + .filter((cid): cid is AnyNodeId => cid !== undefined) } - // Strip the internal-token marker — irrelevant once materialized. - const meta = (remapped as { metadata?: Record }).metadata - if (meta && SUBTREE_KEY in meta) { - const { [SUBTREE_KEY]: _drop, ...rest } = meta - ;(remapped as { metadata: Record }).metadata = rest + + 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) } - return remapped } - const rootNode = remap(subtree.root, rootId, null) - ;(rootNode as { position: [number, number, number] }).position = [ - position[0], - position[1], - position[2], - ] - - const out: AnyNode[] = [rootNode] - for (let i = 0; i < subtree.descendants.length; i += 1) { - const descendant = subtree.descendants[i]! - const token = subtree.internalIds.descendantKeys[i]! - const freshId = tokenToId(token) - const parentToken = (descendant as { parentId?: string | null }).parentId ?? null - const parentFreshId = parentToken ? (idMap.get(parentToken) ?? null) : null - out.push(remap(descendant, freshId, parentFreshId)) + if (!root) { + throw new Error('cloneNodesInto: root node missing after clone') } - return { rootId, nodes: out, idMap } + return { rootId: rootFreshId, nodes: [root, ...out], idMap } } diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 41d75f4db..7eb93f755 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -4,7 +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 { NodeSubtree } from './subtree' +import type { CloneNodesIntoOptions, Subtree } from './subtree' // ─── GeometryContext ───────────────────────────────────────────────── // @@ -971,6 +971,25 @@ 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 @@ -1302,23 +1321,24 @@ export type SceneApi = { pauseHistory: () => void resumeHistory: () => void /** - * Build a {@link NodeSubtree} snapshot rooted at `rootId` — a - * serializable, location-independent payload suitable for storage - * in the unified `items` catalog. See {@link buildSubtreeSnapshot} - * for the stripping rules. Returns `null` if `rootId` is missing. + * 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. */ - getSubtreeSnapshot: (rootId: AnyNodeId) => NodeSubtree | null + getSubtree: (rootId: AnyNodeId) => Subtree | null /** - * Re-hydrate a {@link NodeSubtree} into the scene at `position`. The - * root and every descendant get fresh IDs; the root is parented to - * `parentId` (when provided) or becomes a scene root. Returns the - * new root id, or `null` if the subtree had no root node. + * 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. */ - materializeSubtree: ( - subtree: NodeSubtree, - position: readonly [number, number, number], - parentId?: AnyNodeId, - ) => AnyNodeId | null + cloneNodesInto: (nodes: ReadonlyArray, opts: CloneNodesIntoOptions) => AnyNodeId | null } // ─── Registry surface ──────────────────────────────────────────────── diff --git a/packages/core/src/services/drag-session.test.ts b/packages/core/src/services/drag-session.test.ts index 129a4b85c..5852dc773 100644 --- a/packages/core/src/services/drag-session.test.ts +++ b/packages/core/src/services/drag-session.test.ts @@ -52,8 +52,8 @@ function makeSpyScene(initial: Record = {}): SceneApi & { resumeHistory: () => { calls.resumeHistory += 1 }, - getSubtreeSnapshot: () => null, - materializeSubtree: () => null, + getSubtree: () => null, + cloneNodesInto: () => null, _calls: calls, } } diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts index 9a4ba8087..ee0607c5b 100644 --- a/packages/core/src/services/hosting.test.ts +++ b/packages/core/src/services/hosting.test.ts @@ -52,8 +52,8 @@ function makeFakeScene(nodes: Record): SceneApi { markDirty: () => {}, pauseHistory: () => {}, resumeHistory: () => {}, - getSubtreeSnapshot: () => null, - materializeSubtree: () => null, + getSubtree: () => null, + cloneNodesInto: () => null, } } diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index 99532a909..cf6bd4654 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -75,14 +75,27 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return () => window.removeEventListener('keydown', onKey) }, [isCaptureMode, setCaptureMode]) - // Reset local state when entering capture mode. Preset mode forces - // `area` so the overlay shows the square selection rect immediately. + // Reset local state when entering capture mode. Preset mode also + // auto-stages a centered square crop sized to ~75% of the shorter + // viewport dimension so the user can capture immediately — the + // overlay's pan/move/resize handles still apply if they want to + // tweak the framing, but they don't have to draw the rect first. useEffect(() => { - if (isCaptureMode) { - setMode(isPreset ? 'area' : 'standard') + if (!isCaptureMode) return + setMode(isPreset ? 'area' : 'standard') + setIsDragging(false) + setCaptureState('idle') + if (isPreset && overlayRef.current) { + const rect = overlayRef.current.getBoundingClientRect() + const side = Math.min(rect.width, rect.height) * 0.75 + const cx = rect.width / 2 + const cy = rect.height / 2 + setDrag({ + start: { x: cx - side / 2, y: cy - side / 2 }, + end: { x: cx + side / 2, y: cy + side / 2 }, + }) + } else { setDrag(null) - setIsDragging(false) - setCaptureState('idle') } }, [isCaptureMode, isPreset]) diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 2b007d62d..9791a7661 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -1,12 +1,12 @@ -import { - type AnyNodeId, - type DoorNode as DoorNodeType, - type HandleDescriptor, - type NodeDefinition, - type WallNode, +import type { + AnyNodeId, + DoorNode as DoorNodeType, + HandleDescriptor, + NodeDefinition, + WallNode, } from '@pascal-app/core' -import { doorWidthAffordance } from './floorplan-affordances' import { buildDoorFloorplan } from './floorplan' +import { doorWidthAffordance } from './floorplan-affordances' import { doorFloorplanMoveTarget } from './floorplan-move' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' @@ -143,6 +143,10 @@ export const doorDefinition: NodeDefinition = { duplicable: true, deletable: true, wallOpeningPlacement: true, + // `wallId` ties the door to its host wall and is re-derived from + // the wall under the cursor when a preset is placed. Host apps + // strip this at preset-save time via `getHostRefFields(def)`. + hostRefFields: ['wallId'], }, parametrics: doorParametrics, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 400bd1ba4..c48635da3 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -76,6 +76,12 @@ export const itemDefinition: NodeDefinition = { // preset system treats `kind='preset'` (parametric subtree // snapshots) and `kind='glb'` (this) as siblings, not duplicates. presettable: false, + // Items can be hosted on walls (assets with `attachTo: 'wall'`) + // via `wallId` + `wallT`. When a parametric composition that + // includes a wall-hosted item is saved as a preset (e.g. a + // shelf-with-items), the host app strips these so the descendant + // re-attaches against the new wall geometry at placement time. + hostRefFields: ['wallId', 'wallT'], // Floor items get lifted by slabs underneath via the generic // ``. Wall- / ceiling-attached items live in // their parent's local frame and skip the lift via `applies`. diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index cf7bf683a..d083a4bde 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -1,9 +1,9 @@ -import { - type AnyNodeId, - type HandleDescriptor, - type NodeDefinition, - type WallNode, - type WindowNode as WindowNodeType, +import type { + AnyNodeId, + HandleDescriptor, + NodeDefinition, + WallNode, + WindowNode as WindowNodeType, } from '@pascal-app/core' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' @@ -77,8 +77,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor = { duplicable: true, deletable: true, wallOpeningPlacement: true, + // `wallId` is re-derived from the wall under the cursor at preset + // placement time — see the door capability for the same pattern. + hostRefFields: ['wallId'], }, parametrics: windowParametrics, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fa034da39..adbc18845 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -37,11 +37,7 @@ export { SUBTRACTION, } from './lib/csg-utils' export type { EdgeMode } from './lib/edge-style' -export { - applyIsolation, - clearIsolation, - computeIsolationVisibleSet, -} from './lib/isolation' +export { applyIsolation, clearIsolation, collectIsolationSubtree } from './lib/isolation' export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { applyMaterialPresetToMaterials, diff --git a/packages/viewer/src/lib/isolation.ts b/packages/viewer/src/lib/isolation.ts index 83f4d88ae..bf4845730 100644 --- a/packages/viewer/src/lib/isolation.ts +++ b/packages/viewer/src/lib/isolation.ts @@ -1,67 +1,56 @@ 'use client' import type { AnyNodeId } from '@pascal-app/core' -import { sceneRegistry, useScene } from '@pascal-app/core' +import { sceneRegistry } from '@pascal-app/core' import type { Object3D } from 'three' +import { SCENE_LAYER } from './layers' -// Marker stashed on each Object3D we touch during isolation so we can -// restore the original `.visible` flag. Stored under a `Symbol` so it +// Marker on each Object3D we modify during isolation so we can restore +// the original `layers.mask` bitfield. Stored under a `Symbol` so it // can't collide with any kind's own userData fields. -const ORIGINAL_VISIBLE = Symbol('isolation:original-visible') +const ORIGINAL_LAYERS = Symbol('isolation:original-layers') -type IsolationCarrier = Object3D & { [ORIGINAL_VISIBLE]?: boolean } +type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number } /** - * Build the set of node IDs that must remain visible to "isolate" the - * provided ids — the ids themselves, every ancestor along the parent - * chain (so containers stay rendered, otherwise the scene root would go - * dark), and every descendant (so children of the isolated nodes still - * render even after we explicitly toggle individual visibility flags). + * Compute the union of every isolated subtree's `Object3D` descendants. * - * Pure / no I/O — exported for testing. + * Pure traversal — exported so future "focus mode" / debug tooling can + * reuse the same definition of "what's in the isolated set". Each root + * is walked via `Object3D.traverse` (the live Three.js graph, not the + * data-model `children` array — those can disagree when systems mount + * synthesized sub-meshes that the data model doesn't track). */ -export function computeIsolationVisibleSet( - ids: ReadonlyArray, - nodes: Readonly>, -): Set { - const visible = new Set(ids) - +export function collectIsolationSubtree(ids: ReadonlyArray): Set { + const keep = new Set() for (const id of ids) { - let parentId = nodes[id]?.parentId - while (parentId) { - visible.add(parentId) - parentId = nodes[parentId]?.parentId - } - } - - const stack: string[] = [...ids] - while (stack.length > 0) { - const current = stack.pop()! - const node = nodes[current] - const children = node && Array.isArray(node.children) ? (node.children as string[]) : [] - for (const child of children) { - if (!visible.has(child)) { - visible.add(child) - stack.push(child) - } - } + const root = sceneRegistry.nodes.get(id) + if (!root) continue + root.traverse((child) => { + keep.add(child) + }) } - - return visible + return keep } /** - * Imperative visibility filter on the live `sceneRegistry`. Walks every - * registered (id, Object3D) pair and toggles `obj.visible` so only nodes - * inside the isolation set remain rendered. Stashes the original visible - * flag under a private Symbol so {@link clearIsolation} can restore the - * exact prior state — important because nodes may have been hidden by - * other features (`useScene.nodes[id].visible === false`). + * Imperative visibility filter on the live `sceneRegistry`. Hides every + * registered group (and its synthesized child meshes) outside the + * isolated subtree by disabling the {@link SCENE_LAYER} bit on the + * relevant `Object3D.layers` masks. + * + * Why layers instead of `obj.visible = false`? Three.js's visibility + * flag *cascades* — hiding a parent hides every descendant — so we + * can't hide a host wall while keeping a door rendered inside it. + * Layer masks are per-object and don't cascade: `WebGLRenderer + * .projectObject` skips objects whose layer mask doesn't intersect the + * camera's, but always recurses into their children. So we can disable + * `SCENE_LAYER` on the wall and the door (hosted under it in the + * scene graph) still renders, with its local position relative to the + * wall preserved automatically by the matrix walk. * - * Composite-visibility note: setting an ancestor group to `visible=true` - * is necessary because Three.js culls every descendant when an ancestor - * is hidden. The pre-image of the isolation set therefore includes both - * the ancestor chain and the descendant tree of the requested IDs. + * The original `layers.mask` is stashed under a private Symbol so + * {@link clearIsolation} can restore the exact prior state. * * Pass `null` to clear isolation (equivalent to calling * {@link clearIsolation}). @@ -72,25 +61,41 @@ export function applyIsolation(ids: ReadonlyArray | null): void { return } - const visible = computeIsolationVisibleSet( - ids as ReadonlyArray, - useScene.getState().nodes, - ) - for (const [id, obj] of sceneRegistry.nodes) { - const carrier = obj as IsolationCarrier - if (carrier[ORIGINAL_VISIBLE] === undefined) { - carrier[ORIGINAL_VISIBLE] = carrier.visible - } - carrier.visible = visible.has(id) + const keep = collectIsolationSubtree(ids as ReadonlyArray) + + // Iterate registered roots. For each one outside the keep set, + // disable `SCENE_LAYER` on it and on every descendant — *except* + // descendants that are themselves in `keep` (a kept node nested under + // a non-kept host: the isolated door under the hidden wall). + for (const [, obj] of sceneRegistry.nodes) { + if (keep.has(obj)) continue + hideRecursive(obj, keep) + } +} + +function hideRecursive(obj: Object3D, keep: Set): void { + if (keep.has(obj)) return + const carrier = obj as IsolationCarrier + if (carrier[ORIGINAL_LAYERS] === undefined) { + carrier[ORIGINAL_LAYERS] = obj.layers.mask + } + obj.layers.disable(SCENE_LAYER) + for (const child of obj.children) { + hideRecursive(child, keep) } } export function clearIsolation(): void { + // We don't know which objects were touched without re-walking, so + // walk every registered root + its descendants and restore any + // stashed original-mask. `traverse` is cheap and idempotent here. for (const [, obj] of sceneRegistry.nodes) { - const carrier = obj as IsolationCarrier - if (carrier[ORIGINAL_VISIBLE] !== undefined) { - carrier.visible = carrier[ORIGINAL_VISIBLE] - delete carrier[ORIGINAL_VISIBLE] - } + obj.traverse((child) => { + const carrier = child as IsolationCarrier + if (carrier[ORIGINAL_LAYERS] !== undefined) { + child.layers.mask = carrier[ORIGINAL_LAYERS] + delete carrier[ORIGINAL_LAYERS] + } + }) } } From 72b30486ff51890dc215d449715fd59c9d5b30a6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 28 May 2026 08:20:05 -0400 Subject: [PATCH 3/4] fix(editor): lock preset capture frame; allow item presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../editor/snapshot-capture-overlay.tsx | 113 ++++++++++-------- packages/nodes/src/item/definition.ts | 19 +-- 2 files changed, 77 insertions(+), 55 deletions(-) diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index cf6bd4654..d3732e751 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -294,31 +294,47 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return (
- {/* Area mode: dim layer + crosshair cursor + drag-to-select */} + {/* Area mode: dim layer + crosshair cursor + drag-to-select. + * + * Preset mode reuses the same DOM but stays click-through: the + * crop frame is auto-staged and locked, so the user adjusts the + * camera (orbit / pan / zoom) instead of dragging the rect. The + * dim letterbox + dashed border still render via the inline + * `box-shadow` on the selection rect — they're cosmetic. */} {mode === 'area' && (
{ - onPointerMove(e) - // Update cursor: 'move' when hovering inside an existing selection - if (!isDragging && drag && overlayRef.current) { - const rect = overlayRef.current.getBoundingClientRect() - const px = e.clientX - rect.left - const py = e.clientY - rect.top - const x0 = Math.min(drag.start.x, drag.end.x) - const y0 = Math.min(drag.start.y, drag.end.y) - const x1 = Math.max(drag.start.x, drag.end.x) - const y1 = Math.max(drag.start.y, drag.end.y) - e.currentTarget.style.cursor = - px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair' - } - }} - onPointerUp={onPointerUp} - style={{ cursor: 'crosshair' }} + className={ + isPreset + ? 'pointer-events-none absolute inset-0' + : 'pointer-events-auto absolute inset-0 bg-black/30' + } + onPointerDown={isPreset ? undefined : onPointerDown} + onPointerMove={ + isPreset + ? undefined + : (e) => { + onPointerMove(e) + // Update cursor: 'move' when hovering inside an existing selection + if (!isDragging && drag && overlayRef.current) { + const rect = overlayRef.current.getBoundingClientRect() + const px = e.clientX - rect.left + const py = e.clientY - rect.top + const x0 = Math.min(drag.start.x, drag.end.x) + const y0 = Math.min(drag.start.y, drag.end.y) + const x1 = Math.max(drag.start.x, drag.end.x) + const y1 = Math.max(drag.start.y, drag.end.y) + e.currentTarget.style.cursor = + px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair' + } + } + } + onPointerUp={isPreset ? undefined : onPointerUp} + style={isPreset ? undefined : { cursor: 'crosshair' }} > - {/* "No selection" hint */} - {!selectionStyle && ( + {/* "No selection" hint — only when the user has to draw the + area themselves (`standard` capture). Preset mode always + has a pre-staged square, so we never show it there. */} + {!selectionStyle && !isPreset && (
Drag the area you want to capture @@ -341,31 +357,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { background: 'rgba(255,255,255,0.04)', }} > - {/* Corner handles */} - {( - [ - { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' }, - { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' }, - { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' }, - { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' }, - ] as const - ).map(({ pos, cursor }, i) => ( -
onCornerPointerDown(e, i)} - style={{ - position: 'absolute', - width: 10, - height: 10, - borderRadius: '50%', - background: 'white', - boxShadow: '0 1px 4px rgba(0,0,0,0.4)', - pointerEvents: 'auto', - cursor, - ...pos, - }} - /> - ))} + {/* Corner handles — preset mode locks the frame to the + auto-staged centered square; the user adjusts the + camera instead. */} + {!isPreset && + ( + [ + { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' }, + { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' }, + { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' }, + { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' }, + ] as const + ).map(({ pos, cursor }, i) => ( +
onCornerPointerDown(e, i)} + style={{ + position: 'absolute', + width: 10, + height: 10, + borderRadius: '50%', + background: 'white', + boxShadow: '0 1px 4px rgba(0,0,0,0.4)', + pointerEvents: 'auto', + cursor, + ...pos, + }} + /> + ))}
)}
diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index c48635da3..7ca83f1cf 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -72,15 +72,18 @@ export const itemDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // The GLB-kind item already has its own catalog — the unified - // preset system treats `kind='preset'` (parametric subtree - // snapshots) and `kind='glb'` (this) as siblings, not duplicates. - presettable: false, + // Items participate in compositions — e.g. "table-with-plants", + // "shelf-with-books-on-top" — so they're presettable in their own + // right (and as descendants of presettable parents). The GLB-kind + // catalog still exists alongside; preset-flavoured items become + // siblings of GLB items inside the unified `items` table. + // // Items can be hosted on walls (assets with `attachTo: 'wall'`) - // via `wallId` + `wallT`. When a parametric composition that - // includes a wall-hosted item is saved as a preset (e.g. a - // shelf-with-items), the host app strips these so the descendant - // re-attaches against the new wall geometry at placement time. + // via `wallId` + `wallT`. When a composition that includes a + // wall-hosted item is saved as a preset (a sconce, a hanging + // shelf, etc.), the host app strips these via `getHostRefFields(def)` + // so the descendant re-attaches against the new wall geometry at + // placement time. hostRefFields: ['wallId', 'wallT'], // Floor items get lifted by slabs underneath via the generic // ``. Wall- / ceiling-attached items live in From 2d2541da56dcb0dabb14d7560620a777849f3e15 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 28 May 2026 08:27:13 -0400 Subject: [PATCH 4/4] feat(editor): auto-frame camera on preset capture entry; restore on exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` 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) --- .../editor/custom-camera-controls.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 115f9e4fb..3c0d98b2f 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -319,6 +319,76 @@ export const CustomCameraControls = () => { ) }, [isPreviewMode, previewTargetNodeId]) + // Preset capture auto-framing — when `setCaptureMode({ mode: 'preset', + // isolated })` fires, fly the camera to a pose that fits the union + // bounds of the isolated subtree inside the locked square crop. The + // user can still pan / orbit / zoom from there; we only set the + // initial pose. On exit (`mode: 'idle'`), we restore the previous + // pose so the user lands back exactly where they were before the + // modal opened. + const captureMode = useEditor((s) => s.captureMode) + useEffect(() => { + if (!controls.current) return + if (captureMode.mode !== 'preset') return + const ids = captureMode.isolated + if (ids.length === 0) return + + // Stash the pre-capture pose so we can restore it on exit. Using + // a ref keeps the value across the cleanup phase without + // re-renders. + const restorePos = new Vector3() + const restoreTarget = new Vector3() + controls.current.getPosition(restorePos) + controls.current.getTarget(restoreTarget) + + // Union the bounds of every isolated subtree root. `setFromObject` + // walks the Three.js descendants automatically, so this picks up + // synthesized children (door/window cutouts under a wall, etc.). + tempBox.makeEmpty() + for (const id of ids) { + const obj = sceneRegistry.nodes.get(id) + if (!obj) continue + const sub = new Box3().setFromObject(obj) + if (!sub.isEmpty()) tempBox.union(sub) + } + if (tempBox.isEmpty()) return + + tempBox.getCenter(tempCenter) + tempBox.getSize(tempSize) + + // Distance heuristic: fit the subject inside the 75%-of-shorter- + // side square crop. Multiplier 1.6 keeps a small breathing margin + // around the bounds so the dashed frame doesn't kiss the geometry. + const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) + const distance = Math.max(maxDim * 1.6, 3) + + controls.current.setLookAt( + tempCenter.x + distance * 0.7, + tempCenter.y + Math.max(tempSize.y * 0.4, distance * 0.4), + tempCenter.z + distance * 0.7, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + + return () => { + // Cleanup runs on captureMode change *or* unmount. Restore the + // pre-capture pose only if the controls are still around (during + // unmount they might be torn down already). + if (!controls.current) return + controls.current.setLookAt( + restorePos.x, + restorePos.y, + restorePos.z, + restoreTarget.x, + restoreTarget.y, + restoreTarget.z, + true, + ) + } + }, [captureMode]) + useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { if (!controls.current) return