Two follow-up fixes after the FloorElevationSystem landed:
- `viewer/src/components/viewer/index.tsx`: the auto-format hook
stripped the `FloorElevationSystem` import a second time, leaving
the JSX mount unresolved.
- `packages/nodes/src/column/definition.ts`: column stores Y rotation
as a scalar `number`, but `floorPlaced.footprint` types the rotation
field as the full Euler tuple. Wrap as `[0, column.rotation, 0]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit's `floorPlaced` capability used `getScaledDimensions`
in `item/definition.ts` and `ColumnNodeType` in `column/definition.ts`,
but the editor's auto-format hook ran between the import edit and the
body edit and removed both as "unused" — breaking `bun dev`.
Re-add the imports.
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>
Final sweep after the layers, handlers, and state were dismantled —
pruning the remaining orphan types and constants that only the dead
code referenced.
Removed:
- 27 dead constants: `FLOORPLAN_*_HOVER_*_STROKE_WIDTH` for wall /
item / endpoint (consumed by the deleted legacy SVG layers),
`FLOORPLAN_WALL_OUTER_MEASUREMENT_*` + `FLOORPLAN_WALL_INNER_
MEASUREMENT_*` + `FLOORPLAN_OPENING_MEASUREMENT_*` palette
constants (no live measurement consumer), `FLOORPLAN_ITEM_
CLEARANCE_*` thresholds, `FLOORPLAN_MEASUREMENT_LABEL_*` / `LINE_
OUTLINE_*` + `FLOORPLAN_ACTION_MENU_OFFSET_Y` / `FLOORPLAN_NODE_
FOOTPRINT_*` / `FLOORPLAN_SPAWN_*` / `FLOORPLAN_TRACE_*_FILL_
OPACITY` + several `FLOORPLAN_WALL_*_STROKE_WIDTH` variants.
- Dead drag-state types: `SlabBoundaryDraft`, `SlabHoleBoundaryDraft`,
`SlabVertexDragState`, `SlabHoleVertexDragState`, `SlabHoleMoveDraft`,
`CeilingBoundaryDraft`, `CeilingVertexDragState`,
`CeilingHoleBoundaryDraft`, `CeilingHoleVertexDragState`,
`CeilingHoleMoveDraft`, `ZoneBoundaryDraft`, `ZoneVertexDragState`,
`WallFaceLine`.
- Dead helpers: `getWallMeasurementOverlay` + `getLinearMeasurementOverlay`
(both defined but never called now that the measurement layers are
gone).
Floor-plan panel: 9,006 → 8,651 lines (-355). Cumulative reduction
vs `main`: 17,913 → 8,651 (-9,262 lines, ~52%).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the legacy SVG layers and handler callbacks were dismantled, the
backing drag-state useStates, mirror useRefs, and watch-effects became
orphans, along with a handful of top-level math helpers whose callers
were already gone.
Removed:
- 12 dead `useState` declarations: `slabBoundaryDraft` /
`slabVertexDragState` / `slabHoleBoundaryDraft` /
`slabHoleVertexDragState` / `slabHoleMoveDraft` / `ceilingBoundary
Draft` / `ceilingVertexDragState` / `ceilingHoleBoundaryDraft` /
`ceilingHoleVertexDragState` / `ceilingHoleMoveDraft` /
`zoneBoundaryDraft` / `zoneVertexDragState`. All written only by
the dead vertex-drag handlers and read only by the dead
`clear*BoundaryInteraction` callbacks and `transientFloorplanFit`
checks (which collapse to `false` once their inputs are gone).
- 5 dead `useRef` mirrors of the above (`slabBoundaryDraftRef` etc.)
plus their mirror `useEffect` writes.
- 5 dead `clear*BoundaryInteraction` callbacks (slab, slabHole,
ceiling, ceilingHole, zone) — only called by deleted useEffects
and by `clearDraft`, where the call became a no-op.
- 7 dead `useEffect` watchers for `slabVertexDragState`,
`ceilingVertexDragState`, `slabHoleVertexDragState`,
`slabHoleMoveDraft`, `ceilingHoleVertexDragState`,
`ceilingHoleMoveDraft`, `zoneVertexDragState` (each early-returns
because state is always null, so all listener wiring + commit /
cancel paths inside were unreachable).
- 5 dead `shouldShow*BoundaryHandles` flags + the 5 `useEffect`s
that called `clear*BoundaryInteraction` when they flipped.
- `selectedSlabEditingHoleIndex` / `selectedSlabEditingHole` /
`selectedCeilingEditingHoleIndex` / `selectedCeilingEditingHole`
— derived from now-`null` selectedX entries.
- Dead drag-state guards in `handlePointerMove`, `handleSvgPointer
Move`, and the floor-plan fit `useMemo`.
- Dead top-level helpers: `getRaySegmentIntersection`,
`getSlabHandlePolygon`, `getSlabVisualOffsets`,
`getDraftSlabVisualPolygon`, `WallMeasurementFaceContext` type,
`getWallMeasurementFaceContext`, `getAdjacentOpeningBounds`,
`getSelectedWallMeasurementOverlays`,
`getItemDimensionMeasurementOverlays`, `polygonCentroid`.
Floor-plan panel: 10,466 → 9,006 lines (-1,460). Cumulative reduction
vs `main`: 17,913 → 9,006 (-8,907 lines, **~50%**).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the post-migration cleanup. After registry-driven kinds
absorbed selection chrome, action menus, and boundary editing through
`def.floorplanAffordances`, the legacy plumbing in `floorplan-panel.tsx`
was left mounted with empty inputs — another ~4k LoC of dead code.
Removed:
- `Editor2dFloorplanActionMenuLayer` mount + its 10 `selectedX
ActionMenuPosition` useMemos. All ten action menus computed positions
from empty entry arrays / null `selectedXEntry`, so the layer never
rendered anything. `FloorplanRegistryActionMenu` is the only mount left.
- Legacy handle layers: `FloorplanWallEndpointLayer`,
`FloorplanFenceEndpointLayer`, `FloorplanWallCurveHandleLayer`, and
the four `FloorplanPolygonHandleLayer` mounts for slab / slab-hole /
ceiling / ceiling-hole — all rendered from `wallEndpointHandles` /
`slabVertexHandles` / etc. which were empty after the registry took
over endpoint, curve, and polygon affordances.
- `FloorplanZoneLabelLayer` mount + component. Zone labels are now
emitted as `kind: 'text'` from `def.floorplan` on the zone kind.
- Dead handlers: every `handle*Select`, `handle*PointerDown`,
`handle*DoubleClick`, `handle*HoverChange`, `handleFloorplan*Hover
Enter`, `handleSelected*` (Move/Delete/Duplicate/AddHole/HoleMove/
HoleDelete/Curve for all 10 legacy kinds), `duplicateSelected*`,
`handleSelectedWallCurve`, `handleSlabVertex*`/`Midpoint*`/`Edge*`/
`Hole*` (same for ceiling and zone), `handleWallEndpointPointerDown`,
`handleFenceEndpointPointerDown`, `handleWallCurvePointerDown`,
`emitFloorplanNodeClick`, `syncDeleteHoveredId`,
`handleZoneLabelClick`, `hasDuplicatableFloorplanSelection`,
`handleDuplicateFloorplanSelection`, `FloorplanDuplicateHotkey` mount.
- Dead handle data: `wallEndpointHandles`, `fenceEndpointHandles`,
`wallCurveHandles`, `canCurveSelectedWall`, and all the
slab/slab-hole/ceiling/ceiling-hole/zone vertex+midpoint+edge handle
useMemos.
- Dead selection / measurement useMemos: `selectedItemEntry`,
`selectedOpeningEntry`, `selectedSpawnEntry`, `selectedFenceEntry`,
`selectedStairEntry`, `selectedRoofEntry`, `selectedElevatorEntry`,
`selectedWallEntry`, plus `selectedItemClearanceMeasurements` and
`movingOpeningPlacementMeasurements` (both 100-200 lines of math
that consumed the empty entries).
- Two dead `<FloorplanMeasurementsLayer>` mounts (clearance / opening
placement) — both fed by useMemos that returned `[]`.
- `<FloorplanZoneLayer>` mount — `visibleZonePolygons` is always empty.
Kept:
- `siteVertexHandles` / `siteMidpointHandles` + `<FloorplanPolygonHandle
Layer>` mount for site — site is the only kind not registry-driven.
- `FloorplanStairLayer` mount (preview-only, hover/click props swapped
for noop helpers since the preview isn't interactive).
- Stub `selectedSlabEntry` / `selectedCeilingEntry` / `selectedZoneEntry`
as `null` typed values so the remaining hole-editing fallback code
compiles. Those fallbacks themselves run as no-ops now and can be
torn down in a follow-up.
Floor-plan panel: 14,395 → 10,466 lines (-3,929). Cumulative reduction
vs `main`: 17,913 → 10,466 (-7,447 lines, ~42%).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 5 / 6 migration moved 7 kinds (fence, column, spawn, item,
elevator, stair, roof) to the registry's `def.floorplan` path, but the
legacy SVG layers in `floorplan-panel.tsx` were left mounted with empty
entry arrays — dead code carrying ~3k lines of useless cost in the diff.
Removed:
- `FloorplanGeometryLayer` (~1.7k lines): inline component rendering
walls / slabs / ceilings / openings from `wallPolygons` /
`slabPolygons` / `ceilingPolygons` / `openingsPolygons` — all
permanently empty stubs after the migration. Wall / slab / ceiling
/ door / window now render via `FloorplanRegistryLayer`.
- `FloorplanFenceLayer` (~265 lines): fence entries always empty
post-migration; fence renders via the registry.
- `FloorplanElevatorLayer` (~420 lines): elevator entries always
empty post-migration.
- `FloorplanNodeLayer` (~415 lines): rendered items / spawns / stairs.
Items + spawns are registry-driven; stair only needed the in-flight
preview, which is now mounted directly via `FloorplanStairLayer`
(preserved as a sibling of the registry layer).
- `FloorplanItemImage` (~40 lines): internal helper used only by
`FloorplanNodeLayer`.
- `floorplan-roof-layer.tsx` (113 lines): roof / roof-segment now
registry-driven.
Net: -2,989 lines from `floorplan-panel.tsx`, plus the deleted roof
layer file. The remaining 14k-line monolith still owns the orchestration
state (selection lookups, marquee, helper lifecycles, hit-test plumbing)
— that's a separate teardown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs surfaced after the Stage E node-registry migration:
1. Ceiling intercepts 3D hover/click selection
Selecting via the floor-plan helper or the boundary-editor handles
is the intended flow; a direct 3D click on the ceiling should fall
through to whatever's underneath. `SelectionManager` now early-returns
on `ceiling` in onEnter/onLeave/onClick, so `event.stopPropagation`
is skipped and the ray reaches the item/wall/floor below.
2. Ceiling item placement: final click does nothing
When a ceiling-attached draft hangs in front of the ceiling-grid
mesh, the click ray hits the draft first and fires `item:click`,
not `ceiling:click`. `onItemClick` already forwards self-clicks to
shelf-surface / item-surface hosts; this PR adds the matching
ceiling branch so the commit lands on the ceiling under the cursor.
3. Floor-plan item move drift after the commit click
Two contributing causes, both fixed:
- `usePlacementCoordinator`'s `useFrame` lerped the draft mesh
toward `gridPosition.current` (the item's pre-move spot) every
frame, fighting React's render from `scene.position` while the
2D `FloorplanRegistryMoveOverlay` drove the move. Gated the lerp
on a `has3DPointerDrivenMoveRef` flag set on first 3D pointer
event — pure 3D drags are unchanged.
- The overlay's pointer-up handler skipped a final `session.apply`
and committed at the last pointermove position. Browsers don't
guarantee a pointermove right before pointerup, so a quick click
after a drag could land a few pixels off. Re-apply at pointer-up
coords so commit matches where the user actually released.
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>
Captures the gotchas surfaced while building the shelf so the next
contributor adding a registry-driven kind doesn't rediscover them.
`node-definitions.md` — new Pitfalls section + a rule that builders
must emit local-space children. Covers:
- `<GeometrySystem>` must NOT mutate `group.position` / `group.rotation`
after rebuild (the renderer binds them via JSX prop).
- Tag geometry-built children with `userData.__fromGeometry` so
rebuilds don't dispose React-mounted hosted children (the
item-disappears-on-shelf bug).
- Previews must clone materials before mutating them when the kind's
builder caches at module scope.
- Host kinds need a `children: z.array(...).default([])` field on their
schema (and a migration patch for older scenes).
`tools.md` — three new move/placement pitfalls:
- Disable raycast on the moved mesh during drag, otherwise it captures
the ray and starves `grid:move` → commits land at the stale start.
- Commit handlers listen to `grid:click` AND every `${kind}:click` to
catch clicks that land on neighbouring 3D geometry first.
- Move tools must preserve the node's actual `rotation[1]` in
`useLiveTransforms` — hardcoding 0 makes the node un-rotate mid-drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs caused floor items to disappear mid-2D-drag:
1. `startLevelId` did `parent.parentId` unconditionally for non-wall
parents — so for a floor item parented directly to the level (the
canonical convention from `use-placement-coordinator`), it returned
`level.parentId = building.id`. `findContainingSurface` then iterated
the building's children (levels, not slabs) and the fallback
`parentId: startLevelId` reparented the item to the building. Now we
walk the parent chain until we hit a `level` and short-circuit on
`parent.type === 'level'`.
2. `buildSurfaceItemSession` reparented floor items to a slab when the
cursor was over one. Slabs don't carry a `children` field on their
schema (only ceilings + level do), so `updateNodesAction`'s reparent
logic operated on `undefined.children` — the item dropped out of the
level→children DFS the floor-plan layer walks, and the polygon
stopped rendering mid-drag. Split into `buildFloorItemSession`
(always parents to the level, just updates position) and a
ceiling-only `buildSurfaceItemSession`. `findContainingSurface`
narrows to `'ceiling'` as the only valid target.
Matches the 3D `detachItemSurfaceToFloor` convention: floor items live
as level children, not slab children.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`buildZoneFloorplan` now emits a centered name label at the polygon's
area-weighted centroid (Shoelace formula, with bbox-center fallback for
degenerate rings). Label uses the legacy `FloorplanZoneLabel` styling:
`fontSize: 0.2`, white fill, zone-color stroke, `paintOrder: 'stroke'`
for the "outlined text" look that stays legible above any fill.
When the zone is selected the builder also emits the polygon editor —
edge-handle per edge, midpoint-handle per midpoint, endpoint-handle per
vertex — driven by the shared `createPolygonVertexAffordance` /
`createPolygonAddVertexAffordance` / `createPolygonMoveEdgeAffordance`
factories slabs and ceilings already use. Zones have no `holes` field
so the factory's optional `holeIndex` stays undefined and the operations
target `node.polygon` directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The slab body covered the underlying zone fill almost completely —
`opacity: 0.85` (gray) and `opacity: 0.95` (white-on-select) drowned out
any zone color sitting beneath. Switched to independent `fillOpacity`
(0.6 unselected / 0.45 selected) and `strokeOpacity` (0.85 / 0.96) so
the outline stays crisp while the zone color reads through the fill.
On the selected state the hatch overlay carries the visual weight; no
need for an opaque white background underneath.
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>
Two bugs caused dragged shelves to snap to a weird position on commit:
1. `MoveRegistryNodeTool` wrote `useLiveTransforms.set(id, { ..., rotation: 0 })`,
so during the drag `ParametricNodeRenderer` applied `<group rotation={[0,0,0]}>`
and the shelf visually un-rotated. On commit the live transform cleared
and the renderer re-read the node's true rotation — the snap-back read
as "reverts to a weird position." Now we capture `originalRotationY`
from the node at mount time and forward it on every set.
2. `<GeometrySystem>` reset `group.position.set(0,0,0)` /
`group.rotation.set(0,0,0)` after every rebuild. That was carry-over
from legacy per-kind systems that didn't bind `position` on the group.
`ParametricNodeRenderer` now drives the transform via JSX prop, and
the reset clobbered it — React doesn't necessarily re-render on a
rebuild tick, so R3F never re-applied the prop and the registered
`<group>` stayed at the origin. Removed the reset; builders are
expected to emit local-space children.
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>
Three remaining wall affordances ported into nodes/src/wall/ as
direct copies of the legacy implementations. Wall D is now complete.
- move-endpoint-tool.tsx (426 LoC) — linked-wall corner cascade +
Alt-detach + angle label. Mounted via `affordanceTools.move-endpoint`.
- move-tool.tsx (804 LoC, the most complex tool in the editor) —
center-drag with axis lock, linked-wall corner cascade via
`planWallMoveJunctions`, bridge wall ghost previews, auto-slab
live preview via `planAutoSlabsForLevel`, R/T rotation in 45°
steps, Shift to bypass grid snap, isNew metadata strip on first
commit. Mounted via `affordanceTools.move`.
- tool.tsx (332 LoC) — two-click placement with length/angle HUD,
Shift to bypass angle snap. Mounted via `def.tool`.
Editor public surface gains:
- createWallOnCurrentLevel, snapWallDraftPoint, WallPlanPoint
- MovingWallEndpoint type
ToolManager dispatch for `movingWallEndpoint` routes through the
registry with the legacy fallback (same shape as the fence
move-endpoint dispatch).
Wall is now A ✅ C ✅ D ✅. Stage B still pending (geometry depends on
level-batch miter data, blocked on `ctx.levelData` design decision).
Stage E pending (drop WallPanel — has slider drags + actions).
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 pointed out the auto-derived panel was title-only; the legacy
panels rendered a small icon in front. Legacy used a URL path (next
Image). The registry-driven path has structured `IconRef` values
(iconify / svg / lazy component) declared on `def.presentation.icon`.
- PanelWrapper's `icon` prop now accepts `string | React.ReactNode`.
String → next/image (legacy URL behavior). Node → rendered as-is.
- ParametricInspector resolves `def.presentation.icon` to a node:
iconify → `<Icon icon="lucide:fence" />`, svg → inline svg, component
→ Suspense + lazy.
Kind-owned custom panels (slab/ceiling) keep their existing legacy
URL icons since they pass `<PanelWrapper>` themselves — no change
needed there.
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>
The Stage E kind-owned panels (slab/ceiling) import lucide icons.
Without peerDependencies declaring lucide-react, consumers bundling
nodes/dist/<kind>/panel.js fail with "Module not found: Can't resolve
'lucide-react'" because the bundler has no signal that nodes needs it.
Matches the same pattern @react-three/drei + @react-three/fiber
already use here.
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>
Two regressions in the Stage D port surfaced the same gap: the wiki
already said "the mesh offset must mirror the useLiveTransforms entry"
but didn't spell out that for polygon-based kinds (slab / fence /
ceiling / wall), the value to mirror is the DELTA (the group's local
position) — not the world location of the polygon's center.
Expanded the live-drag rule with a position-based vs polygon-based
split and an explicit note that ParametricNodeRenderer reconciles the
group's position via React on every Zustand notification, so the two
writes must agree to the exact value or they fight every frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the slab/fence mesh jittered/teleported continuously
while dragging (not on commit — during the drag). Root cause: the
move tool wrote TWO conflicting values per grid:move tick:
1. `mesh.position.set(deltaX, 0, deltaZ)` — relative offset, direct
Three.js mutation.
2. `useLiveTransforms.set(id, { position: [originalCenter + deltaX,
0, originalCenter + deltaZ], rotation: 0 })` — absolute world
position of the translated polygon center.
`ParametricNodeRenderer` reads `useLiveTransforms` and binds it via
React: `<group position={liveTransform.position}>`. So every Zustand
notification re-rendered the renderer and reconciled the group's
position back to "originalCenter + delta" (the absolute), overriding
the "delta" the direct mutation had just written. The two systems
fought every frame → visible jitter.
Fix: `useLiveTransforms.position` now holds the SAME delta the direct
mutation uses (`[deltaX, 0, deltaZ]`). React reconciles to the same
value the direct mutation already set — no conflict.
The cursor sphere position stays as the translated polygon center
(it's tracked separately via React state, not `useLiveTransforms`).
Ceiling aligned for consistency, though CeilingRenderer doesn't read
`useLiveTransforms` so the value there has no rendering effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported regressions on the 1:1 legacy ports: slab/ceiling moves
were slow (polygon CSG rebuilds per scene.update tick), fence moves
teleported briefly on commit (residual mesh.position offset survived
the geometry rebuild). All three now use the same live-drag pattern
the legacy fence move was designed for:
- During drag, write only to `sceneRegistry.nodes.get(id).position`
+ `useLiveTransforms`. No `scene.update`, no polygon rebuild, no
React re-render of geometry.
- History stays UNPAUSED — scene state isn't changing.
- On commit, a single `scene.update` writes the translated
polygon (or fence start/end + linked-fence cascade). Recorded as one
natural undo step.
- Tools leave `mesh.position` at the drag delta on commit;
GeometrySystem / CeilingSystem reset it to (0,0,0) when they
rebuild the geometry on the next frame. By the time position
clears, the new geometry is in place — no teleport.
Two framework changes enable this:
- `GeometrySystem` (viewer/systems/geometry) now resets
`group.position` + `group.rotation` after every rebuild, matching
the legacy `FenceSystem.updateFenceGeometry` behavior. Tools that
translate the group during live-drag can rely on the reset.
- Legacy `CeilingSystem.updateCeilingGeometry` extends its existing
`position.y` reset to cover X/Z too — previously it left X/Z at the
drag delta after rebuild, double-translating the visual.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each tool is now a direct copy of the legacy implementation, relocated
under @pascal-app/nodes/<kind>/ and dispatched via the registry's
def.affordanceTools. No DragAction abstraction, no clever live-drag
exception, no novel snap pipeline — same code, same UX, same
performance, same history dance.
Ports:
- fence/curve-tool.tsx (legacy CurveFenceTool, 1:1)
- fence/move-tool.tsx (legacy MoveFenceTool, 1:1 — including
the mesh.position + useLiveTransforms
exception that the legacy uses for fence
specifically)
- wall/curve-tool.tsx (legacy CurveWallTool, 1:1)
- slab/move-tool.tsx (legacy MoveSlabTool, 1:1)
- ceiling/move-tool.tsx (legacy MoveCeilingTool, 1:1 — preview
fill + outline overlay preserved)
Drops the obsolete DragAction-based action files
(packages/nodes/src/{fence,wall,slab,ceiling}/actions/{curve,move}.ts)
and their now-empty actions/ directories where applicable. Fence
keeps actions/move-endpoint.ts since that port works.
Editor public surface gains `getWallGridStep` + `snapScalarToGrid`
(transitional exports — Stage F moves them into @pascal-app/nodes).
ToolManager + MoveTool dispatch unchanged: the same legacy-fallback
branches now mount the registry component because the affordances are
declared, but the rendered behavior matches the legacy because the
implementations are copies.
Per-kind progress: fence D ✅ (curve / move-endpoint / move / placement
all kind-owned), slab D ✅, ceiling D ✅, wall D 🟡 (curve only,
endpoint/move/placement still legacy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported regressions made it clear that my D-ports of the curve
and whole-item move tools introduced more friction than they removed.
The legacy CurveFenceTool / CurveWallTool / MoveFenceTool /
MoveSlabTool / MoveCeilingTool ship more polish than the new ports do
right now:
- Legacy curve tools pre-snap the pointer position to the 0.5m grid
BEFORE projecting onto the chord normal, and they Shift-toggle to
a free-place mode. The ports skipped both — finer math, but the
user-visible UX regressed (laggy because the cascade resolver fires
per move, history feels broken near the no-op threshold).
- Legacy whole-item moves use scene.update per tick which keeps
hosted children visually aligned. The live-drag mesh.position port
cleared the offset before the GeometrySystem could rebuild,
producing a one-frame teleport on commit.
Drop the affordance registrations for those tools — the ToolManager /
MoveTool dispatch falls back to the legacy per-kind tools when the
registry doesn't declare the affordance. Stage D progress preserved
for: fence move-endpoint (linked cascade + alt-detach), slab/ceiling
boundary + hole editors, fence placement, slab placement, ceiling
placement.
The kind-owned files (curve-tool.tsx, move-tool.tsx, actions/curve.ts,
actions/move.ts) stay on disk for the next iteration — when they
reach parity with legacy UX, re-add the affordance entries.
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>
Three user-reported regressions, all in the Stage D move/curve ports:
1. **Fence/wall bend cancelled the fence creation on Ctrl-Z.** The
action.commit's `return false` shortcut on "no offset change"
bypassed the dance entirely — pastStates wasn't touched, so the
next Ctrl-Z fell through to whatever preceded activation
(typically the create step). Fix: always run the dance, even on
no-op commits. First Ctrl-Z then absorbs a silent no-op entry,
subsequent presses roll back real prior actions. Same fix applied
to fence move-endpoint, fence move, slab move, ceiling move.
2. **Slab/ceiling move 'maximum update depth exceeded' loop.** The
`useScene` selector in `SlabMoveTool` returned a freshly-allocated
`[sx, sz]` tuple on every call. Zustand's `Object.is` equality
failed each comparison → re-subscribe → re-render → loop. Fix:
subscribe to the stable live-node reference and derive the center
via `useMemo`. Same recipe for fence/ceiling move-tools, including
memoizing the `originalCenter` fallback that was getting a new
array per render.
3. **No grid-snap sfx during move drag.** Action `preview` now tracks
the last snapped pointer on a mutable `lastSnapped` ctx field and
emits `sfx:grid-snap` when it changes between ticks. Matches the
legacy MoveFenceTool's per-tick sound.
Locking test for foot-gun 1 lives in
`packages/core/src/services/single-undo-dance.test.ts`.
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>
Direct copy of the fence curve recipe — pure
`curveWallDragAction` (chord-perpendicular projection + clamp +
normalize + single-undo dance) plus a thin wrapper feeding
`useDragAction`. Mounted via `def.affordanceTools.curve`.
Slight precision difference vs legacy: the legacy CurveWallTool snapped
the pointer position to `getWallGridStep()` before projecting onto the
chord normal; the ported action skips that pre-snap and relies on
`normalizeWallCurveOffset` to settle the final value. The user-visible
result is the same magnitude of step, just with the snap applied at
the offset level instead of the position level.
Remaining wall D affordances (endpoint move, whole-wall move,
placement) are larger and queued for future sessions — wall's move
tool alone is 804 LoC with the linked-wall corner-cascade logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replicates the slab Stage D recipe for ceiling: four affordances
routed through the registry — def.tool for the placement flow,
def.affordanceTools for boundary edit / hole edit / whole-ceiling move.
Ceiling-specific bits preserved:
- Placement tool keeps the dual-cursor + vertical TSL-gradient
connector + ground-shadow lines (1:1 with legacy).
- Move tool wrapper renders the translucent preview fill + outline
overlay so the user sees the destination before clicking.
ToolManager mount sites for CeilingBoundaryEditor / CeilingHoleEditor
now route through `getRegistryAffordanceTool` with legacy fallback.
Per-kind progress: ceiling A ✅ B (n/a, def.renderer escape hatch
preserved) C ✅ D ✅; E + F pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replicates the fence Stage D recipe for slab — three drag affordances
+ one placement tool, all routed through the registry:
affordanceTools:
'boundary-edit' → thin <PolygonEditor> wrapper (vertex/edge drag)
'hole-edit' → same for a single hole polygon
move → DragAction with single-undo dance
tool: () => placement (multi-click polygon with axis/45° snap)
`PolygonEditor` + `PolygonEditorProps` exported from
`@pascal-app/editor` as Stage D transitional surface (Stage F cleanup
moves them into `@pascal-app/nodes`).
ToolManager mount sites for SlabBoundaryEditor / SlabHoleEditor now
route through `getRegistryAffordanceTool` with legacy fallback.
Slab move action does not use the live-drag exception (polygon CSG
rebuild every tick) — matches legacy behavior; optimization is a
separate task once we measure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth and final Stage D fence affordance — the placement tool itself.
Unlike the drag affordances (curve / move / endpoint), placement is a
two-click flow with state across grid events, not a single drag-down →
drag-up lifecycle. `DragAction` doesn't fit; the component owns its
own emitter subscriptions directly. The kind exposes it via
`def.tool: () => import('./tool')` and ToolManager's existing
`getRegistryTool()` lookup mounts it (legacy `tools.structure.fence =
FenceTool` falls through when the registry entry is missing).
Adds transitional exports from `@pascal-app/editor` for the helpers
the kind-owned tool needs at module scope: `createFenceOnCurrentLevel`,
`markToolCancelConsumed`, `EDITOR_LAYER`. Stage F cleanup moves these
into `@pascal-app/nodes` once every consumer is registry-driven.
Fence Stage D is now complete. Per-kind progress: A ✅ B ✅ C ✅ D ✅
(four affordances ported — curve, move-endpoint, move, placement).
E (drop legacy panel) and F (cleanup) pending across all kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third Stage D affordance port (302 LoC legacy → action + thin wrapper).
The action (`actions/move.ts`) uses the live-drag exception documented
in editor/wiki/architecture/tools.md: visual-only updates via
`sceneRegistry.nodes.get(id).position` + `useLiveTransforms` during
the drag, no scene mutations. Avoids re-rebuilding the fence geometry
(many posts + infill panels) every pointer tick. Commit performs the
single-undo dance — writes final start/end to scene, geometry rebuilds
once, Ctrl-Z reverses the whole drag.
Linked-fence cascade follows the same shape as MoveFenceEndpoint —
any fence in the same parent that shared an endpoint at activation
moves with the drag.
`getRegistryAffordanceTool` extracted to
`tools/shared/affordance-dispatch.ts` so move-tool.tsx and
tool-manager.tsx share the lazy-load helper (no duplicate caches).
MoveTool dispatch gains a generic `affordanceTools.move` check after
the capability-driven `movable` shortcut — fence routes through here;
wall / slab / etc. fall through to their legacy per-kind chain until
their D ports land.
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 commit() just returned true, relying on
createDragSession.terminate() to resumeHistory. But that alone never
captures the drag in zundo's pastStates — the pause window's mutations
are skipped entirely. After commit, Ctrl-Z jumped past the curve *and*
past the prior fence creation.
Adds the same dance now used by move-endpoint: restoreAll →
resumeHistory → re-apply the final draft. Zundo records one undo step
for the whole drag. cancel() becomes a no-op (orchestrator's
restoreAll covers it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second Stage D affordance port (425 LoC legacy → ~430 split across
action + wrapper). All math (snap, linked-fence cascade, alt-detach,
min-length gate, single-undo dance) lives in the pure
`moveFenceEndpointDragAction`. The React wrapper handles the UI
overlays (cursor sphere, Drag/Detach badge, angle label) and the live
state subscriptions.
The action introduces the single-undo dance pattern for multi-write
commits: `commit()` calls `scene.restoreAll()` → `resumeHistory()` →
re-applies the final draft so zundo records the entire drag as one
undo step. Reusable shape for slab/wall/door endpoint ports.
Transitional exports added to `@pascal-app/editor`'s public surface
(`snapFenceDraftPoint`, `isWallLongEnough`, the segment-angle helpers,
`MovingFenceEndpoint`). Stage F cleanup moves these into
`@pascal-app/nodes` once every consumer is registry-driven.
`getRegistryAffordanceTool` is now generic (`ComponentType<any>`) so
affordances with different prop shapes (`{ node }`, `{ target }`, …)
all dispatch through the same helper.
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>
The legacy CurveFenceTool ignored grid:click for 150ms after mount —
otherwise the very click that activates a tool (e.g. the floating menu
"curve" button) cascades through the R3F drei <Html> portal into the
grid, fires grid:click on the just-mounted tool, and commits the drag
before any preview move runs. The new useDragAction was missing this
guard, so the Stage D fence curve port "click → place sfx → exit"
without ever letting the user adjust.
Adds `activationGraceMs` (default 150) on useDragAction; ports the
sfx:item-place commit emission into FenceCurveTool so the kind-owned
tool matches legacy UX. Same guard will cover the upcoming Stage D
ports (endpoint move, whole-fence move, placement, plus
slab/ceiling/wall D).
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>
Shelf clicks in 3D weren't selecting: getSelectionTarget routed shelf
(category='furnish') to the furnish phase, but furnish.isValid hard-
coded `node.type !== 'item'` and rejected shelf. Click switched phase,
nothing selected.
Fix: extend furnish.isValid to also accept registry-driven kinds whose
def.category === 'furnish' AND def.capabilities.selectable. Item's
asset.category door/window special-case stays first.
Future furnish-category kinds (tables, lamps, etc.) are selectable in
furnish phase without further changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Item is the last Stage A-only kind to gain def.floorplan. Closes Stage C
across every registered kind.
Files added:
- nodes/src/item/floorplan.ts: buildItemFloorplan inlines a self-
contained parent-chain transform walker using `ctx.resolve`. Mirrors
the legacy `getItemFloorplanTransform` math from editor/lib/floorplan/
items.ts:
* Wall parent: rotate item.position by wall's angle, anchor at
wall.start, handle wall-side attachTo via wall.thickness offset.
* Item parent (nested): recurse for parent's transform.
* Level / slab / ceiling parent: item.position is level-local.
Returns a rotated width × depth rectangle. asset.floorPlanUrl image
overlay deferred for Phase 5 follow-up.
Files changed:
- nodes/src/item/definition.ts: wires `floorplan: buildItemFloorplan`.
- floorplan-panel.tsx: floorplanItemEntries useMemo short-circuits to
[] when nodeRegistry.has('item'). Phase 6 deletes the entire useMemo.
Stage C coverage (all 9 registered kinds):
shelf ✅ spawn ✅ fence ✅ slab ✅ ceiling ✅ wall ✅ door ✅ window ✅ item ✅
Next sessions: Stage B for door / window / wall (each large geometry
extraction), Stage D per kind (DragAction affordance ports), Stage E
(drop legacy panels), Phase 6 Stage F cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three remaining kinds at Stage C this session:
wall → C
- buildWallFloorplan: uses ctx.siblings to gather other walls in the
level, runs calculateLevelMiters, computes plan footprint via
getWallPlanFootprint. Same visual output as legacy.
- getFloorplanWall thickness exaggeration inlined (~25 lines from
editor/lib/floorplan/walls.ts) to keep nodes/wall self-contained.
- floorplan-panel.tsx's wallPolygons short-circuits to [] when wall
is registered.
- Performance note: recomputes level miter data per wall (O(N²) for N
walls in a level). Acceptable for typical scenes; ctx.levelData?.
miters optimization deferred to Stage B's wall design pass.
door → C
- buildDoorFloorplan: inlines getOpeningFootprint math from
floorplan-panel.tsx (40 lines, pure math). Uses ctx.parent as the
wall to compute direction + perpendicular for the cutout footprint.
- Returns null when parent isn't a wall (orphaned doors during
placement).
window → C
- buildWindowFloorplan: same shape as door, glass-blue tint to
distinguish visually.
Both share the legacy openingsPolygons gating:
- floorplan-panel.tsx's openingsPolygons useMemo filters per kind so
a partial migration still works (e.g., if only door registers, only
doors get skipped). When both registered, returns [] entirely.
Item C intentionally deferred — needs parent-chain transform helpers
(buildFloorplanItemEntry / getItemFloorplanTransform from editor/lib/
floorplan/items.ts) exposed publicly or moved into core. A focused
session is the right place to design that boundary.
Stage B for door / window / wall still pending — each is a focused
session per kind (large geometry math extractions, wall needs ctx.
levelData design).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Depth-first session: drive registered kinds through Stage B (pure
def.geometry, drop system re-export) and Stage C (def.floorplan,
short-circuit legacy inline rendering in floorplan-panel.tsx).
spawn → C
- buildSpawnFloorplan wired on definition (was written but deferred
to avoid double-render).
- floorplan-panel.tsx's floorplanSpawnEntries useMemo short-circuits
to [] when nodeRegistry.has('spawn').
fence → B
- generateFenceGeometry exported from viewer; buildFenceGeometry
wraps it in a Group+Mesh with DEFAULT_STAIR_MATERIAL.
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/fence/{renderer.tsx,system.tsx}.
fence → C
- buildFenceFloorplan: polyline along centerline (sampled for curved
fences via sampleWallCenterline from core). Stroke width = node.thickness.
- floorplan-panel.tsx's floorplanFenceEntries short-circuits.
slab → B
- generateSlabGeometry exported from viewer; buildSlabGeometry wraps
it in a Group+Mesh + cached material (preset / custom / default
pattern preserved from legacy renderer).
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/slab/{renderer.tsx,system.tsx}.
slab → C
- buildSlabFloorplan: SVG path with outer polygon + hole subpaths
(uses getRenderableSlabPolygon from core for wall-clipping parity).
- floorplan-panel.tsx's slabPolygons short-circuits.
ceiling → B INTENTIONALLY SKIPPED
- Ceiling renderer renders React children (hosted items) + uses TSL
shader materials + named meshes that other systems poke
(getObjectByName('ceiling-grid')). Pure def.geometry can't preserve
that. Ceiling keeps def.renderer (the custom escape hatch) — same
pattern item uses. Documented in ceiling/definition.ts.
ceiling → C
- buildCeilingFloorplan: dashed-outline path with hole subpaths
(visually distinct from slab since ceilings are above).
- floorplan-panel.tsx's ceilingPolygons short-circuits.
Per-kind progress after this session:
- shelf: B ✅ C ✅ (Stage E since brand-new)
- spawn: A ✅ C ✅
- wall: A ✅ (B blocked on ctx.levelData design)
- fence: A ✅ B ✅ C ✅
- slab: A ✅ B ✅ C ✅
- ceiling: A ✅ C ✅ (B intentionally not applicable)
- door / window / item: A ✅ (B+C pending in future sessions)
Known test issue: `bun test` in packages/nodes fails to load
`three-bvh-csg` through the viewer's transitive imports (UMD/ESM
mismatch in Bun's test runner). The Next.js editor build works fine
because it bundles differently. Fix requires either dynamic imports
(breaks sync def.geometry contract) or test env config — deferred.
Other tests (schema, geometry, parity) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: items needed a double click to select after the item kind
registered (Phase 5). Root cause: `getSelectionTarget` checked
`isRegistrySelectable(node.type)` as part of the FIRST branch (which
routes to structure phase), matching `item` before the item-specific
branch below could route door/window-category items to structure +
everything else to furnish.
Effect: clicking an item triggered phase switch (structure ← furnish),
then the next click selected. Hence the double click.
Fix:
1. Item-specific case moved to the TOP of getSelectionTarget. Its
asset.category-driven routing (door/window items → structure;
everything else → furnish) beats any generic registry fallback.
2. Generic registry fallback at the bottom now reads `def.category`
to pick the phase — `category: 'furnish'` → furnish phase,
everything else → structure/elements. Future furnish-category
kinds (only shelf right now) route correctly without a special
case.
3. `isRegistrySelectable(node.type)` clause removed from the
structure branch — replaced by the def.category check at the
bottom.
Net: single-click selection works for items again, and the routing
logic is now cleanly capability/category-driven instead of "all
registered kinds → structure" which was a Stage A simplification
that broke as soon as a furnish-category kind registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Item is the first kind to use the `def.renderer` escape hatch (custom
React component with `useGLTF` + drei + interactive widgets) — not
expressible as a pure `def.geometry`. Catalog-backed + multi-host
(free / wall / wall-side / ceiling).
Files added (packages/nodes/src/item/):
- schema.ts: re-exports ItemNode from core.
- parametrics.ts: empty groups[]. Item parametrics come from the
asset's catalog-defined interactive controls (toggles / sliders /
temperature) — too dynamic for the auto-inspector at Stage A.
Legacy ItemPanel renders the catalog-driven controls; Phase 5
Stage E will likely use parametrics.customPanel.
- definition.ts: capabilities (no `movable` — item move is bespoke
MoveItemContent that handles attachTo transitions floor↔wall↔
ceiling mid-drag; capability-driven dispatch keeps legacy mover),
parametrics, renderer (wrap-export of ItemRenderer), system
bundling ItemSystem + ItemLightSystem, toolHints matching the
user's screenshot (Place item / R rotate ccw / T rotate cw /
Shift free place / Esc cancel). defaults() casts an object
literal with a stub asset since asset is required by the schema;
createNode re-parses through ItemNode at runtime.
- renderer.tsx: wrap-export of legacy ItemRenderer (~280 lines with
useGLTF + interactive widgets — too much to duplicate at Stage A).
- system.tsx: bundles ItemSystem + ItemLightSystem.
- index.ts: barrel.
Files changed:
- packages/viewer/src/index.ts: new public exports for ItemRenderer,
ItemSystem, ItemLightSystem.
- packages/nodes/src/index.ts: appends itemDefinition.
- packages/editor/src/components/ui/panels/item-panel.tsx: panel
slider-drag fix recipe applied (nodeRef pattern, drop subscribed
updateNode dep, drop node from useCallback deps). Item panel has
scale + position + rotation sliders all subject to the cascade.
Item is the registry's stress test for `def.renderer` escape hatch.
GLB loading via useGLTF + drei works as-is; nothing in the registry
forces a pure-geometry shape on kinds that don't fit.
Phase 5 progress: shelf ✅ spawn ✅ wall ✅ fence ✅ slab ✅ ceiling ✅
door ✅ window ✅ item ✅. Nine kinds on the registry. Stair / roof /
zone / containers remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both kinds share traits — hosted on walls, cuttable, animated open/
close state via a geometry system + animation system. Stage A
migration: register + wrap-export the legacy renderer + bundle both
per-kind systems. Pure geometry + floor-plan ports are later
milestones.
Files added (packages/nodes/src/door/, packages/nodes/src/window/):
- schema.ts: re-export from core.
- parametrics.ts: minimal — dimensions only. Door has 29 sliders +
segmented controls + presets in its legacy panel; window has 15+
sliders. Auto-inspector can't cover them at Stage A — legacy
panel keeps rendering via panel-manager.tsx case fall-through.
Stage E may extend parametrics or use parametrics.customPanel
escape hatch.
- definition.ts: capabilities (no `movable` — wall-bound drag is
bespoke; capability-driven dispatch keeps legacy MoveDoorTool /
MoveWindowTool), parametrics, renderer, system. defaults() uses
`DoorNode.parse({...stub})` to leverage zod's schema-level
`.default()` annotations — door has 40+ fields, window has 20+;
listing them inline duplicates the schema.
- renderer.tsx: wrap-export of legacy DoorRenderer / WindowRenderer
(thin 33-36 lines each).
- system.tsx: bundles each kind's TWO systems — DoorSystem +
DoorAnimationSystem, WindowSystem + WindowAnimationSystem. Both
per-kind systems mount via RegisteredSystems when the kind is
registry-driven; `<LegacySystem kind="door|window">` wrappers
around each individual system short-circuit.
- index.ts: barrel.
Files changed:
- packages/viewer/src/index.ts: new public exports for DoorRenderer,
DoorSystem, DoorAnimationSystem, WindowRenderer, WindowSystem,
WindowAnimationSystem.
- packages/nodes/src/index.ts: appends doorDefinition + windowDefinition.
- packages/editor/src/components/ui/panels/door-panel.tsx + window-
panel.tsx: panel slider-drag fix recipe applied. Drop the
subscribed `updateNode` action, drop the `node` dep from
handleUpdate / previewDoorUpdate / commitDoorPreview useCallbacks.
Use useScene.getState() inside. Door panel has 29 SliderControls,
window 15+ — both at high risk of the Maximum update depth
cascade without the fix.
Phase 5 progress: shelf ✅, spawn ✅, wall ✅, fence ✅, slab ✅, ceiling ✅,
door ✅, window ✅. Eight kinds on the registry. Item / stair / roof /
zone / containers remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as fence — thin renderer + system re-export, capabilities
declared, panel slider-drag fix recipe applied. Pure geometry +
floor-plan ports are later milestones.
Files added (packages/nodes/src/slab/):
- schema.ts: re-exports SlabNode from core.
- parametrics.ts: elevation slider only. Polygon + holes edited
via floor-plan boundary / hole editors, not number inputs.
- definition.ts: capabilities (no `movable` — slab move is bespoke
whole-translation through MoveSlabTool that integrates with the
boundary editor; capability-driven dispatch keeps the legacy mover),
surfaces.top with elevation-as-height for stacked items, relations
(hosts: ['item'], cascadeDelete: 'descendants'), toolHints (trace /
finish / cancel for the placement tool).
- renderer.tsx: thin placeholder mesh + markDirty on mount + node
events + cached material via the same getSlabMaterial pattern as
the legacy renderer (preset apply on shared material instance).
- system.tsx: re-exports the legacy SlabSystem from viewer.
- index.ts: barrel.
Files changed:
- packages/viewer/src/index.ts: exports SlabSystem (already had
DEFAULT_SLAB_MATERIAL, applyMaterialPresetToMaterials, createMaterial
from earlier exports).
- packages/nodes/src/index.ts: appends slabDefinition unconditionally
to builtinPlugin.nodes.
- packages/editor/src/components/ui/panels/slab-panel.tsx: applied
the panel slider-drag fix recipe from plans/editor-node-registry.md
prophylactically (nodeRef pattern, useScene.getState().updateNode
inside handler, drop subscribed updateNode dep). Slab's elevation
slider is the only drag-driven control in the panel — would have
triggered the same Maximum update depth cascade as wall/fence.
No behavior change. Slab now mounts via the registry path, but the
legacy SlabSystem still does the actual polygon triangulation + hole
CSG work (re-exported, not duplicated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: after wall registered, the move tool's smart sims-style
arrow UX (endpoint handles + linked-wall corner cascade + ALT-detach)
was replaced by a generic whole-wall-translate. The dispatch was
unconditionally routing every registered kind through
MoveRegistryNodeTool — but MoveRegistryNodeTool is for kinds whose
move semantics are "translate position on X/Z plane" (shelf, spawn,
single-position items). Wall / fence / slab / stair endpoint drags
are bespoke and need their legacy movers until each gets a proper
DragAction-based affordance port.
Fix: gate the registry-mover dispatch on `def.capabilities.movable`.
When a kind opts in (`movable: { axes, gridSnap }`), use the generic
mover; when a kind omits the capability deliberately (wall and fence
do), fall through to the legacy per-kind branch below.
This is the registry-aware analogue of "the registry doesn't limit
custom behavior — it lets kinds opt in to generic dispatch". Adding
`movable` is an opt-in; omitting it is an opt-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: dragging the Length slider on a fence triggered
"Maximum update depth exceeded" in updateNodesAction.
Root cause: the panels' `handleUpdate` / `handleUpdateLength`
useCallback deps included the subscribed `node` and `updateNode`
references. On every store tick during slider drag (one per
pointermove), Zustand notified subscribers → panel re-rendered →
new `node` ref → new handler refs → SliderControl re-rendered with
new onChange prop → its `useCallback([..., onChange])` for
handleLabelPointerMove rebuilt while pointer capture was active.
Combined with float drift in `getWallCurveLength` recomputing per
render, React eventually flagged the cascade as a
componentWillUpdate / componentDidUpdate loop.
Fix:
- Mirror `node` into a `nodeRef` updated on every render. Handlers
read from `nodeRef.current` instead of closing over `node`.
- Drop the subscribed `updateNode` dep: use `useScene.getState().
updateNode(...)` inside the handler. Same pattern ParametricInspector
already uses for its registry-driven inspector.
- Drop the now-redundant `useScene.getState().dirtyNodes.add(id)`
call — updateNode's RAF markDirty already covers it.
Net effect: handler refs are stable across slider drags (only change
when `selectedId` changes). SliderControl's pointer listeners no longer
churn mid-drag. Cycle broken.
Same fix applied to wall-panel.tsx prophylactically — it has the
identical pattern and would exhibit the same loop under the right
float-drift / drag conditions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parity comparison against deployed prod is now cleaner than juggling
env-var flag toggles locally. Both kinds enter builtinPlugin.nodes
unconditionally; the Phase 0 dispatch shims (<LegacySystem kind="X">
wrappers + NodeRenderer's registry-first branch) handle the cutover.
Files deleted:
- packages/nodes/src/wall/feature-flag.ts
- packages/nodes/src/fence/feature-flag.ts
Files changed:
- packages/nodes/src/index.ts: drops isWallRegistryEnabled /
isFenceRegistryEnabled gates; wallDefinition + fenceDefinition
land directly in builtinPlugin.nodes.
- packages/nodes/src/{wall,fence}/index.ts: drop the flag re-export.
- packages/nodes/src/{wall,fence}/renderer.tsx: drop the one-shot
verification console.info. Same for the system.tsx wrappers.
- packages/viewer/src/components/renderers/{wall,fence}/{wall,fence}-
renderer.tsx: drop the paired [X:legacy] verification logs (no
longer comparing flag-toggled paths).
Net DX: no env var to remember when starting `bun dev:community`. To
A/B test, compare against editor.pascal.app deployed prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same shape as wall milestone B — thin renderer + system re-export, no
geometry / floor-plan / tool ports yet (later milestones). Feature flag
NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE gates the dispatch flip.
Files added (packages/nodes/src/fence/):
- schema.ts: re-exports FenceNode from core.
- parametrics.ts: dimensions / posts / style fields for the auto-
inspector. Endpoints + curveOffset edited via tools, not in
parametrics.
- feature-flag.ts: mirrors the wall flag pattern.
- definition.ts: capabilities (snappable + surfaces sides +
selectable + duplicable + deletable), relations (linkedBy
endpoint-match, no hosts, no affectsSpatial — matches legacy),
parametrics, renderer, system, toolHints (Left click / Shift /
Esc — fence has no helper file today so this adds a panel where
there wasn't one). Tool field absent: fence has 4 tools (build,
curve, move, move-endpoint) wired through editor state, not the
registry dispatch — they keep running unchanged.
- renderer.tsx: thin placeholder mesh + markDirty on mount + node
events + DEFAULT_STAIR_MATERIAL (matches legacy material reuse).
Verification log fires once on first mount.
- system.tsx: re-exports the legacy FenceSystem from viewer.
Verification log on mount/unmount confirms the bundle activates.
- index.ts: barrel.
Files changed:
- packages/viewer/src/index.ts: new exports for FenceSystem and
DEFAULT_STAIR_MATERIAL so the @pascal-app/nodes bundle can
compose them without reaching into viewer internals.
- packages/viewer/src/components/renderers/fence/fence-renderer.tsx:
paired one-shot legacy verification log so the dispatch path is
unambiguous from the browser console.
- packages/nodes/src/index.ts: conditional fenceEntries appended to
builtinPlugin.nodes based on isFenceRegistryEnabled. With the flag
off (default), behavior is unchanged; with it on, Phase 0 shims
switch fence to the registry path — legacy <FenceRenderer> and
<LegacySystem kind="fence"><FenceSystem /></LegacySystem> short-
circuit, the bundled system.tsx re-mounts FenceSystem via
RegisteredSystems, and the new renderer takes over the dispatch.
No behavior change with the flag off. With it on, behavior should be
byte-identical (same FenceSystem code, same priority, same geometry
path).
Phase 5 batch order continues with slab / ceiling / door / window /
item / etc. as flagged migrations after fence parity signs off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four issues in one pass:
1. Move snaps to grid
MoveOverlay's cursor is now snapped via snapPointToGrid([m.x, m.y],
GRID_STEP=0.5). Matches the 3D shelf tool's placement step so 2D
and 3D placement feel identical.
2. Move translates the actual rendered SVG, not a ghost
MoveOverlay no longer portals a 50%-opacity ghost. Instead it finds
the rendered [data-node-id] <g> inside the floor-plan scene and sets
its `transform` attribute imperatively each pointermove. The inner
group's translate(px pz) rotate(deg) stays untouched — the outer
transform composes as a pure delta. Same "smooth move" pattern as
the 3D MoveRegistryNodeTool: no React re-renders, no zundo bloat,
the actual shape follows the cursor with full fidelity.
3. Click commits the position (previously did nothing)
Switched from `window click` (with capture + composedPath check)
to `window pointerup`. Pointerup fires reliably regardless of
click-vs-drag semantics in the floor-plan panel's pointer-down
handlers (which can preventDefault on certain modes and suppress
the synthesized click). Target check uses
`target.closest('[data-floorplan-scene]')` instead of composedPath
for cross-browser SVG reliability.
4. Clicking in floor plan with shelf tool active creates a shelf
Root cause: `isFloorplanGridInteractionActive` is a hardcoded OR of
build/move modes that doesn't include registry kinds, so the panel
never emits `grid:click` / `grid:move` for them. Shelf tool listens
on those events; without them, clicks were silently dropped.
Fix: new `isRegistryToolBuildActive` derived from
`mode === 'build' && tool != null && nodeRegistry.has(tool)` — added
to the OR chain. Future Phase 5 kinds (fence, item, etc.) inherit
floor-plan placement automatically the moment they register a tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new files in packages/editor/src/components/editor-2d/ keep the
work out of the 18k-line floorplan-panel.tsx monolith. The panel
itself gets only four tiny additions (two imports, two component
mounts, one data attribute).
<FloorplanRegistryActionMenu>
- Reads useViewer.selection — when a registered kind is selected and
we're not in a move state, queries the rendered [data-node-id] <g>
for its bounding rect (polled via rAF for pan/zoom/move reactivity).
- Portals an HTML overlay above the bounding box with the existing
<NodeActionMenu>. Buttons gated by def.capabilities:
* Move → setMovingNode(node)
* Duplicate → structuredClone + schema.parse + createNode + set
movingNode (placement cursor) — matches 3D duplicate UX.
* Delete → deleteNode(id) + clear selection.
- Same visual styling as the legacy <FloorplanActionMenuLayer> per
kind, but driven by registry data.
<FloorplanRegistryMoveOverlay>
- Activates when useEditor.movingNode is a kind with def.floorplan.
- Listens on window for pointermove (to track cursor in floor plan
meters via the scene <g>'s getScreenCTM — matches the legacy
getSvgPointFromClientPoint coordinate path so cursor → meters
accounts for pan/zoom/building rotation).
- Renders a 50%-opacity ghost via portal into the floor-plan scene
<g>. Builder reused from def.floorplan — no per-kind ghost code.
- Click commits via updateNode({ position: [cx, oldY, cz] }) and
clears movingNode. Clears `isNew` metadata on duplicates so they
don't loop. Esc cancels.
floorplan-panel.tsx touches:
- Two imports (action menu + move overlay).
- data-floorplan-scene="" attribute on the floorplanSceneRef <g>
so the overlay can find the scene without sharing a ref.
- <FloorplanRegistryActionMenu /> mounted alongside the legacy
action menu layer.
- <FloorplanRegistryMoveOverlay /> mounted inside the SVG tree
alongside the registry render layer.
FloorplanRegistryLayer: also stopPropagation on click events so the
outer SVG's onClick={handleBackgroundClick} doesn't deselect right
after our pointerDown sets selection. Fixes "click-in-2D doesn't
select" bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drag-with-grab-cursor model was wrong design — 3D doesn't drag,
it uses select → inspector "Move" button → click-to-place. Floor
plan should match. Drag also had a coord-conversion bug (used outer
<svg> CTM instead of the floorplanSceneRef <g>, so screen→meter
conversion didn't account for the floor plan's pan/zoom/building-
rotation transforms).
Strips the drag pointerdown/move/up handlers, the imperative
transform override, the temporal pause bracketing, and the global
window listeners. Cursor goes back to 'pointer'. Only click-to-
select remains.
The right pattern (move via inspector / action menu + cursor-driven
placement) needs:
- Registry-aware FloorplanActionMenuLayer path
- Generic movingNode handler in floor-plan-panel for any registered
kind with capabilities.movable
- Shared floorplanSceneRef for accurate coord conversion
Both flagged in the plan as Phase 4 follow-on gaps with their
acceptance criteria. 3D-realtime-sync-while-moving is documented as
deferred (legacy doesn't do it for any kind either; design + ship
in a dedicated PR later).
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>
Endpoint drags fire markDirty(wallId) on every pointermove tick. The
old behavior rebuilt the dragged wall AND every wall sharing a junction
on every tick — in a 4-corner room with doors, that's 4× the CSG
+miter pass per tick. Visible as drag lag.
New behavior: the dragged wall rebuilds every tick (so the drag tracks
the cursor with full fidelity, cutouts and all). Adjacent walls are
queued in pendingAdjacentByLevel and rebuilt on the trailing edge —
80ms after the dirty stream stops. The corners snap into their correct
miter joins ~80ms after release, which is the standard CAD-app
"rubber-band the dragged element, fix neighbors on commit" pattern.
Module-level singleton state for the queue + timestamp — WallSystem is
mounted exactly once globally, so module state is the right scope.
Expected speedup:
- t-junction drag: ~3× (was 3 walls/tick, now 1)
- 4-corner room with door per wall: ~4×
The trailing flush condition (!hasDirtyWalls && now - lastWallDirtyAtMs
>= DRAG_FLUSH_MS) means single edits (non-drag) pay an 80ms latency
before neighbors miter correctly. Acceptable for now; the real fix is
the affordance/tool port (Milestone C) which will explicitly signal
"drag in progress" so we can drop the heuristic. Until then this is a
substantial drag-perf win for zero risk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three one-shot console.info calls so the Phase 3 milestone-B parity
check is unambiguous from the browser console alone:
- [wall:registry] system bundle mounted — fires when RegisteredSystems
lazy-loads nodes/src/wall/system.tsx (exactly once per viewer mount
when the flag is on).
- [wall:registry] first WallRenderer mounted — fires once when the
first registry-driven WallRenderer mounts.
- [wall:legacy] first legacy WallRenderer mounted — fires once if the
legacy path is active (flag off, or kind not registered).
Module-level booleans gate the renderer logs so they don't spam in
scenes with many walls. Drop all three alongside the feature flag at
Phase 3 sign-off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the wall kind onto the registry path when
NEXT_PUBLIC_USE_REGISTRY_FOR_WALL=true; default-off keeps wall on its
legacy path unchanged.
Files added:
- nodes/src/wall/renderer.tsx — thin placeholder-mesh mount point.
Identical pattern to the legacy WallRenderer: registers ref via
useRegistry, marks dirty on mount, renders hosted children
recursively via NodeRenderer. The legacy WallSystem fills geometry
on the next frame regardless of which mount path is active.
- nodes/src/wall/system.tsx — a bundle component that renders
<WallSystem /> + <WallCutout /> (both re-exported from viewer).
Registered via def.system with priority 4 to mirror the legacy
WallSystem's useFrame priority. Zero logic duplication — the
~970 lines of CSG/mitering/cutaway code stays in viewer.
Files changed:
- packages/viewer/src/index.ts — new exports for WallSystem, WallCutout,
and NodeRenderer. The first two so the registry-driven system bundle
can compose them; NodeRenderer so any parent kind (wall, slab,
ceiling, building) can recursively render hosted children without
reaching into viewer internals.
- nodes/src/wall/definition.ts — adds renderer + system fields. Tool
field stays absent (wall placement / endpoint drag remain bespoke
for now; the affordance port is a later milestone).
- nodes/src/index.ts — conditionally appends wallDefinition to
builtinPlugin.nodes based on isWallRegistryEnabled(). With the flag
off, the array is identical to before this commit; with it on,
Phase 0 dispatch shims switch wall to the registry path:
* <LegacySystem kind="wall"> around WallSystem returns null
* <LegacySystem kind="wall"> around WallCutout returns null
* <NodeRenderer> takes the registry-first branch and mounts the
new renderer instead of the legacy switch case for 'wall'
* RegisteredSystems mounts the new system bundle, which re-mounts
the same WallSystem + WallCutout components from viewer
No behavior change with the flag off. With the flag on, behavior should
be byte-identical (same components, same priority, same geometry path).
Manual verification next: place walls, t-junctions, walls-with-doors
with the flag toggled both ways; confirm visual + interactive parity.
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>
New page covering the geometry/renderer/system trio that registry-driven
kinds opt into. Documents:
- The three optional fields on NodeDefinition and when each applies
- Generic <GeometrySystem> + <ParametricNodeRenderer> runtime
- GeometryContext shape (resolve / children / siblings / parent)
- Combination matrix for shelf / spawn / zone / door / window / GLB items
- Migration recipe from custom renderer+system files to def.geometry
- Rules around purity, dispose-on-rebuild, register-once
renderers.md and systems.md gain "prefer registry-driven" banners and
link out to the new page. Architecture README adds the page to the
index so review-architecture skill picks it up.
The pattern was validated by the shelf spike: inline-JSX geometry was
visibly laggy on parametric edits; moving to a per-kind system reading
dirtyNodes (mirroring door/wall/item) restored smoothness. The three-
checkbox model generalises that win so most future kinds need only a
pure geometry function.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follows the renderer/system split documented in wiki/architecture/
renderers.md and systems.md: the renderer must not run geometry
generation. Mirrors the door-renderer/door-system pattern.
- New ShelfSystem reads dirtyNodes in useFrame, retrieves the shelf's
registered Group from sceneRegistry, swaps its children with the
output of buildShelfGeometry(node), then clears the dirty flag.
Geometry rebuild is fully imperative — no React work involved.
- ShelfRenderer is now a thin empty <group> that registers with
sceneRegistry, marks the node dirty on mount, and carries the
pointer-event handlers + live transform overrides at the root.
- Wired system into shelfDefinition so RegisteredSystems mounts it
alongside the renderer.
Net effect: dragging shelf parametric sliders no longer re-renders
the renderer per tick — the system rebuilds meshes at frame cadence
based on dirtyNodes, the inspector's per-field subscription only
re-renders the dragged field, and the rest of the React tree stays
quiet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscribe to node.type at the top and to each field's value individually
inside FieldRenderer. Slider drags previously re-rendered the entire
inspector + every field every tick because the panel subscribed to the
whole node object (which gets a new reference on every updateNode).
Primitive field values stay === equal across unrelated mutations, so
now only the dragged field re-renders.
Handlers (move/delete/update) use useScene.getState() inside callbacks
instead of subscribing — they only need the current value, not a
reactive read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of Phase 4 work pulled in: an inspector that reads any
registered node's `parametrics` descriptor and renders the right-side
panel automatically. Verifies the parametric descriptor design end-to-end
on shelf and proves the loop (descriptor → UI → store update → render).
ParametricInspector component:
- Reads `nodeRegistry.get(node.type)?.parametrics`.
- Renders one `<PanelSection>` per group, one control per field.
- Field kinds supported in v1:
* `number` → SliderControl with min/max/step/unit from descriptor
* `enum` → dark-themed <select> with prettified labels
* `color` → native color picker + hex input pair
* `vec3` → 3× SliderControl (X / Y / Z)
- Honors `field.visibleIf(node)` to gate conditional fields.
- Generic Actions footer with Move / Delete, gated on
`capabilities.movable` / `capabilities.deletable !== false`.
- Title from `presentation.label`, defaults to `node.type`.
Wired as the `default:` arm of panel-manager.tsx's switch — registered
kinds without a hardcoded case (shelf, future kinds) get the auto-derived
panel. Spawn keeps its hand-written panel (the legacy switch catches
it first); we'll switch to registry-first dispatch when Phase 4 finishes
and the hand-written panels can be deleted.
What you can verify after this:
- Click a shelf → right panel shows Dimensions (width/depth/thickness/
height sliders with units + bounds from the schema) + Style (bracket
style select + color picker) + Actions (Move / Delete).
- Drag a slider → mesh updates live (store update → renderer re-renders).
- Try to set width > 3.0 — schema rejects, no update fires (the
parametric bounds are enforced by Zod, same source of truth as MCP
bound generation in Phase 4's MCP work).
Not in scope for this commit:
- `parametrics.customPanel?` escape hatch.
- `material` / `ref` field kinds.
- `invariants` validation feedback in the UI.
- Migration of legacy panels (spawn/column/etc.) to the auto-generated
path — those keep working unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shim ordering bug: the `nodeRegistry.has(movingNode.type)` check sat at
the END of the dispatch chain, AFTER the per-kind `if (movingNode.type
=== 'spawn') return <MoveSpawnTool>` branches. Spawn (now registered
in builtinPlugin) was therefore still routing to the legacy
MoveSpawnTool — which uses the broken useLiveTransforms pattern and
makes the spawn mesh disappear during drag.
Moved the registry check to the TOP, matching the registry-first
dispatch model the Phase 0 shims use everywhere else (NodeRenderer,
ToolManager, system guards). Now any kind registered via
@pascal-app/nodes routes to MoveRegistryNodeTool — same smooth
imperative drag for shelf, spawn, and every future kind. Legacy
per-kind movers below run only for kinds not yet in the registry.
This is exactly how the Phase 5 progressive consolidation works: as
kinds migrate to the registry, their legacy movers stop being reached
and can be deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: updating the store per tick caused tons of React
re-renders → laggy drag. Switched to pure imperative.
MoveRegistryNodeTool now:
- Mutates `sceneRegistry.nodes.get(id).position` directly per
grid:move tick. No useScene.updateNode during drag. No store
change → no renderer re-render → R3F doesn't reapply
`position={node.position}` → the imperative mutation sticks.
- On commit: single tracked `useScene.updateNode(id, { position })`.
Undo replays one step (original → final), no per-tick spam.
- On cancel / unmount: imperatively snap the mesh back to original.
Store was never touched so no data revert needed.
Trade-off vs the items pattern (which does update the store per tick
and re-renders per tick): our approach is faster but assumes the
renderer doesn't re-render mid-drag. Items get away with constant
re-renders because their renderer is heavily optimized; for parametric
shelves (and future kinds) the imperative path is simpler and faster.
Cleanup: removed the dev `[shelf] rendered` and `[shelf] placed`
console.info logs from the shelf renderer and tool. They were Phase 2
verification scaffolding — no longer needed now that everything works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The useLiveTransforms + sceneRegistry.position.set approach used by
MoveColumnTool is broken — column ALSO disappears during move, per
user observation. The mesh doesn't visibly follow the cursor.
Items work because their move tool directly updates the scene store's
node.position on every grid:move tick (with history paused), and the
renderer reads node.position. Matching that pattern here.
MoveRegistryNodeTool now:
- Snapshots the original position at mount (for cancel / commit
revert path).
- Pauses scene history so per-tick updateNode calls don't fill undo.
- On grid:move: `useScene.updateNode(id, { position })`. The kind's
registered renderer reads node.position and re-renders, so the
actual mesh visibly follows the cursor.
- On commit: revert to original while still paused → resume → final
update (single tracked action) → re-pause. Undo replays one step,
not the per-tick spam.
- On cancel / unmount-without-commit: restore original position with
history still paused (won't enter undo), then resume.
The cursor sphere stays as the aim indicator alongside the moving
mesh. No translucent ghost — the actual mesh IS the preview now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: the move tool shouldn't render a separate translucent
preview. The actual mesh (registered with sceneRegistry through the
kind's renderer) should follow the cursor — that's what's already
happening via useLiveTransforms + the imperative position.set on the
registered Object3D.
Removed from MoveRegistryNodeTool:
- The lazy `def.preview` load + Suspense-wrapped <Preview> render.
- Now only CursorSphere shows as the aim indicator. The shelf's
actual rendered mesh follows the cursor via:
- `useLiveTransforms.set(...)` triggers ShelfRenderer to re-render
with `position={liveTransform.position}`.
- `sceneRegistry.nodes.get(node.id).position.set(...)` is a
defensive imperative update so motion feels snappy.
Added: `sfx:grid-snap` emit on grid-cell cross, matching the placement
tools' behavior. Move now sounds like placement.
The `preview` slot on NodeDefinition stays — still used by ShelfTool
for the placement cursor (where no real mesh exists yet). Phase 4 may
consolidate placement preview with the renderer too.
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>
Shelf had the floating action menu (move/delete) showing thanks to
the previous registry-driven selection commit, but clicking move
did nothing and duplicate silently failed. Two hardcoded chains:
1) FloatingActionMenu.handleMove guarded `setMovingNode` behind a
hardcoded `node.type === 'item' || ... || node.type === 'spawn'`
chain. Added `|| isRegistrySelectable(node.type)` so any
registry kind triggers the move flow.
2) MoveTool dispatched per-kind components (MoveItemContent,
MoveColumnTool, MoveWallTool, ...). The default fallback
mounted MoveItemContent, which assumes the node is an ItemNode
with asset/scale/metadata — crashes for shelf. Added a generic
MoveRegistryNodeTool (kind-agnostic clone of MoveColumnTool):
pure position+rotation drag with grid snap, re-parses orphan
re-creates via `nodeRegistry.get(kind).schema.parse(...)`.
MoveTool dispatches to it for any `nodeRegistry.has(movingNode.type)`
before the MoveItemContent fallback.
3) FloatingActionMenu.handleDuplicate had a hardcoded
`node.type === 'door' ? DoorNode.parse(...) : ...` chain. Added
a registry-driven fallback after it:
`const def = nodeRegistry.get(node.type); duplicate = def.schema.parse(duplicateInfo)`.
Then the createNode + setMovingNode branches also augment with
`nodeRegistry.has(duplicate.type)` so the new shelf gets
created in the scene and handed off to the move tool for
placement.
After this:
- Click shelf → move icon in floating menu → cursor follows mouse,
click to place at new position.
- Click shelf → duplicate icon → new shelf appears, offset by (1,0,1),
handed to move tool so the user can position it.
Phase 4 will collapse MoveRegistryNodeTool with the per-kind movers
once they all reduce to the same position+rotation shape, and read
`capabilities.movable` to gate handleMove instead of the OR chain.
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>
Shelf renderer was emitting `shelf:click` / `shelf:enter` / `shelf:leave`
via useNodeEvents from the previous commit, but no listener subscribed
— the SelectionManager components (one in editor, one in viewer) each
maintain hardcoded allTypes arrays that didn't include 'shelf'.
Adds 'shelf' to:
- editor/selection-manager: 5 allTypes arrays (one per selection strategy
— structure, structure-hover, furnish, site, deselect-also-listens-to).
- viewer/selection-manager: the SelectableNodeType union + allTypes
array.
Shelves can now be clicked / hovered in the 3D canvas and the
selection state updates correctly.
The hardcoded arrays are exactly the kind of cross-cutting friction
the registry is supposed to eliminate. Phase 4 should derive these
lists from `nodeRegistry.entries().filter(d => d.capabilities.selectable)`
so adding a new kind doesn't require editing two files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the wall / slab / curve-wall tool pattern: emit
sfx:grid-snap only when the snapped position changes (cursor crosses
a grid cell), not every frame of mouse movement within the same cell.
Tracked via a `previousSnapRef` per tool, reset when the tool
re-activates.
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>
Three concrete bugs surfaced when first-running the spike in community:
1) NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag never detected:
The previous readEnvFlag used dynamic bracket access
(`env?.[name]`), which Next.js / Turbopack does NOT substitute at
build time. Only literal `process.env.NEXT_PUBLIC_FOO` references
get inlined into the client bundle. Switched to literal access
plus a `typeof process` guard. Spawn now toggles via the flag as
designed.
2) Shelf cursor appeared offset from the mouse:
The cursor mesh lives inside the ToolManager's building-local
group, but the tool was setting `cursorRef.current.position` to
level-local coordinates (computed via `worldToLocal(level)`).
Result: cursor shifted by (building-pos − level-pos) in worst
case. Switched cursor display to use `event.localPosition`
(already building-local) with grid snap — matches the legacy
spawn-tool pattern. The commit path keeps the worldToLocal(level)
conversion since the shelf node's `position` field is stored
relative to its level parent.
3) Shelf rendered invisibly after click (suspected):
The renderer used a useEffect-swap pattern where it mounted an
empty <group> and imperatively added Three.js children from a
buildShelfGeometry() Group. Plausibly fragile under StrictMode
double-invoke or fast HMR. Switched to inline R3F JSX — top
board + brackets as plain <mesh> primitives. The pure geometry
function still exists in geometry.ts for tests and AI-authored
consumers; renderer just doesn't go through it.
Diagnostics added (dev-only; removed once spawn parity ships):
- `[shelf] placed <id> level-local <pos> parent <levelId>` on click
- `[shelf] rendered <id> at <pos>` on mount
Also: types: ["node"] in nodes/tsconfig.json so the typeof process
guard typechecks cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups after the first user-visible spike test.
shelf cursor stuck at origin:
The placeholder cursor is a translucent slab inside a <group ref>.
The tool was calling setCursor(state) on every grid:move, which
triggered a React re-render. R3F re-applies props on each render,
and since the <group> had no `position` prop, the implicit default
[0,0,0] clobbered the imperative `position.set` from the previous
tick. Result: cursor stuck at level origin instead of following
the mouse.
Fix: drop the unused useState entirely. Pure imperative position
updates via the ref. No re-renders, no clobbering. (The legacy
spawn tool gets away with the same pattern because CursorSphere
buffers its position prop differently — but for the spike, the
simpler model is fine.)
shelf snap step:
Was 0.1 (10cm) — much finer than the editor's default 0.5 grid.
Bumped to 0.5 (matches the toolbar grid setting and the legacy
half-meter snap pattern used by spawn/column).
registry-driven spawn renderer paints red:
Temporary verification marker. With NEXT_PUBLIC_USE_REGISTRY_FOR_
SPAWN=1, spawns rendered via the new path now appear in #ef4444
red. Legacy renderer stays in #22c55e green. Easy visual check
for "which dispatch path is this spawn on?" Reverted in the PR
that signs off spawn parity (alongside legacy file deletion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verification anchor for "which path is running this kind?":
[pascal:registry] loaded pascal:core v1 (1 kinds: shelf)
prints in the browser dev console on app boot. Empty array means every
kind is on the legacy dispatch path. A kind in the array means the
registry-first NodeRenderer / ToolManager shims own it (legacy path is
short-circuited).
Also exposes `globalThis.__pascalNodeRegistry` in dev so you can run
ad-hoc inspections like `__pascalNodeRegistry.has('spawn')` from the
console. Production builds skip both.
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>
Without a "test" script the package was invisible to `turbo test` —
private-editor's CI runs `bun run test` (= `turbo test`), which only
walks workspace packages that declare a test runner. Mirrors mcp and
nodes which already do this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before mounting any legacy build tool, ToolManager checks whether the
active tool's kind has a registered NodeDefinition with a tool
contribution. If yes, the registry tool wins; the legacy tool map and
special-cased spawn/column/elevator branches are skipped for that kind.
Lazy-loaded via React.lazy (cached by loader) and wrapped in Suspense.
Today the registry is empty, so useRegistryTool is always false and
every code path renders unchanged. The moment a kind registers (Phase
2+), its registry tool takes over without further edits here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions plus a viewer JSX rewire:
- legacy-system.tsx: <LegacySystem kind="..."> wrapper that renders its
children only when nodeRegistry.has(kind) is false. Lets one wrapper
cover all legacy systems for a kind (door has DoorSystem and
DoorAnimationSystem — both belong to 'door' so they yield together).
- registered-systems.tsx: <RegisteredSystems /> iterates the registry,
filters entries that contribute a system, sorts by system.priority
(default 5; e.g. wall mitering at 8 runs after door cuts at 3),
mounts each via React.lazy. Today empty registry = renders nothing.
- viewer/index.tsx: every existing per-kind system is wrapped in
LegacySystem. RegisteredSystems is mounted alongside.
With the registry empty (Phase 0), every LegacySystem passes through
unchanged and RegisteredSystems is a no-op — zero behavior change.
Once a kind registers in Phase 2+, its legacy systems yield and its
registry-contributed system runs in their place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NodeRenderer now checks the registry first. Registered kinds load
their renderer module via React.lazy (cached by RendererSource so the
Suspense boundary is stable across re-renders). Unregistered kinds
fall through to the legacy chain below.
Today the registry is empty (Phase 0 builtinPlugin.nodes is []), so
every node still hits the legacy chain — no behavior change. The
moment a kind registers in @pascal-app/nodes (Phase 2 onward), the
registry path takes over without further edits here.
GLB / instanced-GLB RendererSource kinds are typed but not yet
honored — they get their built-in renderers in Phase 5.
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>
Adds apps/editor/lib/bootstrap.ts that calls loadPlugin(builtinPlugin)
as a module-side-effect on first import. Imported from scene-loader.tsx
so it runs on the client side where the editor mounts.
Idempotency guard handles HMR re-execution (would otherwise throw on
duplicate registerNode). For the empty plugin this commit, the entire
call is a no-op — included now so future commits that add real node
kinds only need to push them onto builtinPlugin.nodes.
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>
New workspace package that owns built-in node bundles, one folder per
kind. Today the plugin is empty — no behavior change. Future commits
will add column/, shelf/, wall/, etc. and append each definition to
builtinPlugin.nodes.
The package depends on core (registry types) at v1; viewer, editor,
react, three are declared as peer deps so future node bundles can use
them without bumping their own version on every monorepo bump.
External plugins land as separate packages with the exact same shape
— this package is the dogfooded reference.
Tests: builtinPlugin shape + loadPlugin succeeds with zero kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Biome noRestrictedImports rule scoped to core/, viewer/, and
editor/ packages. Framework code must reach node-specific behavior via
nodeRegistry.get(kind), never via direct import. The @pascal-app/nodes
package doesn't exist yet (lands in the next commit) so the rule is a
no-op today; codifies the boundary ahead of node bundles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>