* 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>
252 lines
7.3 KiB
TypeScript
252 lines
7.3 KiB
TypeScript
import type { ThreeEvent } from '@react-three/fiber'
|
|
import mitt from 'mitt'
|
|
import type { Object3D } from 'three'
|
|
import type {
|
|
BoxVentNode,
|
|
BuildingNode,
|
|
CeilingNode,
|
|
ChimneyNode,
|
|
ColumnNode,
|
|
DoorNode,
|
|
DormerNode,
|
|
ElevatorNode,
|
|
FenceNode,
|
|
GuideNode,
|
|
ItemNode,
|
|
LevelNode,
|
|
RidgeVentNode,
|
|
RoofNode,
|
|
RoofSegmentNode,
|
|
ScanNode,
|
|
ShelfNode,
|
|
SiteNode,
|
|
SkylightNode,
|
|
SlabNode,
|
|
SolarPanelNode,
|
|
SpawnNode,
|
|
StairNode,
|
|
StairSegmentNode,
|
|
WallNode,
|
|
WindowNode,
|
|
ZoneNode,
|
|
} from '../schema'
|
|
import type { AnyNode } from '../schema/types'
|
|
|
|
// Base event interfaces
|
|
export interface GridEvent {
|
|
/** World-space intersection point on the grid plane. */
|
|
position: [number, number, number]
|
|
/**
|
|
* Building-local intersection point — relative to the currently selected building.
|
|
* Equals `position` when no building is selected.
|
|
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
|
|
*/
|
|
localPosition: [number, number, number]
|
|
faceIndex?: number
|
|
/**
|
|
* Optional: the hit Three.js object. Present when the grid event was
|
|
* synthesized from a R3F mesh hit (the legacy grid-plane mesh path);
|
|
* absent when emitted by the canvas-level raycaster in
|
|
* `use-grid-events.ts`, where there is no specific mesh to attribute
|
|
* the intersection to.
|
|
*/
|
|
object?: Object3D
|
|
nativeEvent: ThreeEvent<PointerEvent>
|
|
}
|
|
|
|
export interface NodeEvent<T extends AnyNode = AnyNode> {
|
|
node: T
|
|
position: [number, number, number]
|
|
localPosition: [number, number, number]
|
|
normal?: [number, number, number]
|
|
faceIndex?: number
|
|
object: Object3D
|
|
stopPropagation: () => void
|
|
nativeEvent: ThreeEvent<PointerEvent>
|
|
}
|
|
|
|
export type WallEvent = NodeEvent<WallNode>
|
|
export type FenceEvent = NodeEvent<FenceNode>
|
|
export type ItemEvent = NodeEvent<ItemNode>
|
|
export type SiteEvent = NodeEvent<SiteNode>
|
|
export type BuildingEvent = NodeEvent<BuildingNode>
|
|
export type LevelEvent = NodeEvent<LevelNode>
|
|
export type ZoneEvent = NodeEvent<ZoneNode>
|
|
export type ShelfEvent = NodeEvent<ShelfNode>
|
|
export type SlabEvent = NodeEvent<SlabNode>
|
|
export type SpawnEvent = NodeEvent<SpawnNode>
|
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
|
export type ColumnEvent = NodeEvent<ColumnNode>
|
|
export type RoofEvent = NodeEvent<RoofNode>
|
|
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
|
export type StairEvent = NodeEvent<StairNode>
|
|
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
|
export type WindowEvent = NodeEvent<WindowNode>
|
|
export type DoorEvent = NodeEvent<DoorNode>
|
|
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
|
export type ScanEvent = NodeEvent<ScanNode>
|
|
export type GuideEvent = NodeEvent<GuideNode>
|
|
export type BoxVentEvent = NodeEvent<BoxVentNode>
|
|
export type RidgeVentEvent = NodeEvent<RidgeVentNode>
|
|
export type ChimneyEvent = NodeEvent<ChimneyNode>
|
|
export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
|
export type SkylightEvent = NodeEvent<SkylightNode>
|
|
export type DormerEvent = NodeEvent<DormerNode>
|
|
|
|
// Event suffixes - exported for use in hooks
|
|
export const eventSuffixes = [
|
|
'click',
|
|
'move',
|
|
'enter',
|
|
'leave',
|
|
'pointerdown',
|
|
'pointerup',
|
|
'context-menu',
|
|
'double-click',
|
|
] as const
|
|
|
|
export type EventSuffix = (typeof eventSuffixes)[number]
|
|
|
|
type NodeEvents<T extends string, E> = {
|
|
[K in `${T}:${EventSuffix}`]: E
|
|
}
|
|
|
|
type GridEvents = {
|
|
[K in `grid:${EventSuffix}`]: GridEvent
|
|
}
|
|
|
|
export interface CameraControlEvent {
|
|
nodeId: AnyNode['id']
|
|
}
|
|
|
|
export interface ThumbnailGenerateEvent {
|
|
projectId: string
|
|
captureMode?: 'standard' | 'viewport' | 'area'
|
|
cropRegion?: { x: number; y: number; width: number; height: number }
|
|
/**
|
|
* When true, snap levels to their true positions before capturing (for a
|
|
* consistent auto-thumbnail angle) and defer the capture if the tab is
|
|
* hidden — the background auto-save path. Omit for user-driven captures
|
|
* that should fire immediately from the current camera pose.
|
|
*/
|
|
snapLevels?: boolean
|
|
/**
|
|
* When true, keep the rendered alpha channel — emits a transparent PNG
|
|
* without baking the scene background into the output. Used by the
|
|
* preset capture flow so saved preset thumbnails composite cleanly on
|
|
* any palette background.
|
|
*/
|
|
transparent?: boolean
|
|
}
|
|
|
|
export interface CameraControlFitSceneEvent {
|
|
/**
|
|
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
|
|
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
|
|
* viewer's camera-controls listener frames the camera onto this box.
|
|
* Omitted values fall back to the camera's default pose.
|
|
*/
|
|
bounds?: {
|
|
min: [number, number]
|
|
max: [number, number]
|
|
center: [number, number]
|
|
size: [number, number]
|
|
}
|
|
}
|
|
|
|
type CameraControlEvents = {
|
|
'camera-controls:view': CameraControlEvent
|
|
'camera-controls:focus': CameraControlEvent
|
|
'camera-controls:capture': CameraControlEvent
|
|
'camera-controls:top-view': undefined
|
|
'camera-controls:orbit-cw': undefined
|
|
'camera-controls:orbit-ccw': undefined
|
|
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
|
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
|
}
|
|
|
|
type ToolEvents = {
|
|
'tool:cancel': undefined
|
|
}
|
|
|
|
type GuideEvents = {
|
|
'guide:set-reference-scale': { guideId: GuideNode['id'] }
|
|
'guide:cancel-reference-scale': undefined
|
|
'guide:deleted': { guideId: GuideNode['id'] }
|
|
}
|
|
|
|
type DoorAnimationEvents = {
|
|
'door:animation-completed': {
|
|
doorId: DoorNode['id']
|
|
field: 'operationState' | 'swingAngle'
|
|
}
|
|
}
|
|
|
|
type WindowAnimationEvents = {
|
|
'window:animation-completed': {
|
|
windowId: WindowNode['id']
|
|
field: 'operationState'
|
|
}
|
|
}
|
|
|
|
type PresetEvents = {
|
|
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
|
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
|
}
|
|
|
|
type ThumbnailEvents = {
|
|
'thumbnail:before-capture': undefined
|
|
'thumbnail:after-capture': undefined
|
|
}
|
|
|
|
type SnapshotEvents = {
|
|
'snapshot:saved': undefined
|
|
'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] }
|
|
}
|
|
|
|
type AIChatEvents = {
|
|
'ai-chat:attach-images': {
|
|
images: { url: string; name: string; kind: 'snapshot' | 'render' }[]
|
|
}
|
|
}
|
|
|
|
type EditorEvents = GridEvents &
|
|
NodeEvents<'wall', WallEvent> &
|
|
NodeEvents<'fence', FenceEvent> &
|
|
NodeEvents<'item', ItemEvent> &
|
|
NodeEvents<'site', SiteEvent> &
|
|
NodeEvents<'building', BuildingEvent> &
|
|
NodeEvents<'elevator', ElevatorEvent> &
|
|
NodeEvents<'level', LevelEvent> &
|
|
NodeEvents<'zone', ZoneEvent> &
|
|
NodeEvents<'slab', SlabEvent> &
|
|
NodeEvents<'shelf', ShelfEvent> &
|
|
NodeEvents<'spawn', SpawnEvent> &
|
|
NodeEvents<'ceiling', CeilingEvent> &
|
|
NodeEvents<'column', ColumnEvent> &
|
|
NodeEvents<'roof', RoofEvent> &
|
|
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
|
NodeEvents<'stair', StairEvent> &
|
|
NodeEvents<'stair-segment', StairSegmentEvent> &
|
|
NodeEvents<'window', WindowEvent> &
|
|
NodeEvents<'door', DoorEvent> &
|
|
NodeEvents<'scan', ScanEvent> &
|
|
NodeEvents<'guide', GuideEvent> &
|
|
NodeEvents<'box-vent', BoxVentEvent> &
|
|
NodeEvents<'ridge-vent', RidgeVentEvent> &
|
|
NodeEvents<'chimney', ChimneyEvent> &
|
|
NodeEvents<'solar-panel', SolarPanelEvent> &
|
|
NodeEvents<'skylight', SkylightEvent> &
|
|
NodeEvents<'dormer', DormerEvent> &
|
|
CameraControlEvents &
|
|
ToolEvents &
|
|
GuideEvents &
|
|
DoorAnimationEvents &
|
|
WindowAnimationEvents &
|
|
PresetEvents &
|
|
ThumbnailEvents &
|
|
SnapshotEvents &
|
|
AIChatEvents
|
|
|
|
export const emitter = mitt<EditorEvents>()
|