Loading JSON previously called setScene blindly and crashed when
the file held schema-invalid nodes (e.g. items missing `asset`).
The new dialog parses the file, runs validateBuildJson in core,
and surfaces structure counts (site/building/levels/walls/doors/
windows/items/slabs/ceilings/zones/scans), floor area, and a
per-node Schema Details list grouped by type. Import is blocked
when any hard error exists.
Two schema bugs the validator surfaced are fixed here too:
- SiteNode.children is now an id array like every other node
(was a discriminatedUnion of full objects; three readers carried
a string-or-object ternary that's now dropped). migrateNodes
flattens legacy nested-object children on load. Default-scene
seed and photo-to-scene MCP builder updated to pass `building.id`.
- LevelNode.children now includes shelf — the editor allowed it
but the schema didn't. The schema-vs-registry-as-source-of-truth
discussion is captured in plans/editor-node-registry.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symptom: a shelf placed on a level with a raised slab underneath
visually clipped through it — `ItemSystem` lifted items onto slabs,
but shelves (and other floor-placed registry kinds) had no equivalent
path.
Fix: lift the slab-elevation logic out of `ItemSystem` into a generic
`<FloorElevationSystem>` keyed off a new `capabilities.floorPlaced`
config. Any kind that opts in declares a `footprint(node)` (dimensions
+ rotation used to query overlapping slabs) and an optional `applies`
predicate (skips items whose `asset.attachTo` is wall / ceiling).
The new system runs at frame priority 1 so its `mesh.position.y`
override lands before `ItemSystem` / `GeometrySystem` (priority 2)
clear the dirty mark. The spatial-grid sync's `markNodesOverlappingSlab`
also dropped its hardcoded `item` branch in favour of an iteration over
every registered kind that declares `floorPlaced` — so any new
floor-placed kind picks up slab-driven re-elevation automatically.
Tagged kinds:
- `item` — `footprint = getScaledDimensions`, `applies = !asset.attachTo`
- `shelf` — `footprint = (w, h, d)`
- `column` — `footprint = (w, h, d)`
- `spawn` — `footprint = (0.6, 1.8, 0.6)` (marker)
`ItemSystem` retains only the wall-side z-offset block (`mesh.position.z =
wallThickness / 2`). The elevation block + its `getScaledDimensions` /
`resolveLevelId` / `spatialGridManager` imports moved to the generic
system.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three layered improvements on the 2D registry layer:
- Z-order buckets so the SVG document order reflects intent: zones (0)
paint first, slabs/ceilings (1) next, every other kind (walls / items /
shelves / columns / stairs / …) on top. Stable sort preserves DFS order
within a bucket.
- Base / overlay split: each entry's `FloorplanGeometry` tree is walked
through `splitFloorplanOverlay`, partitioning into a base group
(polygons, paths, fills, hatches) and an overlay group (interactive
handles + labels — `text` / `endpoint-handle` / `midpoint-handle` /
`edge-handle` / `move-handle` / `dimension` / `dimension-label`). Base
renders rank-sorted; overlays paint after every base entry so polygon-
editor chrome on a selected slab and zone name labels stay legible
above the structural fills sitting on top of them.
- Click guard on the outer layer `<g>`. The base/overlay split means
pointer-down lands on base and pointer-up lands on overlay (selection
mounts the overlay on top mid-gesture). The browser then dispatches
`click` to the lowest common ancestor, ABOVE the entry's
`onClick={handleClickStop}`. Without a higher-level stop, the click
reached the SVG's `handleBackgroundClick` → `clear-elements` and the
selection set on pointer-down vanished a frame later. Scoped to
`onClick` only so pointer / hover / drag still propagate inside the
registry tree.
Also extends the `text` FloorplanGeometry with stroke / strokeWidth /
paintOrder / fontFamily so kinds can match the legacy "white fill +
colored outline" label look (used by the new zone name label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle
and a `children: ItemNode[]` field for item hosting. Schema-level defaults
preserve the v1 wall-shelf visual so existing scenes load unchanged; the
placement tool spreads `shelfDefinition.defaults()` for fresh shelves
(cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on).
Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share
the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per
row, plus the bottom-board top when `withBottom` is on for cubby /
bookshelf.
Material is a single paintable surface (same shape walls / slabs / stairs
use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so
unpainted shelves read as the canonical off-white.
Preview clones each cached material before mutating `transparent / opacity`
on the ghost — without the clone the mutation leaked into the cached
`getShelfMaterial` instance every committed shelf was using, rendering
them all see-through after the first placement preview rendered.
Store hardening: `migrateNodes` patches missing `children: []` on v1
shelves, and `updateNodesAction` reparenting tolerates a missing children
array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint
mode picks up the kind.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed at the palette's PNG icons in the bottom toolbar — they
expected the inspector title to use the same artwork, not the iconify
lucide glyphs. Different visual style.
- IconRef gains `{ kind: 'url'; src: string }`. Plain `<img>` render
in ParametricInspector (no next/image — the inspector is
`'use client'`).
- All currently-registered kinds switched to URL refs matching their
palette `iconSrc`:
fence → /icons/fence.png
slab → /icons/floor.png
ceiling → /icons/ceiling.png
wall → /icons/wall.png
spawn → /icons/site.png
shelf → /icons/column.png (placeholder, same as palette)
- Kind-owned panels (slab/ceiling) already pass URL strings to their
own PanelWrapper; unchanged.
Door/window/item will get URL refs when they register at Stage A.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User noticed the auto-derived fence inspector was missing the legacy
panel's Length + Curve sliders, and the section labelling was wrong
(Posts → Structure). Both Length and Curve are awkward for the
parametrics field model:
- **Length** doesn't map to a single node key — it's derived from
`start`/`end`, and editing it moves `end` along the existing
direction.
- **Curve** maps to `curveOffset` but the slider's min/max are
bounded per-node by the chord length, plus updates need
`normalizeWallCurveOffset`.
Adds a `kind: 'custom'` field with a kind-supplied
`component: ComponentType<{ node, onUpdate }>`. The inspector mounts
it and lets the kind own rendering + update logic. `key` becomes a
free-form React key/label since it no longer needs to map to a node
property.
Fence parametrics now mirrors the legacy layout 1:1:
- Style (segmented controls + showInfill toggle).
- Dimensions (Length, Curve, Height, Thickness).
- Structure (Base Height, Top Rail, Post Spacing, Post Size, Ground
Clear, Edge Inset).
Length + Curve live in fence/inspector-editors.tsx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed out two regressions in the auto-derived fence inspector:
- A `color` field rendered for fence — but the legacy `FencePanel`
hid it (fence's color is a leftover schema field that isn't part of
the inspector UX). Dropped from `fenceParametrics`.
- Style + base-style enums rendered as a dropdown, but the legacy
used the inline segmented switcher (Slat/Rail/Privacy +
Grounded/Floating). Added a `display?: 'select' | 'segmented'` hint
to the enum field kind. ParametricInspector renders SegmentedControl
when set; defaults to dropdown otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three flavors of Stage E in one PR:
- **Fence** — fully auto-derived. Adds `kind: 'boolean'` to ParamField
(rendered as ToggleControl), wires `showInfill` through
`def.parametrics`. Legacy `FencePanel` deleted; the auto-derived
`<ParametricInspector>` now drives fence editing entirely.
- **Slab / Ceiling** — kind-owned via `parametrics.customPanel`. The
legacy panels have shape-specific bits (elevation/height presets,
area display, holes list with auto-vs-manual provenance) that don't
fit the auto-derived field model yet. `<ParametricInspector>` learns
to lazy-load and mount `parametrics.customPanel` when present;
legacy `SlabPanel` + `CeilingPanel` files relocate to
`nodes/src/<kind>/panel.tsx` and the legacy copies delete.
When `list` / `computed` / `action` field kinds eventually graduate
to auto-derived support, these custom panels collapse back into
`parametrics.groups`. The plan calls this out under "Custom-behavior
escape hatch" and Foot-gun 5 of the recipe.
Public-surface additions in `@pascal-app/editor`:
- `ActionButton`, `ActionGroup`, `PanelSection`, `SegmentedControl`,
`ToggleControl`, `PanelWrapper` — needed by the kind-owned panels.
Per-kind progress table: fence/slab/ceiling all flip to E ✅.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported regressions after the previous Stage D move ports:
1. Slab/ceiling moves were sluggish because the actions wrote
scene.update(polygon) every grid:move tick → React re-render +
CSG-with-holes geometry rebuild per frame.
Apply the live-drag exception (same recipe fence move already used):
visual translation via `sceneRegistry.nodes.get(id).position` +
`useLiveTransforms`; scene.polygon is only written on commit. Polygon
center precomputed in ctx, mesh-offset clears on commit/cancel.
2. Cursor sphere sat at the polygon center (offset from the user's
actual cursor by `originalCenter - first_cursor`). Move wrappers
now subscribe to `grid:move` and set `cursorRef.current.position`
directly — no React state, no per-tick reconcile. Cursor lands on
the user's pointer.
3. Fence move had the same React-reconcile-per-tick cost via its
`useLiveTransforms` subscription. Switched to the same direct
ref-mutation pattern.
Adds a "REAL bend" test pinning that one Ctrl-Z after a real curve
drag undoes only the bend, not the create. The previously reported
"first undo does nothing" outcome reproduces only for no-op bends
(drags within `normalizeWallCurveOffset`'s straight-snap threshold,
≈1.5cm on a 3m fence). For visible bends the dance pushes a real
pastState entry and one undo step rolls back the bend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Includes a regression test for the "no-op bend" case that reproduces
the user-reported 'fence bend cancels fence creation' bug: when
action.commit returns false (draft === original), no pastState push
happens during the drag's commit path. The very next Ctrl-Z falls
through to whatever was on the stack before activation — typically
the fence creation itself.
The actual dance with real changes (delta !=0) works correctly — the
test exercising the full createDragSession flow with action.commit
returning true pins one undo step covering only the drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These files had been showing as modified in every dev session — biome's
canonical formatting (line-length collapses, import sort, type-modifier
placement) didn't match the committed state. No semantic changes.
Committing now so the working tree stays clean across sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous dispose() was documented as "equivalent to cancel()" and
fired onCancel. That breaks React StrictMode's mount → cleanup → mount
cycle for useDragAction consumers: the first cleanup's onCancel resets
the parent state machine (e.g. setCurvingFence(null)), which unmounts
the component before the second mount runs. Net result: the tool blinks
in and out instantly.
dispose() now restores scene state + resumes history but skips onCancel.
Explicit cancel() still fires onCancel (Esc / external aborts). New
test locks this in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Stage D port — `CurveFenceTool` (178 LoC legacy) split into a pure
`DragAction` primitive (`packages/nodes/src/fence/actions/curve.ts`) plus
a thin React wrapper (`packages/nodes/src/fence/curve-tool.tsx`) that
feeds it through `useDragAction`. The kind declares the affordance via
`def.affordanceTools.curve`; ToolManager lazy-loads it at runtime when
`useEditor.curvingFence` activates. Falls back to the legacy
`CurveFenceTool` for any kind that hasn't been ported.
The lazy-load dispatch dodges the editor→nodes circular dep (nodes
already depends on editor for `useDragAction` + `CursorSphere`).
Establishes the pattern for the remaining fence affordances
(`MoveFenceEndpoint`, `MoveFence`, placement) and for slab/ceiling/wall
D ports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions on top of the floor-plan registry contract:
1. def.toolHints + RegisteredToolHelper (registry contract for the
shortcut hint panel)
- New ToolHint type in core: { key, label } static array.
- Added `def.toolHints?: ToolHint[]` to NodeDefinition.
- New <RegisteredToolHelper hints={...}> in editor — same visual
styling as WallHelper / ItemHelper but data-driven.
- HelperManager: registry-first check before falling through to the
hand-written per-tool switch. Per-tool helper files get deleted
as their kind migrates `toolHints` in.
- Shelf + spawn definitions ship toolHints today; wall ports in
Phase 3 Milestone C alongside its tool/affordance port.
2. Floor-plan interaction layer (selection + drag-to-move)
- <FloorplanRegistryLayer> now wraps each entry in an interactive
<g>:
* Click → useViewer.setSelection({ selectedIds: [id] }).
Selection visual is a thicker accent-colored stroke applied
via withSelectionStyle() recursion through the FloorplanGeometry
tree — kinds don't author selection decoration.
* Drag → imperative SVG transform during the gesture, single
updateNode commit on pointerup. Same "smooth move" pattern as
MoveRegistryNodeTool for 3D drag: no per-tick store update,
no React re-render storm, no zundo bloat. Coordinate
conversion via svg.getScreenCTM().inverse().
* useScene.temporal.pause/resume brackets the gesture so one
drag = one undo step.
- Global pointermove / pointerup listeners so the gesture survives
the cursor leaving the entry's bounding box (matches the legacy
elevator-resize-drag and item-drag patterns in floorplan-panel).
3. Spawn floor-plan builder (deferred wiring)
- buildSpawnFloorplan written but NOT wired on the definition —
spawn already renders in the legacy floorplan-panel.tsx via
`floorplanSpawnEntries`, and wiring def.floorplan now would
double-render. The pure builder lives in nodes/src/spawn/
floorplan.ts ready to wire when the legacy inline branch is
removed (Phase 5 spawn-floorplan migration PR — same shape as
wall's feature flag, but per kind inside the legacy panel).
Plan updated: floor-plan interaction section locks the click/drag
contract in, wall-floor-plan-as-legacy note flags everything advanced
the user sees today as legacy that ports alongside Milestone C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the floor-plan side of the three-checkbox composition model
documented in wiki/architecture/node-definitions.md. Mirrors the 3D
side (def.geometry → <GeometrySystem> → <ParametricNodeRenderer>) but
emits SVG primitives instead of three.js Object3Ds, and runs inside
the floor-plan panel.
Type-side (packages/core/src/registry/types.ts):
- New FloorplanGeometry tagged union covering path / polygon /
polyline / rect / circle / line / group. FloorplanStyle props map
straight to SVG attributes. Coordinates are level-local meters;
rotations are radians (three.js convention).
- New `def.floorplan?: (node, ctx) => FloorplanGeometry | null` field
on NodeDefinition, independent of `geometry` and `renderer`. Re-
exported via packages/core/src/registry/index.ts.
Runtime (packages/editor):
- <FloorplanGeometryRenderer> walks the FloorplanGeometry tree and
emits the matching React-SVG elements. Pure data → DOM; no per-kind
logic.
- <FloorplanRegistryLayer> reads the active levelId, walks the level
subtree, looks up each node's def.floorplan, builds a
GeometryContext, calls the builder, and renders the output via
FloorplanGeometryRenderer.
- Mounted in floorplan-panel.tsx just before <FloorplanMarqueeLayer>
so registry-driven kinds layer above legacy inline content.
Shelf migration (proof port):
- New nodes/src/shelf/floorplan.ts — buildShelfFloorplan(node) emits
a group with the rotation/translation transform and a width × depth
rectangle in the shelf's color. Brackets omitted (hidden under top
board from above).
- Wired to shelfDefinition.floorplan. Shelf now appears in the floor
plan view for the first time (was missing from the legacy panel's
inline switch).
Pattern proven; every future kind migrating in Phase 5 follows the
same shape: a pure (node, ctx) => FloorplanGeometry function. As kinds
register their floor-plan builders, the corresponding inline branches
in floorplan-panel.tsx become dead code and can be deleted in the
same PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the three-checkbox composition runtime documented in
wiki/architecture/node-definitions.md. A kind with only a pure
geometry function now needs zero per-kind React or system code.
Type-side additions (packages/core/src/registry/types.ts):
- New `GeometryContext` (resolve / children / siblings / parent) — read-
only scene access for builders that reference other nodes by ID
(wall miters, door cutouts). Most kinds ignore it.
- New `geometry?: (node, ctx) => Object3D` field on NodeDefinition,
independent of renderer/system. Three orthogonal opt-ins replace the
v0 RendererSource union.
- Re-exported via packages/core/src/registry/index.ts (consumed by
nodes packages through `export * from './registry'`).
Runtime (packages/viewer):
- New <GeometrySystem> (systems/geometry/geometry-system.tsx) walks
dirtyNodes, builds a GeometryContext per dirty node, calls
def.geometry, disposes old children, attaches new ones, clearDirty.
Frame priority 2 (matches the priority shelf's per-kind system had).
Mounted in viewer/index.tsx alongside <RegisteredSystems>.
- New <ParametricNodeRenderer> (components/renderers/parametric-node-
renderer.tsx) — empty <group> + useRegistry + useNodeEvents +
markDirty-on-mount + useLiveTransforms. Mounts hosted children via
<NodeRenderer> recursively. The default renderer for any registered
kind without a custom def.renderer.
- <NodeRenderer> dispatch updated: custom renderer wins, else
geometry-only kinds fall through to ParametricNodeRenderer, else
null (legacy switch fallback). Documented inline.
Shelf migration (proof of the boilerplate collapse):
- Deleted nodes/src/shelf/renderer.tsx (was 45 lines of registry +
handler boilerplate).
- Deleted nodes/src/shelf/system.tsx (was 60 lines of dirty-loop +
dispose plumbing).
- shelfDefinition now: `geometry: buildShelfGeometry`. One line.
buildShelfGeometry is the pure function from geometry.ts that already
existed.
End-to-end effect: registry-driven shelf now mounts via the framework's
generic renderer + system. Parametric edits flow through the same
dirty-driven rebuild path, but the kind ships ~100 fewer lines of
boilerplate. Every future kind that fits the same shape (item, fence
segment, column, etc. as they migrate in Phase 5) follows the same
"one line, one pure function" pattern.
Wall stays on its dedicated def.renderer + def.system — its mitering
needs level-batch context (`ctx.levelData?.miters`, future extension)
that the generic system doesn't yet provide. Decided at Phase 3+, not
blocking Phase 4 acceptance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays down the wall folder under @pascal-app/nodes with everything needed
to register the kind, but intentionally without runtime wiring:
- schema.ts re-exports WallNode from core (door/window/item still type
their parentId against WallNode.shape.id, so the schema stays canonical
there for now).
- parametrics.ts declares thickness / height / curveOffset for the Phase 4
inspector. Endpoints and host children are edited via affordances, not
number inputs, so they're not in parametrics.
- definition.ts encodes capabilities (surfaces, selectable, duplicable,
deletable — no movable since wall's move is bespoke endpoint-drag),
relations (hosts doors/windows/items, affectsSpatial slabs/ceilings/
zones, linkedBy endpoint-match, cascadeDelete descendants), and the
presentation metadata for the palette. Renderer / system / tool fields
are deliberately absent — the existing wall-renderer.tsx and
wall-system.tsx keep serving wall until milestone B.
- feature-flag.ts gates the eventual registration via
NEXT_PUBLIC_USE_REGISTRY_FOR_WALL (same pattern Phase 2 used for spawn).
- wallDefinition is NOT yet appended to builtinPlugin.nodes — registration
is what flips the Phase 0 dispatch shims, and we don't want that until
the runtime port lands. Until then this file is metadata-only.
Two type-side changes pulled forward from Phase 4 to make a metadata-only
definition compile:
- NodeDefinition.renderer becomes optional (the three-checkbox model
documented in wiki/architecture/node-definitions.md already promises
this). RegistryRenderer in node-renderer.tsx gains a null-guard so an
undefined renderer cleanly falls through to the legacy switch.
No runtime behavior change. Walls render and behave exactly as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: the move tool's CursorSphere is just a vertical line,
hard to tell what you're moving. Placement gets a translucent shape
that follows the cursor; move should too.
NodeDefinition.preview?: () => Promise<{ default: ComponentType<{ node }> }>
opt-in lazy component that renders a translucent ghost of the node.
Used by:
- The placement tool (ShelfTool) — renders the preview at the cursor
position so the user sees the shape they're placing.
- The move tool (MoveRegistryNodeTool) — renders the preview at the
drag target alongside the CursorSphere. Plus the original node is
also dragged via live transforms, so the user sees both: the actual
node moving + a translucent ghost at the same spot.
Implementation:
- New nodes/shelf/preview.tsx: ShelfPreview component. Renders the
same shape as ShelfRenderer but `transparent: true, opacity: 0.5`.
- shelfDefinition.preview = () => import('./preview').
- ShelfTool's placement preview now uses <ShelfPreview node={defaults} />
instead of an inline copy of the box geometry.
- MoveRegistryNodeTool lazy-loads `def.preview` (cached by loader,
Suspense-wrapped). If a kind doesn't define `preview`, only the
CursorSphere shows — matches today's behavior.
Phase 4 may merge `preview` with `renderer` behind an `opacity` prop
so kinds don't duplicate JSX between the solid and translucent
versions; until then defining `preview` is opt-in and one extra file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two concerns from the spike:
1) Selection / floating-action-menu had hardcoded kind lists scattered
across 4 files. Adding 'shelf' to each one per migration was the
wrong abstraction — the user's question "did you make it generic
from the noderegistry?" was the right one. Done now.
Added to @pascal-app/core/registry:
- getSelectableKinds(): string[] — returns all registered kinds
whose definition declares `capabilities.selectable`.
- isRegistrySelectable(kind): boolean — predicate for OR-chains.
Refactored hardcoded sites to merge registry kinds at runtime,
keeping legacy hardcoded lists intact so existing kinds keep
working unchanged:
- editor SelectionManager: 4 subscription loops (enter/leave/click)
+ structure.isValid + getSelectionTarget — all augment with
registry kinds. Phase 6 deletes the hardcoded lists.
- viewer SelectionManager: subscription loop + SelectableNodeType
broadened with `(string & {})` to accept registry kinds.
- floating-action-menu: ALLOWED_TYPES OR'd with isRegistrySelectable.
- Removed the manually-added 'shelf' entries from previous commit
857ddd4; they were redundant once the registry-driven path landed.
Future built-in nodes that declare `capabilities.selectable` get
click-selection + hover + the floating action menu (move/delete
icons) for free, no editing of these 4 files.
2) Spawn parity is signed off. Drop the
NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag entirely; spawn registers
unconditionally in builtinPlugin.nodes. Restored SPAWN_COLOR to
the original #22c55e green (was #ef4444 red as a Phase 2
verification marker).
Pre-existing typecheck errors in editor (ceiling/fence/slab tree-node,
scene.ts buildingId) are unchanged.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible follow-ups after first running the Phase 2 spike.
Spawn tool now matches legacy UX:
- CursorSphere from @pascal-app/editor for the placement indicator
(ring + line + tool-icon tooltip) — was a plain sphere mesh.
- Emits sfx:structure-build on commit + setTool(null) + setMode
('select') to exit build mode, matching legacy spawn-tool.
Shelf tool placement:
- Emits sfx:structure-build on commit.
- Cursor preview now shows top board + brackets (was just the top),
matching what gets placed.
Shelf selectable from the 3D canvas:
- ShelfEvent type added to @pascal-app/core/events/bus.
- 'shelf' added to NodeConfig in useNodeEvents.
- ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto
every mesh. Clicks/hovers now bubble through the editor's selection
manager and update useViewer.selection.
Shelf appears in the sidebar:
- ShelfTreeNode component (mirrors spawn-tree-node's shape +
selection/hover/rename wiring; lucide Layers icon).
- TreeNode dispatcher adds a `case 'shelf':` arm.
Framework changes:
- @pascal-app/editor exports CursorSphere alongside triggerSFX.
- @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep.
Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node,
fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on
main and not introduced by this commit.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first time registry-driven nodes actually run in the editor.
Spawn migration (under NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag):
- New packages/nodes/src/spawn/ folder with renderer, tool, schema
(re-exported from core), parametrics, definition, index.
- Spawn definition appended to builtinPlugin.nodes only when the flag
is set. With the flag off, the Phase 0 shims fall through and the
legacy SpawnRenderer / SpawnTool keep ownership.
- New no-props SpawnTool reads activeLevelId from useViewer directly,
matches legacy placement behavior (half-meter snap, singleton-per-
level, replace-on-reclick).
- Structural parity test (9 cases) validates definition shape +
schema identity. Pixel-diff defers to Phase 4 alongside more nodes.
New shelf node (no legacy — registered unconditionally):
- ShelfNode schema in core/schema/nodes/shelf.ts (hand-maintained
AnyNode union for now; Phase 6 derives the union from the registry
and moves the schema fully into nodes/shelf/).
- packages/nodes/src/shelf/ folder: pure geometry builder
(buildShelfGeometry returns a Three.js Group of top board +
brackets), R3F renderer that mounts the built group, no-props
placement tool, parametrics descriptor (width/depth/thickness/
height/bracketStyle/color), definition with surfaces.top stackable
surface for future stacking, and presentation metadata for the
palette.
- 13 unit tests across schema bounds and geometry behavior.
- Palette wiring: 'shelf' added to StructureTool union + an entry in
the structure-tools array (placeholder icon, replaced in Phase 4
when palette is registry-driven).
Framework changes:
- @pascal-app/viewer now exports useNodeEvents from its public barrel
so node bundles in @pascal-app/nodes can subscribe to node-specific
pointer events. (Used by spawn renderer; shelf renderer skips it
for now since useNodeEvents has a hardcoded kind list — Phase 4
generalizes it via the registry.)
- @pascal-app/nodes gains @pascal-app/viewer as a peer + dev dep so
node bundles can import from it.
630 tests pass across 76 files (22 new this phase). Editor app
continues to ship green with both legacy spawn and the new shelf
node co-existing through the Phase 0 dispatch shims.
To validate end-to-end in dev:
- bun dev:community → open editor → click 'Shelf' in structure
toolbar → click to place. Confirms full registry path
(NodeRenderer dispatch + ToolManager dispatch + sceneRegistry
byType Proxy).
- Set NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN=1, restart dev, place spawn
→ visually identical to legacy. Confirms parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds a 15200-node fixture (50×100 wall grid + 8000 hosted doors +
200 sparsely-indexed slabs) and runs `cascadeDirty` 1000 times with
warm-up. Reports p50/p95/p99/mean/max in ms.
Runs via `bun run bench:registry` from the core package.
Today: p95 measured at ~0.002ms — three orders of magnitude under the
Phase 1 gate of 2ms. Headroom is substantial; we'll only revisit this
if Phase 3 wall introduces `linkedBy: 'endpoint-match'` and pushes the
inner cascade past the gate.
Not wired into CI for v1 — regressions reviewed manually before phase
gates. Output is JSON so a future CI step can diff against a baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure orchestrator in core, thin React wrapper in editor:
- `core/services/drag-session.ts` — `createDragSession(action, scene,
options)` returns an imperative session with `start / move / commit
/ cancel / dispose / isActive / getDraft`. Pauses history on start,
resumes on terminate. Per-move runs preview → snap → apply, then
cascades dirty marks via the relations resolver (deduped across
ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel
callbacks. All tested in bun:test — no React needed.
- `editor/src/hooks/use-drag-action.ts` — wraps the session with the
editor's grid-event emitter and an Esc-to-cancel keyboard listener.
Builds a `SceneApi` once via `createSceneApi(useScene)` at module
init. The hook itself is small enough to read top-to-bottom; all
behavior lives in the session.
Tests (13 cases) cover the hard parts: history pause/resume bracket,
explicit cancel restoring all touched nodes, dispose mid-drag, commit
returning false short-circuiting to cancel, snap callback wired in,
re-entry rejected, deduped dirty-mark across multiple move ticks,
hosts cascade from the registry firing in apply.
No callers yet — Phase 2 column and shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure constraint math built on the registry's `MovableConfig`:
- `resolveMovable(node)` — reads `def.capabilities.movable`, runs the
optional `override(ctx)` callback (returning null falls back to the
base config). Returns null when the kind isn't movable.
- `applyAxisLock(current, target, axes)` — projects 3D motion onto
the allowed axes; locked components fall back to current.
- `moveToward(node, current, target, options?)` — top-level helper
combining axis lock + (optional) grid snap. Returns null when the
node is not movable.
- `movePlanToward(node, currentY, current, target, options?)` —
X/Z-plane convenience for floor/plan-view placement.
- `isMovable(node)` — predicate for tools/UI gating.
Tests cover override callback, null-override fallback, axis lock
permutations, grid-snap on/off, and the 2D plan convenience.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure math, no React, no scene access. Three primitives plus a facade:
- `snapScalar(value, step)` / `snapPointToGrid(point, step)` /
`snapVec3ToGrid(point, step)` — regular grid snapping. Default
step 0.25m matches the editor's wall tool.
- `snapPointToAngle(from, cursor, angleStep, gridStep?)` — locks a
cursor to the nearest angle multiple from a fixed point, preserves
distance, optionally re-grids the projected point. Default angle
step π/12 (15°).
- `snapAngleToList(angle, list, tolerance)` — snaps a free angle to
the nearest entry in a fixed list (e.g. 0/45/90/135) within a
tolerance; returns the original angle otherwise. Handles wrap.
- `snapServices` facade — `grid.*` + `angle.*` namespaces. Stable
contract that `DragAction.snap` callbacks receive. Phase 3 ports
the existing `snapWallDraftPoint` family from
`editor/.../wall-drafting.ts` under a `wall.*` namespace.
17 unit tests cover the math + the facade pass-through. No existing
callers re-wired yet — Phase 2 column/shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First service in `core/src/services/`: pure, no React or R3F, takes
SceneApi + node data, returns results.
Exports:
- `canAttach(childId, hostId, scene)` — validates host attachment.
Rejects self-host, cycles (host's ancestor chain contains child),
chains past MAX_HOST_DEPTH (6), and host kinds outside the child
def's `capabilities.hostable.parents` allowlist. Returns a typed
AttachError discriminated union so callers can render specific
messages.
- `getSurface(host)` / `getTopSurfaceHeight(host)` — reads
`def.capabilities.surfaces` from the registry; resolves
function-valued heights with the node.
- `clampYToHostTop(host, y)` — convenience for placement code.
- `pickHost({ point, candidates, placedKind, hitTest? })` — given
spatially pre-filtered candidates, returns the first hostable.
The runtime is responsible for spatial filtering; this function
stays pure.
MAX_HOST_DEPTH = 6: the explore earlier found today's editor has no
cap on item-on-item nesting. Cap is bounded by hostable depth, not
total tree depth (sites/buildings/levels don't count).
17 tests cover all rejection paths + happy paths + function-valued
surface heights.
Re-exported from `@pascal-app/core` via a new `services/` barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure traversal that walks a node's declared `relations` and returns the
full set of IDs that should be marked dirty alongside it. Two
implementations:
- `cascadeDirty(id, ctx)` — follows `hosts` (matching children) and
`affectsSpatial` (via injected spatialQuery). Phase 3 will add
`linkedBy: 'endpoint-match'`.
- `collectDescendants(id, ctx)` — pure subtree traversal for
`cascadeDelete: 'descendants'` and subtree deletion tools.
Both bounded by maxDepth (default 16) and visited-set so cycles in
bad data can't loop forever.
Context-based design: spatialQuery and childQuery are injected, so the
resolver itself stays pure — the DragAction runtime can plug in
spatialGridManager-backed queries; tests pass stubs.
Today, registry has no kinds → cascadeDirty(id) always returns just
{id}. No behavior change. Phase 3 wall is the first real consumer.
11 unit tests cover empty/no-relations baseline, hosts cascade, depth
limit, spatial query, missing spatialQuery branch, cycle protection,
childQuery override, descendant collection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional `presentation` field for tool palette metadata: sentence-
case label, optional description, icon (iconify reference, inline SVG,
or lazy React component), palette section override, sort order, and a
`hidden` flag for container kinds that exist but should not appear in
the palette.
Consumer arrives in Phase 4 (auto-derived palette buttons) — defining
the type now means Phase 2's `column` and `shelf` definitions ship with
the field already populated, no later round-trip.
Iconify is the encouraged form for built-ins and AI-authored nodes:
matches the @iconify-react setup the editor app already uses, and AI
emits a name string from a curated list (no asset upload step needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
byType was a hardcoded object keyed by the built-in node kinds. With
the registry, kinds can come from @pascal-app/nodes (or future
plugins) — so byType now wraps a Map via a Proxy that auto-creates an
empty Set the first time any kind is touched.
Built-in kinds are still pre-seeded at module init so the fast path
(no Proxy trap) is preserved. clear() iterates the backing Map.
useRegistry's `type` parameter widens from `keyof typeof byType` to
`KnownNodeKind | (string & {})` — preserves autocomplete for
built-ins while accepting plugin-supplied kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion changes for the new nodes package: bun.lock entry from
`bun install`, and a Biome-auto-sort of the registry barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the @pascal-app/core/registry surface that future node-bundle
packages (and external plugins) will use to register node kinds with the
host. No runtime behavior changes — registry is empty until subsequent
PRs populate it.
- types.ts: NodeDefinition, Capabilities, Relations, DragAction, Plugin,
ParametricDescriptor, Affordance, SceneApi, NodeRegistry. Capability
configs accept an override escape hatch; additive-only after v1.
- registry.ts: nodeRegistry singleton, registerNode, async loadPlugin.
Validates kind, schemaVersion, apiVersion; rejects duplicate kinds.
- scene-api.ts: createSceneApi factory wrapping the scene store with
copy-on-write snapshot semantics for pauseHistory/restore/resumeHistory.
- index.ts: barrel re-exporting the public surface.
- core/index.ts + package.json: export * from registry and add the
./registry subpath so consumers can import either way.
Tests (27 cases, all bun:test): registry registration / validation /
plugin loading; SceneApi read/write/dirty/history; lazy snapshot capture
with update/upsert/delete reversal via restore and restoreAll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file imports useEffect/useRef from React, which Next.js RSC builds
flag as client-only. Other core systems (e.g. elevator-runtime-system)
use useFrame from @react-three/fiber and slip through, but this one
needs the directive explicitly.
Fixes Turbopack build failure in private-editor community app:
"You're importing a module that depends on useEffect into a React
Server Component module."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>