Skip to content

Preset system: editor primitives (headless exports + unified Viewer + scene APIs + def.presettable) #340

@wass08

Description

@wass08

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

  • Inspector, FloatingMenu, Toolbar, useSelection, useScene, useViewer exported from @pascal-app/editor
  • Viewer, standard camera/controls/lighting helpers exported from @pascal-app/viewer
  • <Viewer isolate={ids}> hides every registered node group not in ids (descendants included via their root's group)
  • viewerRef.setIsolated(ids | null) exposes the same filter imperatively
  • useEditor.captureMode is a discriminated union { mode: 'idle' | 'standard' | 'preset', ... }; setCaptureMode({ mode: 'preset', isolated }) enables a square + transparent + isolated capture path in ThumbnailGenerator
  • Calling capture in preset mode emits a PNG blob through the existing onThumbnailCapture callback with the locked aspect and alpha
  • sceneApi.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.presettable exists on NodeDefinition; structural / utility / item kinds explicitly opt out
  • bun typecheck passes; existing tests pass; apps/editor continues to render
  • Architecture review per wiki/architecture/layers.md — no layer violations introduced

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestready-for-agentReady for an autonomous agent to pick up

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions