PR #320 changed SiteNode.children from embedded BuildingNode/ItemNode
objects to flat string[] IDs. PR #325 updated the runtime call sites
but missed the three scene templates, which still mutated the site
node's children array to embed full building objects after building
the flat dict. This caused AnyNode.safeParse to fail for site_empty,
site_2br, and site_garden in bun test --cwd packages/mcp.
Remove the obsolete mutation blocks; each template already initialises
site.children with the correct string id (e.g. ['building_empty']).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes breakage introduced by #320 which changed `SiteNode.children` from
embedded `BuildingNode | ItemNode` objects to flat `string[]` IDs.
- `packages/mcp/src/lib/rehydrate-site-children.ts`: replace now-obsolete
re-embedding logic with a no-op passthrough (call-site compatible)
- `packages/mcp/src/tools/variants/generate-variants.ts`: drop the inline
copy of the same function and its call
- `packages/nodes/src/site/renderer.tsx`: cast `childId as AnyNodeId` since
`SiteNode.children` is now `string[]`, not `AnyNodeId[]`
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Loading JSON previously called setScene blindly and crashed when
the file held schema-invalid nodes (e.g. items missing `asset`).
The new dialog parses the file, runs validateBuildJson in core,
and surfaces structure counts (site/building/levels/walls/doors/
windows/items/slabs/ceilings/zones/scans), floor area, and a
per-node Schema Details list grouped by type. Import is blocked
when any hard error exists.
Two schema bugs the validator surfaced are fixed here too:
- SiteNode.children is now an id array like every other node
(was a discriminatedUnion of full objects; three readers carried
a string-or-object ternary that's now dropped). migrateNodes
flattens legacy nested-object children on load. Default-scene
seed and photo-to-scene MCP builder updated to pass `building.id`.
- LevelNode.children now includes shelf — the editor allowed it
but the schema didn't. The schema-vs-registry-as-source-of-truth
discussion is captured in plans/editor-node-registry.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251af2c added a `?.` on cursorRef in onGridClick but inadvertently
deleted the closing braces, the trailing reset block, and the
onKeyDown/onKeyUp/onCancel handlers — leaving onGridClick syntactically
unterminated and three undefined references at the emitter.on/off sites.
Restores the deleted block verbatim, keeping the optional-chaining null
guard on cursorRef.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>