* 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:
co-authored by
Claude Opus 4.7
parent
350cad9c89
commit
1fd59dd9cd
@@ -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<ViewerProps> = ({
|
||||
children,
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
selectionManager = 'default',
|
||||
perf = false,
|
||||
useBvh = true,
|
||||
renderContext = 'editor',
|
||||
defaultRender,
|
||||
}) => {
|
||||
/** Imperative handle exposed via `ref` on `<Viewer>`. */
|
||||
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<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')
|
||||
useEffect(() => {
|
||||
const ctx = renderContext
|
||||
@@ -261,7 +308,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
</ErrorBoundary>
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const DebugRenderer = () => {
|
||||
useFrame(({ gl, scene, camera }) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export { ErrorBoundary } from './components/error-boundary'
|
||||
// `@pascal-app/nodes/<kind>/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,7 @@ export {
|
||||
SUBTRACTION,
|
||||
} from './lib/csg-utils'
|
||||
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 {
|
||||
applyMaterialPresetToMaterials,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user