* 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>
210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
import type {
|
|
AnyNodeId,
|
|
DoorNode as DoorNodeType,
|
|
HandleDescriptor,
|
|
NodeDefinition,
|
|
WallNode,
|
|
} from '@pascal-app/core'
|
|
import { buildDoorFloorplan } from './floorplan'
|
|
import { doorWidthAffordance } from './floorplan-affordances'
|
|
import { doorFloorplanMoveTarget } from './floorplan-move'
|
|
import { doorParametrics } from './parametrics'
|
|
import { DoorNode } from './schema'
|
|
|
|
const SIDE_HANDLE_OFFSET = 0.24
|
|
const HEIGHT_HANDLE_OFFSET = 0.24
|
|
const MIN_DOOR_HEIGHT = 0.5
|
|
const MIN_DOOR_WIDTH = 0.3
|
|
|
|
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
|
if (!door.wallId) return Number.POSITIVE_INFINITY
|
|
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
|
|
if (!wall) return Number.POSITIVE_INFINITY
|
|
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
|
}
|
|
|
|
function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
|
if (!door.wallId) return Number.POSITIVE_INFINITY
|
|
const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined
|
|
return wall?.height ?? Number.POSITIVE_INFINITY
|
|
}
|
|
|
|
// Width arrow on the door-local +X (right) or -X (left) side. Drag grows
|
|
// the door from the anchored OPPOSITE edge; the door's wall-local center
|
|
// re-centers so the anchored edge stays put.
|
|
function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType> {
|
|
const sign = side === 'right' ? 1 : -1
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'x',
|
|
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
|
|
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
|
|
anchor: side === 'right' ? 'min' : 'max',
|
|
min: MIN_DOOR_WIDTH,
|
|
max: (n, scene) => readWallLength(n, scene),
|
|
currentValue: (n) => n.width,
|
|
apply: (initial, newWidth) => {
|
|
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
|
// applied by the inner ride group (the renderer mounts a nested
|
|
// <group> at the door's pose), so the apply math here is in
|
|
// door-local coords AND the patch.position must be in wall-local
|
|
// coords. We compute the anchored wall-local point from the
|
|
// initial node, then derive the new wall-local center from it.
|
|
const rotY = initial.rotation[1]
|
|
const armX = Math.cos(rotY)
|
|
const armZ = -Math.sin(rotY)
|
|
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
|
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
|
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
|
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
|
return {
|
|
width: newWidth,
|
|
position: [newCenterX, initial.position[1], newCenterZ],
|
|
}
|
|
},
|
|
placement: {
|
|
// door-local: +X axis lives along door's own X. Inner ride group
|
|
// applies door.rotation, so we sit purely on door-local +X / -X.
|
|
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), 0, 0],
|
|
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
|
},
|
|
portal: 'grandparent',
|
|
}
|
|
}
|
|
|
|
function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'y',
|
|
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
|
|
min: MIN_DOOR_HEIGHT,
|
|
max: (n, scene) => {
|
|
const bottom = n.position[1] - n.height / 2
|
|
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
|
},
|
|
currentValue: (n) => n.height,
|
|
apply: (initial, newHeight) => {
|
|
const bottom = initial.position[1] - initial.height / 2
|
|
return {
|
|
height: newHeight,
|
|
position: [initial.position[0], bottom + newHeight / 2, initial.position[2]],
|
|
}
|
|
},
|
|
placement: {
|
|
position: (n) => [0, n.height / 2 + HEIGHT_HANDLE_OFFSET, 0],
|
|
},
|
|
portal: 'grandparent',
|
|
}
|
|
}
|
|
|
|
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
|
doorWidthHandle('left'),
|
|
doorWidthHandle('right'),
|
|
doorHeightHandle(),
|
|
]
|
|
|
|
/**
|
|
* Door — Phase 5 batch kind. Hosted on walls, cuts holes in them,
|
|
* animated open/close state.
|
|
*
|
|
* Capabilities:
|
|
* - **No `movable`**: door's move is bespoke wall-bound drag (slide
|
|
* along the wall, snap to wall start/end). Capability-driven dispatch
|
|
* keeps legacy `MoveDoorTool`.
|
|
* - `selectable`, `duplicable`, `deletable` standard.
|
|
*
|
|
* Stages:
|
|
* - A: registered.
|
|
* - B: deferred — door geometry (frame / leaf / glass / hardware /
|
|
* segments) is ~800 lines in DoorSystem; extraction is a focused
|
|
* session. `def.renderer` (wrap-export of legacy DoorRenderer) +
|
|
* `def.system` (DoorSystem + DoorAnimationSystem bundle) hold parity.
|
|
* - C: `def.floorplan` polygon sits in parent wall's cutout. Legacy
|
|
* `openingPolygons` short-circuits door entries when registered.
|
|
*/
|
|
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|
kind: 'door',
|
|
schemaVersion: 1,
|
|
schema: DoorNode,
|
|
category: 'structure',
|
|
surfaceRole: 'joinery',
|
|
|
|
// Leverage the schema's zod `.default()` annotations to compute the
|
|
// full default shape — door has 40+ fields, listing them inline would
|
|
// duplicate the schema. Parse a minimal stub, drop id/type, return rest.
|
|
defaults: () => {
|
|
const stub = DoorNode.parse({ id: 'door_default' as never, type: 'door' })
|
|
const { id: _id, type: _type, ...rest } = stub
|
|
return rest
|
|
},
|
|
|
|
capabilities: {
|
|
selectable: { hitVolume: 'bbox' },
|
|
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,
|
|
handles: doorHandles,
|
|
|
|
renderer: {
|
|
kind: 'parametric',
|
|
module: () => import('./renderer'),
|
|
},
|
|
system: {
|
|
module: () => import('./system'),
|
|
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
|
|
// before wall mitering at 4).
|
|
priority: 3,
|
|
},
|
|
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
|
// direction + perpendicular for the cutout footprint.
|
|
floorplan: buildDoorFloorplan,
|
|
// Stage D — placement (`def.tool`) + move-on-wall (`def.
|
|
// affordanceTools.move`). Both ports of the legacy tools at
|
|
// `editor/components/tools/door/`, relocated into the kind folder and
|
|
// wired through ToolManager's registry-first dispatch (`def.tool` for
|
|
// build-mode placement, `getRegistryAffordanceTool` for the move-on-
|
|
// pick flow). Same legacy semantics: wall-event-driven snap, clamped
|
|
// wall-local coords, hasWallChildOverlap guard, live mesh updates.
|
|
tool: () => import('./tool'),
|
|
affordanceTools: {
|
|
move: () => import('./move-tool'),
|
|
},
|
|
// 2D move-on-floorplan handler. When `useEditor.movingNode` is a
|
|
// door and the floor plan is active, `FloorplanRegistryMoveOverlay`
|
|
// dispatches to this instead of the generic translate path — pointer
|
|
// snaps to the nearest wall, projects onto the wall axis, snaps
|
|
// local-X to 0.5m, clamps inside wall bounds.
|
|
floorplanMoveTarget: doorFloorplanMoveTarget,
|
|
|
|
// 2D drag affordances. `resize-width` drives the door's two side
|
|
// arrows — pointer-down on either arrow starts an anchored width drag
|
|
// (opposite edge stays fixed, clamped to wall bounds).
|
|
floorplanAffordances: {
|
|
'resize-width': doorWidthAffordance,
|
|
},
|
|
|
|
toolHints: [
|
|
{ key: 'Left click', label: 'Place door on wall' },
|
|
{ key: 'Esc', label: 'Cancel' },
|
|
],
|
|
|
|
presentation: {
|
|
label: 'Door',
|
|
description: 'A door cut into a wall. Animated open/close state.',
|
|
icon: { kind: 'url', src: '/icons/door.png' },
|
|
paletteSection: 'structure',
|
|
paletteOrder: 50,
|
|
},
|
|
|
|
mcp: {
|
|
description: 'A door mounted on a wall, with type / dimensions / hardware options.',
|
|
},
|
|
}
|