* 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
@@ -28,6 +28,7 @@ export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
duplicable: false,
|
||||
deletable: false,
|
||||
floorplanLevelContainer: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
parametrics: buildingParametrics,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode as DoorNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type WallNode,
|
||||
import type {
|
||||
AnyNodeId,
|
||||
DoorNode as DoorNodeType,
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { doorWidthAffordance } from './floorplan-affordances'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
import { doorWidthAffordance } from './floorplan-affordances'
|
||||
import { doorFloorplanMoveTarget } from './floorplan-move'
|
||||
import { doorParametrics } from './parametrics'
|
||||
import { DoorNode } from './schema'
|
||||
@@ -143,6 +143,10 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
duplicable: true,
|
||||
deletable: 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,
|
||||
|
||||
@@ -24,6 +24,9 @@ export const guideDefinition: NodeDefinition<typeof GuideNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: false,
|
||||
deletable: true,
|
||||
// Guides are scene-specific measurement annotations — saving them
|
||||
// as reusable catalog items has no meaning.
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
parametrics: guideParametrics,
|
||||
|
||||
@@ -72,6 +72,19 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: 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
|
||||
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
|
||||
// their parent's local frame and skip the lift via `applies`.
|
||||
|
||||
@@ -30,6 +30,9 @@ export const levelDefinition: NodeDefinition<typeof LevelNode> = {
|
||||
// mirror that.
|
||||
duplicable: false,
|
||||
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,
|
||||
|
||||
@@ -23,6 +23,9 @@ export const scanDefinition: NodeDefinition<typeof ScanNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: false,
|
||||
deletable: true,
|
||||
// Scans carry user-uploaded imagery — cataloging them as
|
||||
// reusable presets is out of scope.
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
parametrics: scanParametrics,
|
||||
|
||||
@@ -26,6 +26,7 @@ export const siteDefinition: NodeDefinition<typeof SiteNode> = {
|
||||
// override their selection). Same reasoning as `level`.
|
||||
duplicable: false,
|
||||
deletable: false,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
parametrics: siteParametrics,
|
||||
|
||||
@@ -27,6 +27,8 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
duplicable: false, // singleton per level
|
||||
deletable: true,
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
// Spawn is a singleton anchor — no meaning as a reusable preset.
|
||||
presettable: false,
|
||||
// Slab elevation lift via the generic `<FloorElevationSystem>`. The
|
||||
// spawn marker is a 1.8m-tall figure with a ~0.6m ring footprint.
|
||||
floorPlaced: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type WallNode,
|
||||
type WindowNode as WindowNodeType,
|
||||
import type {
|
||||
AnyNodeId,
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
WallNode,
|
||||
WindowNode as WindowNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { buildWindowFloorplan } from './floorplan'
|
||||
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
|
||||
// at top (positive Y room above the floor).
|
||||
const wallH = readWallHeight(n, scene)
|
||||
const anchored =
|
||||
edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
|
||||
const anchored = edge === 'top' ? n.position[1] - n.height / 2 : n.position[1] + n.height / 2
|
||||
return edge === 'top'
|
||||
? Math.max(MIN_WINDOW_HEIGHT, wallH - anchored)
|
||||
: Math.max(MIN_WINDOW_HEIGHT, anchored)
|
||||
@@ -140,6 +139,9 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
duplicable: true,
|
||||
deletable: 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,
|
||||
|
||||
@@ -30,6 +30,9 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: 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,
|
||||
|
||||
Reference in New Issue
Block a user