What to build
Editor-side prerequisites for the unified preset system being built in pascalorg/private-editor (see pascalorg/private-editor:plans/community-preset-system.md, "Editor-side requirements" section). Five conceptually distinct pieces that ride into one PR (or split if more comfortable):
1. Headless component + hook exports. Atomic, self-wired components consumers compose into their own shell — no slot registry, no <Slot> primitive, no opinion on layout. Both the standalone apps/editor and the private-editor community shell consume these.
Surface to expose:
@pascal-app/editor: Inspector, FloatingMenu, Toolbar, and whichever other editor panels are currently bundled into a shell wrapper.
@pascal-app/editor: hooks useSelection, useScene, useViewer for consumers building their own components on top.
@pascal-app/viewer: Viewer, plus the standard cameras/controls/lighting helpers needed for a complete render.
Respect the layer rules in wiki/architecture/layers.md (UI in packages/editor, canvas in packages/viewer, schemas in packages/core).
2. <Viewer isolate> + ViewerHandle.setIsolated. A visibility filter on the live canvas. There is only ever one mounted <Viewer> in any consumer today, so capture happens against it — no second canvas, no scene-store factory, no React context for the scene store. Isolation walks sceneRegistry and toggles Object3D.visible on every registered node group not in the set (descendants follow their root because they live inside the root's group).
<Viewer
isolate?={string[] | null} // visibility filter on registered node groups
ref?={ViewerHandle}
gl?={{ alpha: boolean }} // pass-through to R3F Canvas gl config
>
{children} // cameras, controls, interactions — consumer-composed
</Viewer>
type ViewerHandle = {
/** Hide every registered node group whose id is not in `ids`. Pass `null` to clear. */
setIsolated(ids: string[] | null): void
}
No scene prop. No size prop. No camera / interactive props. The same isolate / setIsolated API also serves a future in-editor "isolate selection / focus mode" UX feature — out of scope here, but the primitives are designed to support it.
3. setCaptureMode enum (+ preset capture mode). Today useEditor.isCaptureMode is a boolean toggling the existing SnapshotCaptureOverlay + ThumbnailGenerator pipeline. Promote it to an enum so callers describe why they're capturing and the editor enforces the right policy — without surfacing those choices to the user.
type CaptureMode =
| { mode: 'idle' }
| { mode: 'standard' } // existing user-driven snapshot (today's behavior)
| { mode: 'preset'; isolated: string[] } // square + transparent + locked to the isolated set
useEditor.setCaptureMode(next: CaptureMode): void
useEditor.captureMode: CaptureMode // replaces isCaptureMode
standard keeps today's behavior: region / viewport / area picker, blob reflects whatever the camera frames.
preset is new: the overlay locks the crop to a square (user pans / zooms the canvas behind it), the render target clears to alpha 0, and the rendered set is locked to isolated so background never leaks in if the user pans. ThumbnailGenerator checks captureMode.mode === 'preset' and applies these constraints before rendering.
Compat shim: keep an isCaptureMode getter returning captureMode.mode !== 'idle' so existing read sites (level selector / floating menus / etc. that just hide chrome) keep working without per-site changes; migrate write sites (setCaptureMode(true) / setCaptureMode(false)) to the new shape.
4. Scene API public exports. Likely already exist privately for duplicate/paste — make them public:
sceneApi.getSubtreeSnapshot(rootId: AnyNodeId): NodeSubtree
sceneApi.materializeSubtree(subtree: NodeSubtree, position: Vec3): AnyNodeId
NodeSubtree stripping rules: strip IDs (replaced with fresh ones at materialize time), strip absolute world position of the root (preserve relative positions within), strip host references like wallId / wallT (re-derived at materialize time via auto-attach UX). Preserve parametric fields and children[] verbatim.
5. def.presettable capability. Single optional field on NodeDefinition:
capabilities: {
presettable?: boolean // default: true if def.parametrics exists, else false
}
Explicit false on level, building, site, zone, spawn, guide, scan, item (the GLB kind). Implicit true on shelf, column, door, window, fence, stair, roof, wall, slab, ceiling, and other parametric kinds. No runtime behavior change in the editor; community-app reads it to gate the save-as-preset UI.
Acceptance criteria
Out of scope (explicit)
- A second / offscreen
<Viewer> rendering an arbitrary NodeSubtree. The previous draft of this issue specified a scene prop + captureFrame for that purpose; the design was revised after analysis: there's no real use case for two simultaneous viewers, and the offscreen variant would require a full useScene → factory + context refactor across packages/core + every kind-system (~200+ files). The new design captures inside the live canvas via isolation + the existing snapshot pipeline.
- Refactoring
useScene into a factory / context. Stays a singleton. If multi-viewer ever becomes a real requirement, that refactor can land then — none of the APIs above are invalidated by it.
- An in-editor user-facing "isolate selection / focus mode" toggle. The primitives support it, but the UX surface is a follow-up.
Blocked by
None - can start immediately.
What to build
Editor-side prerequisites for the unified preset system being built in
pascalorg/private-editor(seepascalorg/private-editor:plans/community-preset-system.md, "Editor-side requirements" section). Five conceptually distinct pieces that ride into one PR (or split if more comfortable):1. Headless component + hook exports. Atomic, self-wired components consumers compose into their own shell — no slot registry, no
<Slot>primitive, no opinion on layout. Both the standaloneapps/editorand the private-editor community shell consume these.Surface to expose:
@pascal-app/editor:Inspector,FloatingMenu,Toolbar, and whichever other editor panels are currently bundled into a shell wrapper.@pascal-app/editor: hooksuseSelection,useScene,useViewerfor consumers building their own components on top.@pascal-app/viewer:Viewer, plus the standard cameras/controls/lighting helpers needed for a complete render.Respect the layer rules in
wiki/architecture/layers.md(UI inpackages/editor, canvas inpackages/viewer, schemas inpackages/core).2.
<Viewer isolate>+ViewerHandle.setIsolated. A visibility filter on the live canvas. There is only ever one mounted<Viewer>in any consumer today, so capture happens against it — no second canvas, no scene-store factory, no React context for the scene store. Isolation walkssceneRegistryand togglesObject3D.visibleon every registered node group not in the set (descendants follow their root because they live inside the root's group).No
sceneprop. Nosizeprop. Nocamera/interactiveprops. The sameisolate/setIsolatedAPI also serves a future in-editor "isolate selection / focus mode" UX feature — out of scope here, but the primitives are designed to support it.3.
setCaptureModeenum (+presetcapture mode). TodayuseEditor.isCaptureModeis a boolean toggling the existingSnapshotCaptureOverlay+ThumbnailGeneratorpipeline. Promote it to an enum so callers describe why they're capturing and the editor enforces the right policy — without surfacing those choices to the user.standardkeeps today's behavior: region / viewport / area picker, blob reflects whatever the camera frames.presetis new: the overlay locks the crop to a square (user pans / zooms the canvas behind it), the render target clears to alpha 0, and the rendered set is locked toisolatedso background never leaks in if the user pans.ThumbnailGeneratorcheckscaptureMode.mode === 'preset'and applies these constraints before rendering.Compat shim: keep an
isCaptureModegetter returningcaptureMode.mode !== 'idle'so existing read sites (level selector / floating menus / etc. that just hide chrome) keep working without per-site changes; migrate write sites (setCaptureMode(true)/setCaptureMode(false)) to the new shape.4. Scene API public exports. Likely already exist privately for duplicate/paste — make them public:
NodeSubtreestripping rules: strip IDs (replaced with fresh ones at materialize time), strip absolute world position of the root (preserve relative positions within), strip host references likewallId/wallT(re-derived at materialize time via auto-attach UX). Preserve parametric fields andchildren[]verbatim.5.
def.presettablecapability. Single optional field onNodeDefinition:Explicit
falseonlevel,building,site,zone,spawn,guide,scan,item(the GLB kind). Implicittrueon shelf, column, door, window, fence, stair, roof, wall, slab, ceiling, and other parametric kinds. No runtime behavior change in the editor; community-app reads it to gate the save-as-preset UI.Acceptance criteria
Inspector,FloatingMenu,Toolbar,useSelection,useScene,useViewerexported from@pascal-app/editorViewer, standard camera/controls/lighting helpers exported from@pascal-app/viewer<Viewer isolate={ids}>hides every registered node group not inids(descendants included via their root's group)viewerRef.setIsolated(ids | null)exposes the same filter imperativelyuseEditor.captureModeis a discriminated union{ mode: 'idle' | 'standard' | 'preset', ... };setCaptureMode({ mode: 'preset', isolated })enables a square + transparent + isolated capture path inThumbnailGeneratorpresetmode emits a PNG blob through the existingonThumbnailCapturecallback with the locked aspect and alphasceneApi.getSubtreeSnapshot(rootId)+sceneApi.materializeSubtree(subtree, pos)round-trips correctly (a snapshot then materialized at a new position yields an equivalent subtree at that position with fresh IDs)capabilities.presettableexists onNodeDefinition; structural / utility / item kinds explicitly opt outbun typecheckpasses; existing tests pass;apps/editorcontinues to renderwiki/architecture/layers.md— no layer violations introducedOut of scope (explicit)
<Viewer>rendering an arbitraryNodeSubtree. The previous draft of this issue specified asceneprop +captureFramefor that purpose; the design was revised after analysis: there's no real use case for two simultaneous viewers, and the offscreen variant would require a fulluseScene→ factory + context refactor acrosspackages/core+ every kind-system (~200+ files). The new design captures inside the live canvas via isolation + the existing snapshot pipeline.useSceneinto a factory / context. Stays a singleton. If multi-viewer ever becomes a real requirement, that refactor can land then — none of the APIs above are invalidated by it.Blocked by
None - can start immediately.