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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-28 09:49:50 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 350cad9c89
commit 1fd59dd9cd
31 changed files with 1087 additions and 113 deletions
+7
View File
@@ -130,6 +130,13 @@ export interface ThumbnailGenerateEvent {
* that should fire immediately from the current camera pose. * that should fire immediately from the current camera pose.
*/ */
snapLevels?: boolean 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 { export interface CameraControlFitSceneEvent {
@@ -110,6 +110,8 @@ function makeScene(nodes: Record<string, AnyNode>): SceneApi {
markDirty: () => {}, markDirty: () => {},
pauseHistory: () => {}, pauseHistory: () => {},
resumeHistory: () => {}, resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
} }
} }
+25 -15
View File
@@ -1,6 +1,24 @@
export type {
ArcResizeHandle,
Cursor,
EditorApi,
EndpointMoveHandle,
HandleAnchor,
HandleAxis,
HandleDescriptor,
HandleList,
HandlePlacement,
HandlePortal,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
} from './handles'
export { export {
discoverPlugins, discoverPlugins,
getHostRefFields,
getSelectableKinds, getSelectableKinds,
isPresettable,
isPresettableKind,
isRegistryMovable, isRegistryMovable,
isRegistrySelectable, isRegistrySelectable,
kindsWithFloorplanScope, kindsWithFloorplanScope,
@@ -17,22 +35,14 @@ export {
collectDescendants, collectDescendants,
type SpatialQuery, type SpatialQuery,
} from './relations-resolver' } 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 { createSceneApi, type SceneStoreLike } from './scene-api'
export {
type CloneNodesIntoOptions,
type CloneNodesIntoResult,
cloneNodesInto,
collectSubtree,
type Subtree,
} from './subtree'
export type { export type {
Affordance, Affordance,
AnyNodeDefinition, AnyNodeDefinition,
+55 -1
View File
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, test } from 'bun:test' import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod' import { z } from 'zod'
import { loadPlugin, nodeRegistry, registerNode } from './registry' import {
getHostRefFields,
isPresettable,
isPresettableKind,
loadPlugin,
nodeRegistry,
registerNode,
} from './registry'
import type { AnyNodeDefinition, Plugin } from './types' import type { AnyNodeDefinition, Plugin } from './types'
function makeDefinition( function makeDefinition(
@@ -70,6 +77,53 @@ describe('nodeRegistry', () => {
}) })
}) })
describe('isPresettable', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('explicit true wins', () => {
const def = makeDefinition('explicit-true', { capabilities: { presettable: true } })
expect(isPresettable(def)).toBe(true)
})
test('explicit false wins even with parametrics', () => {
const def = makeDefinition('explicit-false', {
capabilities: { presettable: false },
parametrics: { groups: [] } as any,
})
expect(isPresettable(def)).toBe(false)
})
test('defaults to true when parametrics exists', () => {
const def = makeDefinition('param', { parametrics: { groups: [] } as any })
expect(isPresettable(def)).toBe(true)
})
test('defaults to false without parametrics', () => {
const def = makeDefinition('no-param')
expect(isPresettable(def)).toBe(false)
})
test('isPresettableKind looks up the registry', () => {
registerNode(makeDefinition('shelfy', { parametrics: { groups: [] } as any }))
expect(isPresettableKind('shelfy')).toBe(true)
expect(isPresettableKind('unknown')).toBe(false)
})
})
describe('getHostRefFields', () => {
test('returns the declared hostRefFields verbatim', () => {
const def = makeDefinition('door', { capabilities: { hostRefFields: ['wallId'] } })
expect(getHostRefFields(def)).toEqual(['wallId'])
})
test('defaults to an empty array when none declared', () => {
const def = makeDefinition('shelf')
expect(getHostRefFields(def)).toEqual([])
})
})
describe('loadPlugin', () => { describe('loadPlugin', () => {
beforeEach(() => { beforeEach(() => {
nodeRegistry._reset() nodeRegistry._reset()
+28
View File
@@ -146,6 +146,34 @@ export function isRegistryMovable(kind: string): boolean {
return false return false
} }
/**
* Whether the kind can be saved as a reusable preset. Default: an
* explicit `capabilities.presettable` boolean wins; otherwise the kind
* is presettable iff it declares `def.parametrics`. Read by host apps
* (community shell) to gate "save as preset" UI on a selection.
*/
export function isPresettable(def: AnyNodeDefinition): boolean {
if (typeof def.capabilities.presettable === 'boolean') {
return def.capabilities.presettable
}
return def.parametrics !== undefined
}
export function isPresettableKind(kind: string): boolean {
const def = nodeRegistry.get(kind)
return def ? isPresettable(def) : false
}
/**
* Names of schema fields on `def` that are host references (`wallId`,
* `wallT`, etc.). Read by host apps at preset-save time to strip these
* from the stored payload — see `def.capabilities.hostRefFields` docs.
* Returns an empty array for kinds that don't declare any.
*/
export function getHostRefFields(def: AnyNodeDefinition): ReadonlyArray<string> {
return def.capabilities.hostRefFields ?? []
}
export async function loadPlugin(plugin: Plugin): Promise<void> { export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) { if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error( throw new Error(
@@ -47,6 +47,8 @@ function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
markDirty: () => {}, markDirty: () => {},
pauseHistory: () => {}, pauseHistory: () => {},
resumeHistory: () => {}, resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
} }
} }
+33
View File
@@ -1,5 +1,10 @@
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control' import { pauseSceneHistory, resumeSceneHistory } from '../store/history-control'
import {
type CloneNodesIntoOptions,
collectSubtree,
cloneNodesInto as runCloneNodesInto,
} from './subtree'
import type { SceneApi } from './types' import type { SceneApi } from './types'
/** /**
@@ -14,6 +19,7 @@ export type SceneStoreLike = {
rootNodeIds: AnyNodeId[] rootNodeIds: AnyNodeId[]
dirtyNodes: Set<AnyNodeId> dirtyNodes: Set<AnyNodeId>
createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNode: (node: AnyNode, parentId?: AnyNodeId) => void
createNodes?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
deleteNode: (id: AnyNodeId) => void deleteNode: (id: AnyNodeId) => void
markDirty: (id: AnyNodeId) => void markDirty: (id: AnyNodeId) => void
@@ -104,5 +110,32 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
resumeSceneHistory(store) resumeSceneHistory(store)
snapshot = null snapshot = null
}, },
getSubtree(rootId) {
return collectSubtree(store.getState().nodes, rootId)
},
cloneNodesInto(nodes, opts: CloneNodesIntoOptions) {
const { rootId, nodes: cloned } = runCloneNodesInto(nodes, opts)
const root = cloned[0]
if (!root) return null
const state = store.getState()
const ops: { node: AnyNode; parentId?: AnyNodeId }[] = []
for (let i = 0; i < cloned.length; i += 1) {
const node = cloned[i]!
if (i === 0) {
ops.push(opts.parentId ? { node, parentId: opts.parentId } : { node })
} else {
ops.push({ node })
}
}
const batch = state.createNodes
if (batch) {
batch(ops)
} else {
for (const op of ops) state.createNode(op.node, op.parentId)
}
return rootId
},
} }
} }
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { cloneNodesInto, collectSubtree } from './subtree'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return {
object: 'node',
id,
type,
parentId: null,
visible: true,
metadata: {},
...extra,
} as unknown as AnyNode
}
describe('collectSubtree', () => {
test('returns null for missing root', () => {
expect(collectSubtree({}, 'missing' as AnyNodeId)).toBeNull()
})
test('returns just the root for a leaf node', () => {
const root = makeNode('shelf_1', 'shelf', { width: 1 })
const sub = collectSubtree({ ['shelf_1' as AnyNodeId]: root }, 'shelf_1' as AnyNodeId)
expect(sub?.root).toBe(root)
expect(sub?.descendants).toEqual([])
})
test('walks descendants in BFS / declaration order', () => {
const nodes: Record<AnyNodeId, AnyNode> = {
['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', {
position: [0, 0, 0],
children: ['item_a', 'item_b'],
width: 1,
}),
['item_a' as AnyNodeId]: makeNode('item_a', 'item', {
parentId: 'shelf_1',
position: [0, 0, 0],
}),
['item_b' as AnyNodeId]: makeNode('item_b', 'item', {
parentId: 'shelf_1',
position: [0.3, 0, 0],
}),
}
const sub = collectSubtree(nodes, 'shelf_1' as AnyNodeId)
expect(sub?.descendants.map((n) => n.id)).toEqual(['item_a', 'item_b'])
})
test('returned nodes are live references — no cloning', () => {
const item = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const nodes: Record<AnyNodeId, AnyNode> = {
['shelf_1' as AnyNodeId]: makeNode('shelf_1', 'shelf', { children: ['item_a'] }),
['item_a' as AnyNodeId]: item,
}
const sub = collectSubtree(nodes, 'shelf_1' as AnyNodeId)
expect(sub?.descendants[0]).toBe(item)
})
})
describe('cloneNodesInto', () => {
test('clones a single root with fresh id and supplied position', () => {
const original = makeNode('door_orig', 'door', {
position: [1, 2, 3],
wallId: 'wall_x',
width: 0.9,
})
const { rootId, nodes } = cloneNodesInto([original], {
rootId: 'door_orig' as AnyNodeId,
position: [10, 0, -4],
})
expect(nodes).toHaveLength(1)
const cloned = nodes[0] as any
expect(cloned.id).toBe(rootId)
expect(cloned.id).not.toBe('door_orig')
expect(cloned.id.startsWith('door_')).toBe(true)
expect(cloned.position).toEqual([10, 0, -4])
expect(cloned.width).toBe(0.9)
// cloneNodesInto is host-ref-agnostic — wallId is preserved
// verbatim. Stripping is the caller's job (see getHostRefFields).
expect(cloned.wallId).toBe('wall_x')
})
test('preserves root position when none is supplied', () => {
const original = makeNode('shelf_orig', 'shelf', { position: [5, 0, 5] })
const { nodes } = cloneNodesInto([original], { rootId: 'shelf_orig' as AnyNodeId })
expect((nodes[0] as any).position).toEqual([5, 0, 5])
})
test('preserves parent/child subtree with remapped ids and relative positions', () => {
const shelf = makeNode('shelf_1', 'shelf', {
position: [5, 0, 5],
children: ['item_a', 'item_b'],
})
const itemA = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const itemB = makeNode('item_b', 'item', { parentId: 'shelf_1', position: [0.3, 0, 0] })
const { rootId, nodes: out } = cloneNodesInto([shelf, itemA, itemB], {
rootId: 'shelf_1' as AnyNodeId,
position: [99, 0, -99],
})
expect(out).toHaveLength(3)
const root = out[0] as any
expect(root.id).toBe(rootId)
expect(root.id).not.toBe('shelf_1')
expect(root.position).toEqual([99, 0, -99])
// Root's children rewritten to fresh ids; descendants' parentIds
// point at the new root id.
const ids = new Set(out.map((n) => (n as any).id))
expect(root.children).toHaveLength(2)
for (const cid of root.children) expect(ids.has(cid)).toBe(true)
for (let i = 1; i < out.length; i += 1) {
const desc = out[i] as any
expect(desc.parentId).toBe(rootId)
expect(Array.isArray(desc.position)).toBe(true)
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
rootId: 'shelf_1' as AnyNodeId,
parentId: 'level_new' as AnyNodeId,
})
expect((nodes[0] as any).parentId).toBe('level_new')
})
test('two clones produce disjoint id sets', () => {
const orig = makeNode('shelf_1', 'shelf', {
position: [0, 0, 0],
children: ['item_a'],
})
const child = makeNode('item_a', 'item', { parentId: 'shelf_1', position: [0, 0, 0] })
const first = cloneNodesInto([orig, child], { rootId: 'shelf_1' as AnyNodeId })
const second = cloneNodesInto([orig, child], { rootId: 'shelf_1' as AnyNodeId })
const idsA = new Set(first.nodes.map((n) => (n as any).id))
const idsB = new Set(second.nodes.map((n) => (n as any).id))
for (const id of idsA) expect(idsB.has(id)).toBe(false)
})
test('throws if rootId is missing from the input array', () => {
const orig = makeNode('shelf_1', 'shelf', {})
expect(() => cloneNodesInto([orig], { rootId: 'shelf_other' as AnyNodeId })).toThrow(/rootId/)
})
})
+187
View File
@@ -0,0 +1,187 @@
import { generateId } from '../schema/base'
import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement
// catalog / paste / duplicate / preset flows.
//
// Design intent (see pascalorg/editor#340 redesign):
// - The editor exposes a *pure* live-scene walk + a generic clone-and-
// insert helper. It owns nothing about storage shape, position
// re-anchoring policy, or host-ref re-derivation.
// - The host (community-app, embedders, etc.) decides whether to
// persist the subtree as JSON, strip host fields before storage,
// stamp a placement position, re-attach to a wall on drop, etc.
//
// What the editor uniquely knows is which schema fields on each kind
// are *host references* (e.g. `wallId` / `wallT` on a door hosted by a
// wall). That knowledge lives on `def.hostRefFields` — read it via
// `getHostRefFields(def)` and apply it at storage time. See
// `wiki/architecture/node-definitions.md` (host refs section).
/** A flat live-scene subtree rooted at `root`. */
export type Subtree = {
/** The root node, exactly as stored in `useScene.nodes[rootId]`. */
root: AnyNode
/** Every descendant reachable from `root` via the data-model `children` array, in BFS order. */
descendants: AnyNode[]
}
function extractIdPrefix(id: string): string {
const i = id.indexOf('_')
return i === -1 ? 'node' : id.slice(0, i)
}
function getChildIds(node: AnyNode): AnyNodeId[] {
if ('children' in node && Array.isArray((node as { children?: unknown }).children)) {
return (node as { children: AnyNodeId[] }).children
}
return []
}
/**
* Collect the subtree of nodes rooted at `rootId` from the live scene.
*
* - BFS walk via `node.children` arrays — order is stable and matches
* declaration order on container kinds.
* - Returns the live node references (not clones). Cheap; the caller
* chooses whether to deep-clone for persistence.
* - Returns `null` if `rootId` is missing.
*/
export function collectSubtree(
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
rootId: AnyNodeId,
): Subtree | null {
const root = nodes[rootId]
if (!root) return null
const descendants: AnyNode[] = []
const seen = new Set<AnyNodeId>([rootId])
const queue: AnyNodeId[] = [...getChildIds(root)]
let head = 0
while (head < queue.length) {
const id = queue[head++]!
if (seen.has(id)) continue
const node = nodes[id]
if (!node) continue
seen.add(id)
descendants.push(node)
for (const childId of getChildIds(node)) queue.push(childId)
}
return { root, descendants }
}
export type CloneNodesIntoOptions = {
/**
* The id of the root node within `nodes` (i.e. the node whose
* `parentId` becomes `parentId` in the destination instead of being
* remapped to a sibling's fresh id). Required because `nodes` is a
* flat array — there's no other way to mark which one is the root.
*/
rootId: AnyNodeId
/**
* Parent for the cloned root in the destination scene. When omitted,
* the root is inserted as a scene root (its `parentId` becomes the
* preserved value, often `null`).
*/
parentId?: AnyNodeId
/**
* Optional override for the cloned root's `position` (most placement
* flows stamp the cursor / target point here). When omitted, the
* root's own `position` field is preserved verbatim. Descendants
* always keep their original positions — those are local to the root.
*/
position?: readonly [number, number, number]
}
export type CloneNodesIntoResult = {
/** Fresh id assigned to the root in the destination scene. */
rootId: AnyNodeId
/** Every cloned node, root first, ready to feed into `createNodes`. */
nodes: AnyNode[]
/** Original id → fresh id map, mostly useful for tests and host-side bookkeeping. */
idMap: Map<AnyNodeId, AnyNodeId>
}
/**
* Clone a flat array of nodes with fresh IDs and rewired references,
* ready to insert via `useScene.createNodes`.
*
* Transformations applied:
* 1. Deep-clone each node via JSON round-trip (strips three.js refs,
* functions, circular links — same trick `cloneLevelSubtree` uses).
* 2. Mint a fresh id for every node, preserving the prefix
* (`wall_…`, `door_…`, etc.) so logs and lookups stay readable.
* 3. Rewrite `parentId`, `children[]` to use the fresh ids.
* 4. Stamp `position` onto the root if provided.
* 5. Set the root's `parentId` to `opts.parentId` when supplied.
*
* Intentionally generic — no awareness of host refs (`wallId`/`wallT`
* etc.). The caller is responsible for stripping or re-deriving those
* before / after calling this function. See `getHostRefFields(def)`.
*/
export function cloneNodesInto(
nodes: ReadonlyArray<AnyNode>,
opts: CloneNodesIntoOptions,
): CloneNodesIntoResult {
// Phase 1 — mint fresh ids for every node, preserving the prefix.
const idMap = new Map<AnyNodeId, AnyNodeId>()
for (const node of nodes) {
const prefix = extractIdPrefix(node.id)
idMap.set(node.id, generateId(prefix) as AnyNodeId)
}
const rootFreshId = idMap.get(opts.rootId)
if (!rootFreshId) {
throw new Error(`cloneNodesInto: rootId "${opts.rootId}" not found in supplied nodes array`)
}
// Phase 2 — clone each node + rewire references.
const out: AnyNode[] = []
let root: AnyNode | null = null
for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved
// value if not supplied). Descendants point at the remapped parent.
if (original.id === opts.rootId) {
;(cloned as { parentId: AnyNodeId | null }).parentId =
opts.parentId !== undefined
? opts.parentId
: ((cloned as { parentId?: AnyNodeId | null }).parentId ?? null)
} else if (cloned.parentId) {
const parentFresh = idMap.get(cloned.parentId as AnyNodeId)
;(cloned as { parentId: AnyNodeId | null }).parentId = parentFresh ?? null
}
// children[]: remap any internal references, drop external ones
// (a descendant pointing at a sibling that didn't make it into
// `nodes` would dangle — `filter` drops those gracefully).
if ('children' in cloned && Array.isArray((cloned as { children?: unknown }).children)) {
;(cloned as { children: AnyNodeId[] }).children = (
cloned as { children: AnyNodeId[] }
).children
.map((cid) => idMap.get(cid))
.filter((cid): cid is AnyNodeId => cid !== undefined)
}
if (original.id === opts.rootId) {
if (opts.position) {
;(cloned as { position: [number, number, number] }).position = [
opts.position[0],
opts.position[1],
opts.position[2],
]
}
root = cloned
} else {
out.push(cloned)
}
}
if (!root) {
throw new Error('cloneNodesInto: root node missing after clone')
}
return { rootId: rootFreshId, nodes: [root, ...out], idMap }
}
+57
View File
@@ -4,6 +4,7 @@ import type { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material' import type { MaterialSchema } from '../schema/material'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles' import type { HandleList } from './handles'
import type { CloneNodesIntoOptions, Subtree } from './subtree'
// ─── GeometryContext ───────────────────────────────────────────────── // ─── GeometryContext ─────────────────────────────────────────────────
// //
@@ -970,6 +971,43 @@ export type Capabilities = {
* declaring the same flag. * declaring the same flag.
*/ */
floorplanLevelContainer?: boolean floorplanLevelContainer?: boolean
/**
* Names of schema fields on this kind that are *host references* —
* values derived from where the node is placed (rather than declared
* by the user as part of the kind's parametric configuration). Read
* by host apps at preset-save time to strip these from the stored
* payload so a placed instance gets fresh host links at the new
* placement site (e.g. a door snapshot loses `wallId`/`wallT`; at
* placement the auto-attach UX re-derives them from the wall under
* the cursor).
*
* Kinds with no host refs omit this field (default `[]`).
*
* Examples:
* - door: `['wallId', 'wallT']` (door hosted on a wall)
* - window: `['wallId', 'wallT']`
* - item with `attachTo`: depends on the asset; the kind's
* `defaults()` or the dragging logic populates it dynamically.
*/
hostRefFields?: string[]
/**
* Whether instances of this kind can be saved as a reusable preset
* (unified `items` catalog, `kind='preset'`). The editor itself does
* not act on this flag — host apps read it to gate "save as preset"
* UI on the selected node. Default resolution (callers should use the
* `isPresettable(def)` helper rather than reading this directly):
*
* - explicit `true` → presettable
* - explicit `false` → not presettable
* - undefined → presettable when `def.parametrics` exists
*
* Structural / utility kinds (level, building, site, zone, spawn,
* guide, scan, item) opt out explicitly because saving them as a
* standalone preset has no meaning — items already have their own
* catalog, scans/guides carry user-uploaded imagery, and the rest
* are non-leaf scene containers.
*/
presettable?: boolean
} }
/** /**
@@ -1282,6 +1320,25 @@ export type SceneApi = {
markDirty: (id: AnyNodeId) => void markDirty: (id: AnyNodeId) => void
pauseHistory: () => void pauseHistory: () => void
resumeHistory: () => void resumeHistory: () => void
/**
* Collect the subtree of live nodes rooted at `rootId` — `root` plus
* every descendant reachable via `children[]` in BFS order. Returns
* live node references (no clones); the caller decides whether to
* persist by value or pass them straight into {@link cloneNodesInto}.
* Returns `null` if `rootId` is missing.
*/
getSubtree: (rootId: AnyNodeId) => Subtree | null
/**
* Clone a flat array of nodes into the live scene with fresh IDs and
* rewired parent / children references. Intentionally generic — see
* {@link cloneNodesInto} for the transformations applied. Does NOT
* strip or re-derive host references (e.g. `wallId` on a door); the
* caller is responsible for that policy (read {@link Capabilities.hostRefFields}
* on the relevant definition).
*
* Returns the new root id, or `null` if insertion failed.
*/
cloneNodesInto: (nodes: ReadonlyArray<AnyNode>, opts: CloneNodesIntoOptions) => AnyNodeId | null
} }
// ─── Registry surface ──────────────────────────────────────────────── // ─── Registry surface ────────────────────────────────────────────────
@@ -52,6 +52,8 @@ function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
resumeHistory: () => { resumeHistory: () => {
calls.resumeHistory += 1 calls.resumeHistory += 1
}, },
getSubtree: () => null,
cloneNodesInto: () => null,
_calls: calls, _calls: calls,
} }
} }
@@ -52,6 +52,8 @@ function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
markDirty: () => {}, markDirty: () => {},
pauseHistory: () => {}, pauseHistory: () => {},
resumeHistory: () => {}, resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
} }
} }
@@ -319,6 +319,76 @@ export const CustomCameraControls = () => {
) )
}, [isPreviewMode, previewTargetNodeId]) }, [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(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return if (!controls.current) return
@@ -7,7 +7,11 @@ import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus' import { triggerSFX } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' 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' type CaptureState = 'idle' | 'capturing' | 'saved'
interface DragPoint { interface DragPoint {
@@ -21,7 +25,7 @@ interface Drag {
} }
function getResolution( function getResolution(
mode: CaptureMode, mode: CropMode,
overlayEl: HTMLDivElement | null, overlayEl: HTMLDivElement | null,
drag: Drag | null, drag: Drag | null,
): { w: number; h: number } | null { ): { w: number; h: number } | null {
@@ -47,10 +51,15 @@ function getResolution(
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isCaptureMode = useEditor((s) => s.isCaptureMode) const isCaptureMode = useEditor((s) => s.isCaptureMode)
const captureMode = useEditor((s) => s.captureMode)
const setCaptureMode = useEditor((s) => s.setCaptureMode) const setCaptureMode = useEditor((s) => s.setCaptureMode)
const isMobile = useIsMobile() 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<CaptureMode>('standard') const [mode, setMode] = useState<CropMode>('standard')
const [drag, setDrag] = useState<Drag | null>(null) const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle') const [captureState, setCaptureState] = useState<CaptureState>('idle')
@@ -66,15 +75,29 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isCaptureMode, setCaptureMode]) }, [isCaptureMode, setCaptureMode])
// Reset local state when entering capture mode // 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(() => { useEffect(() => {
if (isCaptureMode) { if (!isCaptureMode) return
setMode('standard') 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) setDrag(null)
setIsDragging(false)
setCaptureState('idle')
} }
}, [isCaptureMode]) }, [isCaptureMode, isPreset])
// Listen for snapshot saved to show feedback then exit // Listen for snapshot saved to show feedback then exit
useEffect(() => { useEffect(() => {
@@ -142,11 +165,28 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy }, start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
end: { x: snapshot.end.x + dx, y: snapshot.end.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 { } else {
setDrag((d) => (d ? { start: d.start, end: pt } : null)) setDrag((d) => (d ? { start: d.start, end: pt } : null))
} }
}, },
[isDragging], [isDragging, isPreset],
) )
const onPointerUp = useCallback(() => { const onPointerUp = useCallback(() => {
@@ -225,8 +265,12 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
projectId, projectId,
captureMode: mode, captureMode: mode,
cropRegion, 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 if (!isCaptureMode) return null
@@ -250,31 +294,47 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
return ( return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}> <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* 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' && ( {mode === 'area' && (
<div <div
className="pointer-events-auto absolute inset-0 bg-black/30" className={
onPointerDown={onPointerDown} isPreset
onPointerMove={(e) => { ? 'pointer-events-none absolute inset-0'
onPointerMove(e) : 'pointer-events-auto absolute inset-0 bg-black/30'
// Update cursor: 'move' when hovering inside an existing selection }
if (!isDragging && drag && overlayRef.current) { onPointerDown={isPreset ? undefined : onPointerDown}
const rect = overlayRef.current.getBoundingClientRect() onPointerMove={
const px = e.clientX - rect.left isPreset
const py = e.clientY - rect.top ? undefined
const x0 = Math.min(drag.start.x, drag.end.x) : (e) => {
const y0 = Math.min(drag.start.y, drag.end.y) onPointerMove(e)
const x1 = Math.max(drag.start.x, drag.end.x) // Update cursor: 'move' when hovering inside an existing selection
const y1 = Math.max(drag.start.y, drag.end.y) if (!isDragging && drag && overlayRef.current) {
e.currentTarget.style.cursor = const rect = overlayRef.current.getBoundingClientRect()
px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair' const px = e.clientX - rect.left
} const py = e.clientY - rect.top
}} const x0 = Math.min(drag.start.x, drag.end.x)
onPointerUp={onPointerUp} const y0 = Math.min(drag.start.y, drag.end.y)
style={{ cursor: 'crosshair' }} 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 */} {/* "No selection" hint — only when the user has to draw the
{!selectionStyle && ( area themselves (`standard` capture). Preset mode always
has a pre-staged square, so we never show it there. */}
{!selectionStyle && !isPreset && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm"> <span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
Drag the area you want to capture Drag the area you want to capture
@@ -297,31 +357,34 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
background: 'rgba(255,255,255,0.04)', background: 'rgba(255,255,255,0.04)',
}} }}
> >
{/* Corner handles */} {/* Corner handles — preset mode locks the frame to the
{( auto-staged centered square; the user adjusts the
[ camera instead. */}
{ pos: { top: -5, left: -5 }, cursor: 'nwse-resize' }, {!isPreset &&
{ pos: { top: -5, right: -5 }, cursor: 'nesw-resize' }, (
{ pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' }, [
{ pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' }, { pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
] as const { pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
).map(({ pos, cursor }, i) => ( { pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
<div { pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
key={i} ] as const
onPointerDown={(e) => onCornerPointerDown(e, i)} ).map(({ pos, cursor }, i) => (
style={{ <div
position: 'absolute', key={i}
width: 10, onPointerDown={(e) => onCornerPointerDown(e, i)}
height: 10, style={{
borderRadius: '50%', position: 'absolute',
background: 'white', width: 10,
boxShadow: '0 1px 4px rgba(0,0,0,0.4)', height: 10,
pointerEvents: 'auto', borderRadius: '50%',
cursor, background: 'white',
...pos, boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
}} pointerEvents: 'auto',
/> cursor,
))} ...pos,
}}
/>
))}
</div> </div>
)} )}
</div> </div>
@@ -343,7 +406,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
{/* Bottom-center mode toolbar */} {/* Bottom-center mode toolbar */}
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2"> <div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
{(() => { {(() => {
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 : (
<> <>
<ModeButton <ModeButton
active={mode === 'standard'} active={mode === 'standard'}
@@ -408,8 +474,16 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md"> <div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
<div className="flex items-center justify-center gap-1">{modeButtons}</div> {modeButtons && (
<div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2"> <div className="flex items-center justify-center gap-1">{modeButtons}</div>
)}
<div
className={
modeButtons
? 'flex items-center justify-center gap-2 border-white/10 border-t pt-2'
: 'flex items-center justify-center gap-2'
}
>
{resolutionDisplay} {resolutionDisplay}
{captureButton} {captureButton}
</div> </div>
@@ -420,7 +494,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
return ( return (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md"> <div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
{modeButtons} {modeButtons}
<div className="mx-1 h-4 w-px bg-white/10" /> {modeButtons && <div className="mx-1 h-4 w-px bg-white/10" />}
{resolutionDisplay} {resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" /> <div className="mx-1 h-4 w-px bg-white/10" />
{captureButton} {captureButton}
@@ -461,6 +461,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
captureMode?: 'standard' | 'viewport' | 'area' captureMode?: 'standard' | 'viewport' | 'area'
cropRegion?: { x: number; y: number; width: number; height: number } cropRegion?: { x: number; y: number; width: number; height: number }
snapLevels?: boolean 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) await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
} }
@@ -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,
}
}
+27 -2
View File
@@ -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 type { EditorProps } from './components/editor'
export { default as Editor } 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 { export {
type SnapshotCameraData, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
@@ -89,8 +101,19 @@ export {
WALL_FINE_GRID_STEP, WALL_FINE_GRID_STEP,
type WallPlanPoint, type WallPlanPoint,
} from './components/tools/wall/wall-drafting' } from './components/tools/wall/wall-drafting'
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions' // `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles' // 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 { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button' export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { MaterialPicker } from './components/ui/controls/material-picker' 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 // ceiling height presets, etc.) use `parametrics.customPanel` to mount
// a kind-owned panel and need PanelWrapper for the chrome. // a kind-owned panel and need PanelWrapper for the chrome.
export { PanelWrapper } from './components/ui/panels/panel-wrapper' 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 // Presets popover — used by kind-owned door / window panels for their
// hardware / type / opening presets. // hardware / type / opening presets.
export { PresetsPopover } from './components/ui/panels/presets/presets-popover' 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' export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard' export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection'
export { EDITOR_LAYER } from './lib/constants' export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels. // Helper libs used by the kind-owned roof / stair / elevator panels.
export { export {
+29 -5
View File
@@ -48,6 +48,20 @@ const MAX_FLOORPLAN_PANE_RATIO = 0.85
export type ViewMode = '3d' | '2d' | 'split' export type ViewMode = '3d' | '2d' | 'split'
export type SplitOrientation = 'horizontal' | 'vertical' 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 Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
@@ -253,9 +267,16 @@ type EditorState = {
// Preview mode (viewer-like experience inside the editor) // Preview mode (viewer-like experience inside the editor)
isPreviewMode: boolean isPreviewMode: boolean
setPreviewMode: (preview: boolean) => void 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 isCaptureMode: boolean
setCaptureMode: (active: boolean) => void setCaptureMode: (next: boolean | CaptureMode) => void
// View mode (3D only, 2D only, or split 2D+3D) // View mode (3D only, 2D only, or split 2D+3D)
viewMode: ViewMode viewMode: ViewMode
setViewMode: (mode: ViewMode) => void setViewMode: (mode: ViewMode) => void
@@ -287,7 +308,6 @@ type EditorState = {
setFirstPersonMode: (enabled: boolean) => void setFirstPersonMode: (enabled: boolean) => void
activeSidebarPanel: string activeSidebarPanel: string
setActiveSidebarPanel: (id: string) => void setActiveSidebarPanel: (id: string) => void
setIsCaptureMode: (enabled: boolean) => void
floorplanPaneRatio: number floorplanPaneRatio: number
setFloorplanPaneRatio: (ratio: number) => void setFloorplanPaneRatio: (ratio: number) => void
// Mobile-only: pixel height of the secondary panel sheet while open (0 when closed). // Mobile-only: pixel height of the secondary panel sheet while open (0 when closed).
@@ -739,8 +759,13 @@ const useEditor = create<EditorState>()(
set({ isPreviewMode: false }) set({ isPreviewMode: false })
} }
}, },
captureMode: { mode: 'idle' } as CaptureMode,
isCaptureMode: false, 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, viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }), setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation, splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation,
@@ -795,7 +820,6 @@ const useEditor = create<EditorState>()(
}, },
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }), setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio, floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
setFloorplanPaneRatio: (ratio) => setFloorplanPaneRatio: (ratio) =>
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }), set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
@@ -28,6 +28,7 @@ export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
duplicable: false, duplicable: false,
deletable: false, deletable: false,
floorplanLevelContainer: true, floorplanLevelContainer: true,
presettable: false,
}, },
parametrics: buildingParametrics, parametrics: buildingParametrics,
+11 -7
View File
@@ -1,12 +1,12 @@
import { import type {
type AnyNodeId, AnyNodeId,
type DoorNode as DoorNodeType, DoorNode as DoorNodeType,
type HandleDescriptor, HandleDescriptor,
type NodeDefinition, NodeDefinition,
type WallNode, WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { doorWidthAffordance } from './floorplan-affordances'
import { buildDoorFloorplan } from './floorplan' import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
import { doorFloorplanMoveTarget } from './floorplan-move' import { doorFloorplanMoveTarget } from './floorplan-move'
import { doorParametrics } from './parametrics' import { doorParametrics } from './parametrics'
import { DoorNode } from './schema' import { DoorNode } from './schema'
@@ -143,6 +143,10 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
duplicable: true, duplicable: true,
deletable: true, deletable: true,
wallOpeningPlacement: 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, parametrics: doorParametrics,
+3
View File
@@ -24,6 +24,9 @@ export const guideDefinition: NodeDefinition<typeof GuideNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: false, duplicable: false,
deletable: true, deletable: true,
// Guides are scene-specific measurement annotations — saving them
// as reusable catalog items has no meaning.
presettable: false,
}, },
parametrics: guideParametrics, parametrics: guideParametrics,
+13
View File
@@ -72,6 +72,19 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
// 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 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 // Floor items get lifted by slabs underneath via the generic
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in // `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
// their parent's local frame and skip the lift via `applies`. // their parent's local frame and skip the lift via `applies`.
+3
View File
@@ -30,6 +30,9 @@ export const levelDefinition: NodeDefinition<typeof LevelNode> = {
// mirror that. // mirror that.
duplicable: false, duplicable: false,
deletable: true, 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, parametrics: levelParametrics,
+3
View File
@@ -23,6 +23,9 @@ export const scanDefinition: NodeDefinition<typeof ScanNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: false, duplicable: false,
deletable: true, deletable: true,
// Scans carry user-uploaded imagery — cataloging them as
// reusable presets is out of scope.
presettable: false,
}, },
parametrics: scanParametrics, parametrics: scanParametrics,
+1
View File
@@ -26,6 +26,7 @@ export const siteDefinition: NodeDefinition<typeof SiteNode> = {
// override their selection). Same reasoning as `level`. // override their selection). Same reasoning as `level`.
duplicable: false, duplicable: false,
deletable: false, deletable: false,
presettable: false,
}, },
parametrics: siteParametrics, parametrics: siteParametrics,
+2
View File
@@ -27,6 +27,8 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
duplicable: false, // singleton per level duplicable: false, // singleton per level
deletable: true, deletable: true,
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
// Spawn is a singleton anchor — no meaning as a reusable preset.
presettable: false,
// Slab elevation lift via the generic `<FloorElevationSystem>`. The // Slab elevation lift via the generic `<FloorElevationSystem>`. The
// spawn marker is a 1.8m-tall figure with a ~0.6m ring footprint. // spawn marker is a 1.8m-tall figure with a ~0.6m ring footprint.
floorPlaced: { floorPlaced: {
+10 -8
View File
@@ -1,9 +1,9 @@
import { import type {
type AnyNodeId, AnyNodeId,
type HandleDescriptor, HandleDescriptor,
type NodeDefinition, NodeDefinition,
type WallNode, WallNode,
type WindowNode as WindowNodeType, WindowNode as WindowNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildWindowFloorplan } from './floorplan' import { buildWindowFloorplan } from './floorplan'
import { windowWidthAffordance } from './floorplan-affordances' import { windowWidthAffordance } from './floorplan-affordances'
@@ -77,8 +77,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps // bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
// at top (positive Y room above the floor). // at top (positive Y room above the floor).
const wallH = readWallHeight(n, scene) const wallH = readWallHeight(n, scene)
const anchored = const anchored = edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
return edge === 'top' return edge === 'top'
? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored) ? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored)
: Math.max(MIN_WINDOW_HEIGHT, anchored) : Math.max(MIN_WINDOW_HEIGHT, anchored)
@@ -140,6 +139,9 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
duplicable: true, duplicable: true,
deletable: true, deletable: true,
wallOpeningPlacement: 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, parametrics: windowParametrics,
+3
View File
@@ -30,6 +30,9 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
selectable: { hitVolume: 'bbox' }, selectable: { hitVolume: 'bbox' },
duplicable: true, duplicable: true,
deletable: 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, parametrics: zoneParametrics,
+59 -12
View File
@@ -1,10 +1,11 @@
'use client' '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 { 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 * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { applyIsolation, clearIsolation } from '../../lib/isolation'
import type { ColorPreset, RenderShading } from '../../lib/materials' import type { ColorPreset, RenderShading } from '../../lib/materials'
import { getSceneTheme } from '../../lib/scene-themes' import { getSceneTheme } from '../../lib/scene-themes'
import useViewer, { type RenderContext } from '../../store/use-viewer' import useViewer, { type RenderContext } from '../../store/use-viewer'
@@ -130,17 +131,63 @@ interface ViewerProps {
textures?: boolean textures?: boolean
colorPreset?: ColorPreset 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<ViewerProps> = ({ /** Imperative handle exposed via `ref` on `<Viewer>`. */
children, export type ViewerHandle = {
hoverStyles = DEFAULT_HOVER_STYLES, /**
selectionManager = 'default', * Apply / clear the same visibility filter as the `isolate` prop. Useful
perf = false, * for transient cases (a temporary hover-to-isolate UX) where holding
useBvh = true, * the value in React state would be over-engineering. Passing `null`
renderContext = 'editor', * clears.
defaultRender, */
}) => { setIsolated(ids: AnyNodeId[] | null): void
}
const Viewer = forwardRef<ViewerHandle, ViewerProps>(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<AnyNodeId[] | null | undefined>(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') const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
useEffect(() => { useEffect(() => {
const ctx = renderContext const ctx = renderContext
@@ -261,7 +308,7 @@ const Viewer: React.FC<ViewerProps> = ({
</ErrorBoundary> </ErrorBoundary>
</Canvas> </Canvas>
) )
} })
const DebugRenderer = () => { const DebugRenderer = () => {
useFrame(({ gl, scene, camera }) => { useFrame(({ gl, scene, camera }) => {
+2 -1
View File
@@ -12,7 +12,7 @@ export { ErrorBoundary } from './components/error-boundary'
// `@pascal-app/nodes/<kind>/renderer.tsx` and are loaded by the registry // `@pascal-app/nodes/<kind>/renderer.tsx` and are loaded by the registry
// — no per-kind re-exports needed. // — no per-kind re-exports needed.
export { NodeRenderer } from './components/renderers/node-renderer' 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 type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export { export {
DEFAULT_HOVER_STYLES, DEFAULT_HOVER_STYLES,
@@ -37,6 +37,7 @@ export {
SUBTRACTION, SUBTRACTION,
} from './lib/csg-utils' } from './lib/csg-utils'
export type { EdgeMode } from './lib/edge-style' export type { EdgeMode } from './lib/edge-style'
export { applyIsolation, clearIsolation, collectIsolationSubtree } from './lib/isolation'
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export { export {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
+101
View File
@@ -0,0 +1,101 @@
'use client'
import type { AnyNodeId } from '@pascal-app/core'
import { sceneRegistry } from '@pascal-app/core'
import type { Object3D } from 'three'
import { SCENE_LAYER } from './layers'
// 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_LAYERS = Symbol('isolation:original-layers')
type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number }
/**
* Compute the union of every isolated subtree's `Object3D` descendants.
*
* 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 collectIsolationSubtree(ids: ReadonlyArray<string>): Set<Object3D> {
const keep = new Set<Object3D>()
for (const id of ids) {
const root = sceneRegistry.nodes.get(id)
if (!root) continue
root.traverse((child) => {
keep.add(child)
})
}
return keep
}
/**
* 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.
*
* 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}).
*/
export function applyIsolation(ids: ReadonlyArray<AnyNodeId> | null): void {
if (ids == null || ids.length === 0) {
clearIsolation()
return
}
const keep = collectIsolationSubtree(ids as ReadonlyArray<string>)
// 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<Object3D>): 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) {
obj.traverse((child) => {
const carrier = child as IsolationCarrier
if (carrier[ORIGINAL_LAYERS] !== undefined) {
child.layers.mask = carrier[ORIGINAL_LAYERS]
delete carrier[ORIGINAL_LAYERS]
}
})
}
}