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
+59 -12
View File
@@ -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 }) => {