editor: in-world selection UI across wall / door / window / stair (#334)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* wall: 2D floor-plan move, side arrows, drag-to-move endpoints

Bundles the in-progress wall editing work on this branch:

- Wall corner endpoint drag in 3D (`floating-action-menu.tsx`,
  `wall/move-endpoint-tool.tsx`): press-and-drag on the floating
  endpoint button or the new 3D corner sphere, release to commit.
  Replaces the prior click-to-arm / click-to-place flow.
- New 2D move side arrows on selected walls via a new
  `move-arrow` floor-plan geometry kind (core type + registry-layer
  renderer + wall floor-plan builder emission), mirroring the 3D
  `WallMoveSideHandles`.
- 2D wall body move: new `wallFloorplanMoveTarget` translates the
  moving wall and cascades shared endpoints onto linked walls so
  L-corners stay connected through the drag.
- `MoveWallTool` cleanup gains an external-commit guard so a 2D
  commit doesn't get clobbered by the 3D mover's cleanup restore.
- HMR-safe `bootstrap.ts` no longer re-registers builtin kinds
  whose registry entry survived the closure reset.
- Misc 2D polish: floor-plan auto-fit measures the painted scene
  via `getBBox`, wall dimension offset bumped, swallow-click guard
  in `handleSelect` so registry-driven selection holds through the
  post-pointerdown re-render.

Floor-plan move-target / move-arrow code still carries diagnostic
console logs for the cascade flow; keeping for debug on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: 2D floor-plan move adopts 3D junction planner

2D wall drag now produces the same scene topology as 3D — linked corners
cascade per `planWallMoveJunctions`, off-axis branches stay rectilinear
with a bridge wall inserted between the original and new corner, and
same-direction consumed walls collapse and delete. Previously the 2D
handler did a naive endpoint-stretch cascade with no bridges or
collapses, so dragging an L-corner in 2D vs 3D yielded different scenes.

`FloorplanMoveTargetSession` gains an optional `commit` hook. The
default overlay path snapshots affected nodes and writes a diff back on
release — fine for kinds whose commit is a pure position update, but
insufficient when commit needs to also create or delete nodes. When
`commit` is present, the overlay reverts to baseline, resumes history,
and delegates the atomic write; one Ctrl-Z rolls back the entire
operation including bridge creates and collapsed deletes.

Shared helpers (`planWallMoveJunctions` plan → updates, linked-wall
snapshots, bridge synthesis) lifted to a new `packages/nodes/src/wall/
move-shared.ts` so both the 3D `MoveWallTool` and the 2D
`wallFloorplanMoveTarget` import them. Net -163 LoC after dedup.

Auto-slab live preview and ghost bridge previews mid-drag — visible in
3D today — remain 3D-only; 2D surfaces them at commit time through the
normal scene reactions. Tracked as follow-up.

Also drops three `// temp diagnostic` console.log blocks left over from
the prior wall-move branch (2D setup, 2D canCommit, 3D cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* door/window: R flips side, E toggles open/close

R previously toggled the open/closed state of operable doors and
operable windows. It now flips the opening's side (front ↔ back,
rotation += π) for both — same gesture as flipping a furniture item
that knows about handedness.

The open/close toggle moved to E, which was unbound for doors and
windows before. T is now a no-op on doors and windows so it doesn't
free-rotate a wall-bound node by π/4 (which made no architectural
sense).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* door/window: keep 2D plan in sync during placement drag

While drafting a door or window across the same host wall, the tool
was bypassing the scene store and mutating the Three.js mesh
directly. That kept 3D snappy but left the 2D floor plan reading the
last committed position — drafts froze in place on the 2D side during
a same-wall drag.

Route same-wall moves back through \`updateNode\` so 2D and 3D both
re-render from a single source. The reparent path (cross-wall drag)
still uses \`updateNode\` with \`parentId\` and \`wallId\` — we only
avoid forwarding those fields when the wall hasn't changed so the
host wall's \`children\` array doesn't churn each tick and trigger a
WebGPU "Vertex buffer slot 0 ... was not set" warning from the
briefly re-rendered placeholder geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floor-plan: live wall-draft measurement + opening placement event order

Two changes to the floor-plan panel:

  1. Length + angle labels render alongside the wall draft in 2D,
     matching the 3D \`WallTool\` feedback. Length sits at the segment
     midpoint with a plate that flips when its on-screen orientation
     would read upside-down; angle arcs anchor at each endpoint that
     meets an existing wall and label the deviation from that wall's
     direction.

  2. The pointer-move handler ran the registry catch-all
     (\`isFloorplanGridInteractionActive\`) before the opening-placement
     branch. Door and window are registered kinds, so during their
     build mode the catch-all emitted \`grid:move\` and returned —
     starving the \`wall:enter\` / \`wall:move\` events the placement
     tools listen for. Reorder so opening placement runs first; the
     wall-build skip in the catch-all is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: pass geometry as prop on move arrow handles

R3F's \`<primitive attach="geometry">\` path emits a \`Draw(0, 1, 0, 0)\`
on the first frame because the host \`<mesh>\` briefly renders with the
default empty \`BufferGeometry\` before the primitive child attaches.
Combined with \`frustumCulled={false}\`, WebGPU flagged "Vertex buffer
slot 0 ... was not set" every time a wall or fence was selected and
the move arrows mounted.

Pass \`arrowGeometry\` as a prop on the \`<mesh>\` so it's never
mounted with the default placeholder. Same fix applied to both the
wall and fence move-arrow handles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ifc-converter: regenerate next-env.d.ts after Next route path move

Next.js moved the generated routes typings from
\`./.next/dev/types/routes.d.ts\` to \`./.next/types/routes.d.ts\` in
the current version pinned by the workspace. Regenerated via
\`next typegen\` so the project compiles against the right path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* move: track which view finalised so split-view cleanups don't race

In split view, both the 2D move overlay and the 3D move tool mount
for the same \`movingNode\` and each captures its own pre-drag
snapshot. When one side finalises (commit or Esc), the other side
unmounts because \`setMovingNode(null)\` propagates — and its effect
cleanup had to *guess* whether the live scene was already-committed
state (skip restore) or its own drag's uncommitted state (revert).

Both cleanups did this via the same heuristic: diff snapshot fields
against current scene state. Cheap, but it conflates "the other side
committed" with "the user's apply() actually changed something" —
and fails outright if a commit happens to land on the same numeric
values as the snapshot.

Replace the heuristic with an explicit \`movingNodeOrigin\` state
field: '2d' | '3d' | null. The finalising side sets its origin
before \`setMovingNode(null)\` runs; the other side's cleanup reads
it. \`movingNodeOrigin\` is preserved across \`setMovingNode(null)\`
(so it's still observable when the cleanup fires) and reset the
next time a non-null \`setMovingNode\` starts a fresh drag.

Wired on the wall move-tool (3D) and \`FloorplanRegistryMoveOverlay\`
(2D) — the two real call sites today. Other 3D move tools can adopt
the same flag incrementally as their own split-view races surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: 2D drag publishes to useLiveNodeOverrides, zustand only on commit

Side-arrow / corner-dot / curve-handle drags in the 2D floor plan now
publish `{ start, end, curveOffset }` to `useLiveNodeOverrides` each
tick instead of writing to `useScene`. WallSystem, the 2D registry
layer, and the wall sidebar all merge the overrides in when reading
endpoints, so the visual + slider preview tracks the cursor while
zustand stays at the pre-drag values until pointer-up. Commit writes
one tracked `applyNodeChanges` (junction-aware) and clears the
overrides; Esc / pointercancel / mid-drag unmount also clear them.

Also bundles the in-progress branch work this depends on:
 - FloorplanAffordanceSession gains optional `commit?()` mirror of the
   move-target hook; the dispatcher reverts → resumes → calls it
   when present (vs. its default snapshot-diff dance).
 - Selected wall body is now pointer-events-inert (polygon
   `pointerEvents: 'none'` + hit-line skipped) so only the arrows /
   endpoint dots / curve dot start a drag.
 - Move button removed from the 2D floating action menu and the wall
   sidebar inspector for walls — redundant with the side-arrows.
 - `useWallMoveGhosts` store + `FloorplanWallMoveGhostLayer` for the
   dashed bridge previews painted mid-drag.
 - WebGPU "Vertex buffer slot 0 ... was not set" fixes on grid +
   guide renderer + wall draft preview by passing geometry as a
   prop (same pattern as wall-move-side-handles).
 - Floor-plan wall-tool fallback: when the 3D wall tool's
   `grid:click` already committed the wall, treat
   `createWallOnCurrentLevel` returning null as "the 3D side handled
   it" and chain the next draft segment instead of clearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: new in-world selection UI — side arrows, height handle, corner pickers, ground menu

3D affordances for a selected wall, replacing the HTML floating pill:

  - side move arrows: thinner chevron+shaft silhouette (extruded, beveled);
    press-hold-drag-release commits on pointerup (MoveWallTool no longer
    uses grid:click)
  - height arrow above the wall midpoint, drags vertically against a
    camera-facing plane and updates wall.height live; new resizingWallHeight
    state gates camera orbit; commit plays sfx:item-place
  - corner picker per endpoint: billboarded hex disc at floor + dashed
    vertical leader cylinder; pointerdown routes to the existing
    movingWallEndpoint flow (works for 2D and 3D)
  - ground action menu (curve / duplicate / delete): three Lucide SVGs
    rendered as canvas-textured planes lying flat on the floor, anchored
    one wall thickness + clearance outside the camera-facing face; one
    rigid container moves them as a unit (auto-flips sides + rotates with
    the wall, on curved walls uses the t=0.5 curve frame)
  - floating action menu hidden for walls (replaced by the above)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: smooth ground-menu side-flip with hysteresis + lerp

The three floor icons appeared to "move one at a time" when orbiting:
binary side decision flickered on grazing orbits, and the 180° rotation
flip swapped curve/delete across each other while duplicate (offset 0)
stayed put. Now lerps position+rotation toward target with a hysteresis
dead-zone, so the menu swings around the wall as one unit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: raise ground-action menu to 10 cm so icons clear floor textures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: anchor 2D action menu and 3D height arrow at curve apex

2D menu centres on getWallMidpointHandlePoint and stays horizontal 32 px
above the wall; 3D height arrow uses getWallCurveFrameAt(0.5) so the
apex+tangent match the side handles on curved walls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* door: in-world selection UI — side width arrows, height arrow, ground menu

Side arrows resize width anchored at the opposite edge; top arrow drags
height anchored at the floor. Ground menu mirrors the wall pattern with
move + duplicate + delete icons that flip to the camera side. Handles
portal into the level (not the wall mesh) and wrap in a per-frame
transform mirror so wall hover outline doesn't pick them up.

New viewer flag handleDragging gates node pointer events during in-world
drags; pointerup also swallows the follow-up synthetic click so the
PointerMissedHandler doesn't deselect the active item on commit. Wall
height arrow, wall move arrow, and fence move arrow all opt in.

Scale chevron arrows down to 65 % across wall + door so the family reads
as one. Panel type grids (door, window, column, skylight) get matched
breathing room (px-3 py-2.5, gap-2) so labels stop hugging the borders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* door: 2D width arrows + world-relative move dot, deterministic commits

Side-arrow width drag in the 2D floor plan: doors now emit two width
arrows at the wall-tangent edges when selected, routed through a new
`resize-width` affordance that anchors at the opposite edge, clamps to
wall bounds, and previews per-tick via scene writes so both the floor
plan and the 3D viewer track the drag in real time.

`move-arrow` kind gains optional `affordance` + `payload` so the same
chevron primitive can route to either the move flow (walls) or an
arbitrary affordance (door width-resize) without forking the renderer.

Move-dot for the door is now world-anchored — it scales with zoom in
place of the previous screen-constant size, matching the rest of the
door's chrome.

Both `doorWidthAffordance.commit()` and `doorFloorplanMoveTarget.commit()`
own their atomic final write so the dispatchers take the deterministic
revert → resume → commit path. The diff path was silently reverting
when the post-apply state happened to match the snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* window: in-world selection UI, 2D width arrows, deterministic commits

Bring window 3D + 2D selection chrome to parity with door. Selecting a
window in 3D now emits two side width arrows, top + bottom height arrows
(top anchors at the sill, bottom anchors at the lintel and clamps to the
wall floor), and an in-world action menu that rides just below the bottom
arrow's tip so the column moves with the sill.

2D plan adds two `resize-width` arrows at the start / end edges, routed
through the new `windowWidthAffordance` — same anchored-edge + wall-bounds
clamp + per-tick scene-write preview the door uses.

`windowFloorplanMoveTarget.commit()` is now self-owned: `apply()` snapshots
the last valid placement and `commit()` re-applies it, so the dispatcher
takes the deterministic revert → resume → commit path instead of the diff
path that silently reverts when the post-apply state happens to match
the snapshot. Mirrors the door fix.

The HTML floating-action-menu skips windows now that the in-world ground
menu owns those actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stair: in-world selection UI — side/length/height arrows, ground menu

Bring stair-segment selection chrome to parity with wall / door / window.
Selecting a stair segment in 3D now emits two side width arrows (each
slides the opposite edge anchor under the user), a length arrow at the
back face that extends the run, and — for stair-type segments — a height
arrow on top. A ground action menu (duplicate / delete) sits beside the
segment and flips sides as the camera orbits, with hysteresis + lerp so
it doesn't dither.

The handles portal into the stair's PARENT object (level / building / scene
root) rather than the stair group itself: StairRenderer attaches
`useNodeEvents` to the stair group, so any descendant pointer-over would
bubble up and set `hoveredId = stairId`, which then makes the post-processing
outline traverse the entire stair group and stroke our icons. Mirrors the
door fix. A two-layer transform mirror (`stairPoseRef` + `segmentPoseRef`)
keeps the handles aligned with the chained per-segment pose that
StairSystem writes imperatively each frame.

Duplicate forces `attachmentSide: 'front'` on the clone so it continues the
chain end cleanly instead of inheriting the original's side and U-turning.

New `resizingStairSegment{Width,Length,Height}` editor state lets
`CustomCameraControls` suppress orbit/zoom while an arrow is dragging,
matching the wall/door/window handle pattern.

The HTML floating-action-menu skips stair-segments now that the in-world
ground menu owns those actions.

Stair-segment panel swaps its bespoke fill-to-floor toggle for the shared
`ToggleControl` so it looks like the other panels and groups with the
thickness slider under one `space-y-3` block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stair: 2D move-revert fix, parent ground menu, curved/spiral in-world arrows

- Route stair 2D moves through `floorplanMoveTarget` and honor
  `movingNodeOrigin === '2d'` in `MoveRoofTool` cleanup so the 3D
  tool's restore-from-snapshot no longer stomps the 2D commit.
- Parent stair selection shows an in-world ground action menu
  (move / duplicate / delete) anchored beside the stair; the
  screen-space floating menu is suppressed for `type === 'stair'`
  to match door / window / segment.
- Curved & spiral stairs gain in-world resize arrows: rise (centered
  on the pillar for spirals), width, inner radius, and two sweep
  handles (one per arc end) clustered beside the width arrow.
- Camera controls pause during curved-stair drags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: reuse measurement-bar geometry; fix two post-merge dangling refs

MeasurementBar was building a fresh BoxGeometry per render for every wall
measurement bar, which the WebGPU backend flagged ("Vertex buffer slot N
... was not set") when walls moved. Hoist a unit cube and scale it instead.

Two refs left dangling after the main-branch merge resolved its conflicts
on GitHub:

- floorplan-panel.tsx referenced a `theme` variable that no longer
  exists; the file already derives `isDark` from `getSceneTheme(state.
  sceneTheme).appearance === 'dark'` higher up. Use that.
- grid.tsx applied `EDITOR_LAYER` but only imports `GRID_LAYER` (the new
  dedicated grid layer). Use the imported one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: pin building bbox center to cursor during move

The floating drag button anchors at the building's bbox center, but the
move tool was teleporting the building's origin to the cursor — so the
moment a drag started the building jumped by `bbox_center - origin`.

Capture the local-space offset from origin to bbox center at mount and
apply it on every grid move, grid click, and R/T rotation, so the bbox
center stays pinned to the cursor through the whole drag. Also seed the
cursor sphere at the bbox center instead of the origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stair: 2D resize affordances + dispose curved/spiral geometry on swap

Floorplan view: emit `move-arrow` children alongside the existing chrome,
mirroring the in-world arrows on selected stairs:
  - straight: per-segment side (left/right width) and front (length)
  - curved & spiral: width, inner-radius, and two sweep-end arrows
Hidden during placement so they don't fight the cursor follow.

Stroke widths on curved/spiral chrome converted to screen pixels (paired
with `non-scaling-stroke`); the old world-metre values rendered as
sub-pixel at every zoom. First step line is now also emphasised on
curved stairs to match legacy chrome. Skip the straight-only
direction-arrow polyline for curved/spiral — the arc-aligned arrow above
already conveys "up" and `buildFloorplanStairArrow` produces a malformed
polyline once the chain is wrapped around an arc.

Renderer: extract `SpiralColumnMesh` and `SpiralStepSupportMesh` and add
the same prop-+-dispose pattern used by `CurvedStepMesh` /
guide/renderer.tsx. Without disposing the prior BufferGeometry on each
resize tick, WebGPU keeps a stale pipeline reference and flags
"Vertex buffer slot 0 ... was not set" mid-drag on Lambert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: revert in-world ground menus to HTML floating; align grid to building

- Wall/door/window/stair/stair-segment selection menus return to the
  shared HTML floating menu; remove the in-world ground icons, SVG
  textures, hysteresis/lerp constants, and unused imports across
  wall/door/window/stair-segment handle files.
- Drop Move from the floating menu (the in-world side arrows cover it);
  delete the now-unused handleMove.
- Floating menu scales with camera zoom (ortho.zoom or 1/distance),
  clamped at MIN 0.5 / MAX 1 so zoom-in keeps the default pixel size
  and zoom-out shrinks to a readable floor.
- Per-type y-offsets tuned: wall 0.5, opening 0.6, landing 0.5,
  flight 0.75, parent stair 0.2, structural 0.4, default 0.05.
- Align wall/fence arrow materials with the door/window pattern
  (depthTest/depthWrite false, transparent: true) so they render on
  top of geometry consistently.
- Grid cellSize now follows `gridSnapStep` via a small `SnapAwareGrid`
  wrapper, and the grid mesh anchors its world XZ to the active
  building's mesh — snapped wall endpoints (in building-local coords)
  now fall on visible grid lines instead of mid-cell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* handles: registry-driven in-world resize arrows

Adds a `handles?: HandleDescriptor[] | (node) => HandleDescriptor[]`
field to `NodeDefinition` so each kind declares its in-world resize
affordances as pure data instead of shipping a bespoke React component.

- New `packages/core/src/registry/handles.ts` exposes a discriminated
  union: `linear-resize` (axis + center/min/max anchor), `radial-resize`
  (1:1 outward growth), plus stubs for `arc-resize` and `endpoint-move`
  for follow-up migrations.
- New `packages/editor/src/components/editor/node-arrow-handles.tsx`
  reads `def.handles`, mounts arrows with shared drag plumbing (raycast
  plane, NDC, pointer listeners, SFX, history pause, handle-dragging
  guard). Portal modes: `'parent'` (column-like, single wrapper rides
  self pose) and `'grandparent'` (door/window-like, outer wrapper rides
  parent pose + inner group rides self pose so handles escape the
  parent's selection-outline traversal). `apply` receives the
  node-at-drag-start so edge-anchored resizes (door width re-centers
  position) compute their fixed anchor from pre-drag state.
- Migrate column, door, window, stair-segment. Old per-kind handle
  files (`column-side-handles.tsx`, `door-side-handles.tsx`,
  `window-side-handles.tsx`) removed; `stair-segment-handles.tsx`
  retains `StairHandles` (parent stair curved/spiral arrows) pending
  the `arc-resize` migration.
- Column: height + crossSection-aware footprint (radius / uniform
  width=depth / independent width+depth / brace width+depth for
  non-vertical supports).
- Door / window: edge-anchored width (left + right) with wall-length
  max bound; bottom-anchored height (door) / top + bottom edges
  (window).
- Stair-segment: width (chain auto-centers), length anchored at chain
  start, height for step flights only (landings skip it).

Wall and parent-stair curved/spiral arrows stay on legacy components
for now — they need `endpoint-move` + `arc-resize` descriptor variants
and rotated-axis projection, which are their own focused sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* handles: migrate wall + parent stair to registry; add arc-resize

Closes the wall + parent-stair gap on the registry-driven handle
migration. Net −1177 lines (the per-kind handle files were 1500+
lines of duplicated drag plumbing; their replacements are ~50-line
config blocks on each NodeDefinition).

- `arc-resize` reworked to take a raw `delta` (radians) instead of
  `newValue` so two-field writes like curved-stair sweep (which
  updates `sweepAngle` AND `rotation` together to keep the
  non-dragged edge world-fixed) stay in the descriptor without
  awkward inverse-currentValue gymnastics. `currentValue` removed
  from arc-resize for the same reason — applies own their math.
- New `ArcArrow` renderer in `node-arrow-handles.tsx`: raycasts a
  horizontal drag plane at the arrow's Y, measures the signed angle
  delta around the node's local origin (atan2 in world XZ,
  normalised to [-π, π] so wraparound doesn't flip mid-gesture),
  hands the delta to `descriptor.apply` along with the initial node.
- Wall: height arrow migrated (linear-resize axis='y' anchor='min',
  placement uses curve apex for curved walls, chord midpoint for
  straight). Side-move arrows + corner pickers stay on the legacy
  `wall-move-side-handles.tsx` because they're tap-to-engage-mode
  affordances (move whole wall / move endpoint), not drag-resize —
  modelling them in the registry needs an editor-action descriptor
  variant which is a follow-up.
- Parent stair: curved + spiral stairs declare 5 handles
  — rise (linear-resize axis='y' anchor='min'),
  width (linear-resize axis='x' anchor='min'),
  inner-radius (linear-resize that also writes width to keep outer
  rim fixed), and sweep start / end (arc-resize variants writing
  sweepAngle + rotation). Straight stairs declare nothing — their
  segment children own resize.
- Old `stair-segment-handles.tsx` (1405 lines) deleted; all its
  arrows now flow through the registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* handles: tap-action descriptor + EditorApi; finish wall + fence migration

Closes the final gap in the registry-driven handle migration. Wall side-
move + corner pickers + fence side-move were the last legacy handles
because they're click-to-engage-mode affordances (hand the node to its
move tool / start an endpoint drag), not drag-resize — `apply(node,
value, sceneApi)` had no path to editor state.

- New `EditorApi` interface in core (alongside `SceneApi`) exposes
  `engageMove(node)` + `engageEndpointMove(node, endpoint)`. Concrete
  implementation in `packages/editor/src/lib/editor-api.ts` casts
  through `useEditor`'s setters so the descriptor layer never imports
  editor internals.
- New `TapActionHandle` descriptor variant: `placement` + `onActivate
  (node, sceneApi, editorApi)`. `shape` field picks the visual —
  defaults to the chevron arrow; `'corner-picker'` renders the dashed
  vertical leader + billboarded hex disc + ring (sized to
  `nodeHeight(node)`).
- `TapActionArrow` renderer in `node-arrow-handles.tsx` wires up
  pointer-down → descriptor.onActivate. Pulled the chevron and corner
  visuals into `ArrowShape` / `CornerPickerShape` building blocks so
  future shapes can be added without touching the descriptor union.
- Wall: front/back side-move (engageMove) + start/end corner pickers
  (engageEndpointMove). Joined by the existing height arrow on the
  same `def.handles` list. Old `wall-move-side-handles.tsx` (600
  lines) deleted — wall now has zero per-kind handle component.
- Fence: front/back side-move. The bespoke endpoint move buttons in
  the floating menu stay until they migrate to a tap-action too.

Net for this commit: -620 +513. Combined with the prior two
migration commits: -2287 +912 across the full registry migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: revert side-move arrows + corner pickers to legacy component

The registry-driven tap-action path didn't render the four non-height
wall handles, even with descriptors resolved and the wall mesh in
sceneRegistry. Fence uses the same descriptor shape and renders fine,
so the bug is wall-specific and not in the descriptor layer itself —
left for a real diagnosis later.

Restored the pre-5756f241 wall-move-side-handles.tsx (height arrow +
front/back side-move + start/end corner leaders, 753 lines) and
mounted it next to NodeArrowHandles in editor/index.tsx. Dropped the
def.handles field on wallDefinition so the two paths don't race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: level naming helper + ambient floorplan render during building moves

Two unrelated WIP fixes bundled:

- Level names: extract \`getDefaultLevelName(n)\` /
  \`getLevelDisplayName(level)\` into \`packages/editor/src/lib/level-name.ts\`
  and swap in across rename inputs, command palette, floating selector,
  site panel, level-tree node, level-duplicate dialog, view toggles, and
  viewer-overlay breadcrumb. Default labels now read "Ground Floor" /
  "Floor N" / "Basement N" instead of the bare "Level N" string each
  caller was concatenating itself.

- Building-move ambient floorplan: when a building is selected (or
  mid-move) without an explicit level, FloorplanRegistryLayer falls
  back to that building's level 0 (or lowest level) and renders it
  dimmed + non-interactive so the floor stays visible as context
  instead of disappearing. FloorplanPanel allows the SVG to mount in
  that case. MoveBuildingTool publishes per-frame pose to
  useLiveTransforms so the floor-plan follows the drag without
  reading from the Three.js mesh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* handles: 3D resize arrows publish to useLiveNodeOverrides, commit on release

Door / window / wall height-arrow drags now stage the patch in
\`useLiveNodeOverrides\` each frame and write to zustand exactly once on
pointerup. The kind's system reads via \`getEffectiveNode\` and rebuilds
the mesh imperatively, so the React tree never re-renders mid-drag and
undo isn't polluted by per-frame writes.

- \`packages/core\`: shared \`getEffectiveNode<T>(node)\` helper exported
  from \`@pascal-app/core\`; spreads any override fields onto the input,
  returns it unchanged when none. Replaces the inline merge wall-system
  had as \`getEffectiveWall\`.

- \`DoorSystem\` / \`WindowSystem\`: subscribe to
  \`useLiveNodeOverrides.overrides\` (so override-only ticks re-run the
  component and pick up the latest dirtyNodes), merge via
  \`getEffectiveNode\` before \`updateXMesh\`. Parent-wall dirty cascade
  uses the effective node's parentId.

- \`WallSystem.updateWallGeometry\`: door / window children are merged
  through \`getEffectiveNode\` before being passed to
  \`generateExtrudedWall\`, so cutouts track the in-flight resize.

- \`LinearArrow\` (registry handle): onMove → override + markDirty;
  onUp → one tracked \`sceneApi.update(lastPatch)\` + clear; onCancel →
  clear + markDirty to revert geometry.

- Legacy \`WallHeightArrowHandle\` in wall-move-side-handles.tsx
  switched to the same pattern (was the only inline-drag handle in
  that file — side-move + corner pickers hand off to other tools).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floating menu: enable Move icon for wall / door / window

Widens the \`onMove\` gate on \`NodeActionMenu\` so wall, door, and
window join column in showing the Move chevron. \`handleMove\` calls
\`setMovingNode(node)\` which dispatches through the existing
\`affordanceTools.move\` path on each kind's definition (already
present for all three).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* handles: live drag, guide rings, dimension chips; floating-menu Move on stairs

- Window bottom height arrow: flip Z rotation so chevron points down
  when placement Y < 0. Door / column height arrows unaffected (still
  above the node).
- Floating menu: raise stair-segment offsets (segmentType is 'stair' |
  'landing', so the legacy 'stair-flight' key was dead); enable the
  Move icon for parent stair and stair-segment.
- HandleDecoration on LinearResizeHandle + RadialResizeHandle. Generic
  GuideRing renders at node-local (0, y, 0) in the XZ plane when the
  arrow is hovered or dragging. Curved/spiral stair width arrow gets
  an outer rim ring, inner-radius arrow gets an inner pillar ring,
  and column radius arrow gets a footprint ring on round / octagonal /
  sixteen-sided shafts.
- ArcArrow migrated to the live-override pattern (sweepAngle +
  rotation). NodeArrowHandles subscribes to useLiveNodeOverrides for
  the selected node and merges into the effective node, so arrow
  positions, decorations, and dimension chips all track the in-flight
  drag instead of freezing at pre-drag values.
- StairRenderer and ColumnRenderer subscribe narrowly to their own
  override entry and render against the merged effective node, so the
  curved/spiral mesh and the column body update per pointer move
  without zustand churn.
- DimensionLabel chip (<Html>) rendered next to every linear-resize /
  radial-resize arrow on hover or drag. Format follows the wall /
  fence label recipe (metric / imperial via useViewer.unit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fence + column: registry handles, brace spread arrows, per-style defaults

- Fence: side-move arrows already on the registry path; add the height
  arrow (axis 'y' linear-resize, anchor min) + start/end corner pickers
  (tap-action, shape 'corner-picker' with dashed leader + billboarded
  hex). Move icon enabled on the floating menu; the menu floats above
  the height arrow via a fence-specific MENU_Y_OFFSET. Endpoint move
  buttons + Alt-detach plumbing removed from the floating menu — corner
  pickers cover that flow. Legacy wall-move-side-handles.tsx no longer
  branches into fence (dedupes the side-move arrows that were stacked).
- Column: bottom + top spread arrows for non-vertical supports — anchor
  'center' so dragging the right leg outward grows the full leg-to-leg
  span symmetrically. Conditionally added per supportStyle:
    - a-frame:  both bottom and top spreads
    - y-frame / v-frame: top spread only
  Per-style preset map applied on supportStyle switch (panel.tsx) so
  every style snaps to its renderer's natural proportions (defaults
  lifted from each support's fall-through expressions); a customised
  A-frame switched to Y-frame no longer carries its 1.4 m bottom into
  state, and an X-brace gets equal parallel legs rather than inheriting
  A-frame's pinched 0.12 m top.
- GeometrySystem: merge `getEffectiveNode(node)` before calling
  `def.geometry`. Smooths drags for every kind on the parametric path
  (fence, shelf, item, anything that ships `def.geometry`): live
  override mutates the mesh per pointer move, zustand only hears the
  commit. Mirrors WallSystem / DoorSystem / WindowSystem / StairRenderer
  / ColumnRenderer hookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* elevator + column: registry handles, rotation gizmo with curved arrow

- Elevator: width / depth / cab-height arrows on the registry path
  (anchor='center' for width/depth so dragging outward grows the full
  span symmetrically; anchor='min' for cab-height with shaftTopY
  resolved through `resolveElevatorLevels` so the arrow lands above
  the full shaft on multi-level elevators, not just the cab top).
  Floating-menu Move icon enabled + a fence-style MENU_Y_OFFSET so
  the menu floats above the height arrow.
- Whole-node rotation gizmo for both elevator and column. Uses
  arc-resize with `shape: 'rotate'` + a new `decoration` ring on
  ArcResizeHandle. Curved-arrow geometry is a two-headed icon (arc
  ribbon with chevron wings + tangential tip at each end), rendered
  in node-local XZ plane at mid-height. Guide ring traces the rotation
  circle (footprint-diagonal + small offset) on hover or drag.
  Position offsets along +Z only — sticks out the front of the node
  instead of diagonally at the corner. apply() negates the cursor
  angular delta (atan2(z,x) is opposite-handed from three.js Ry) so
  dragging CCW around the node rotates the node CCW.
- ArcArrow renderer extended: tracks `isDragging` like LinearArrow,
  renders the optional ring decoration, and swaps geometry between
  the chevron (default, used by stair-sweep handles) and the new
  curved-arrow shape when `shape: 'rotate'` is set.
- HandlePlacement.position / .rotationY now optionally take a
  `sceneApi` so descriptors that depend on cross-node state (the
  elevator's level-chain resolution) can compute placement against
  the live scene. SceneApi gains a `nodes()` accessor returning the
  full record. Test stubs in core (relations-resolver, drag-session,
  hosting) updated to satisfy the new shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* slab + ceiling + shelf: registry handles, live polygon preview, cursor polish

- slab: per-edge resize chevrons in PolygonEditor (gated on
  `allowEdgeMove`, so site / zone editors are unaffected), height arrow
  via `def.handles`, and a floating-menu Move icon. Polygon drags now
  publish the in-flight polygon to `useLiveNodeOverrides` through a new
  `onPolygonPreview` prop; GeometrySystem rebuilds the slab mesh at
  pointer rate while the store stays untouched until the single commit
  on release. Hole editor wired the same way. Handle materials switched
  meshStandard → meshBasic so the blue corner / green midpoint cylinders
  read true colour instead of dimming in scene lighting.
- ceiling: same Move icon, per-edge arrows, height arrow, and live
  preview through the boundary + hole editors. CeilingSystem now merges
  via `getEffectiveNode`, so polygon and height overrides flow through
  on every dirty tick. Height arrow placement is mesh-local (not
  `height + offset`) because CeilingSystem parks `mesh.position.y`
  on the height value.
- shelf: width / depth / height arrows + a curved rotation gizmo with
  ring decoration. Move icon on the floating menu. Shelf stores
  rotation as a tuple, so the rotate `apply` reads back `[x, y, z]` and
  only mutates `y`.
- LinearArrow: snapshot `rideObject.matrixWorld.invert()` at drag-start
  and reuse it in `onMove`. Kinds that park `mesh.position` on the
  field being dragged (ceiling `height`) used to chase a moving ride
  frame, so the local-Y delta collapsed and the value stalled / jittered.
- ArcArrow: cursor is `'grab'` on hover and `'grabbing'` during the
  drag (was the misleading `'ew-resize'`); the `Cursor` type gains
  those two members.
- ParametricNodeRenderer: merge `useLiveNodeOverrides` for position +
  rotation so the rotation gizmo shows live motion through the outer
  group — GeometrySystem already covered geometry-affecting fields.
- floating menu offsets: slab 0.4 → 0.7, ceiling 0.4 → 1.0, shelf 0.6,
  so the menu floats above each kind's new height arrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* roof-segment: registry handles, live override path, analytical-normal fix

- roof-segment: width / depth / wall-height / pitch / rotation arrows; pitch
  drag back-solves the angle from peak-height via the slope frame
- roof-system: getEffectiveNode + useLiveNodeOverrides so drags rebuild
  the segment + merged shell live, commit-on-release stays a single write
- floating menu: Move icon for roof-segment; uniform EXTRA_MENU_LIFT
- skylight / solar-panel / box-vent ghost: fix analytical normal — shed
  sign flip, mansard / dutch +X face direction, gambrel + mansard tier
  awareness; one (dx·tan, 1, dz·tan) formula across all roof types

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan: resize/rotate arrows for column, shelf, elevator, fence; rotation handedness fix

- Column / shelf / elevator: per-cross-section resize arrows in the 2D
  floor plan, matching the 3D handle set (width / depth / uniform /
  radius / brace dims), plus a corner rotate-arrow. Body move stays on
  the move-handle dot via the registry overlay's generic translate.
- Fence: floor-plan curve sagitta handle + side move-arrows + a
  body-move target (`fenceFloorplanMoveTarget`) with linked-fence
  endpoint cascade and ALT-detach. Commit strips `isNew` metadata and
  re-selects so the chrome stays visible at the new position.
- Roof-segment: floor-plan resize + rotate arrows wired through new
  affordances; `resolveSegmentFrame` aligns with the builder's
  transform so handles stay glued to the rendered footprint.
- Stair: in-world rotate gizmo bow orientation derived from the
  gizmo's position (was a stray `-π/4` that read as "pointing outward"
  on the spiral). StairSystem now merges the live override before the
  slab-elevation spatial query, so dragging the rotate gizmo no longer
  drops the group's Y when a segment swings off its pre-drag footprint.
- Rotation handedness: floor-plan now plots column / shelf /
  roof-segment at `-rotation` so SVG's CW-with-y-down `rotate`
  visually matches Three.js Y-rotation (CCW from top-down). Same
  `rotation` value rotates the same direction in both views, and the
  same cursor gesture writes the same sign — `- delta` in every rotate
  affordance, lined up with the 3D handles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* registry: floorplanScope + movable capability; cursor focus under mouse

- Registry: duplicate-kind throws in production, warns in dev (HMR).
  New `kindsWithFloorplanScope('building')` and `isRegistryMovable`
  helpers; `resolveBuildingForLevel` extracted into spatial-grid-sync.
- Floorplan registry layer: building-scoped kinds (elevator today) now
  dispatched via `def.floorplanScope === 'building'` instead of a
  hardcoded `node.type === 'elevator'` arm.
- FloatingActionMenu: Move button gated by `isRegistryMovable(kind)`,
  replacing the 13-arm `node?.type === '…'` chain so adding a movable
  kind no longer touches this file.
- 2D cursor indicator: render at the raw mouse position in all modes
  (drop the snapped `cursorAnchorPosition` machinery) so the badge
  always sits under the cursor.
- 3D grid reveal ring: the shader's `positionLocal.xy` is in
  grid-mesh-local space, but the cursor uniform was in world coords —
  so the ring drifted by the building's world XZ. Store the last world
  cursor and re-derive the local uniform every frame after the mesh's
  XZ lerp, so the ring stays locked under the mouse including during
  the catch-up frames after a building rotation commits.
- Roof system: tighten the merged-shell filter's type predicate so TS
  narrows `n` before `hasSegmentMaterialOverride(n)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wall: corner billboard, perpendicular grid snap, drop 45° from move/draft

- Corner picker discs (3D move + wall corner leader) now solve
  `parentWorld⁻¹ · cameraWorld` so they face the camera even when an
  ancestor building/level has a rotation; the old `camera.quaternion`
  copy silently broke under any parent rotation.
- Side-handle wall move snaps the wall centre's *absolute* perpendicular
  projection to grid lines, so axis-aligned walls land on real grid
  positions regardless of where they started.
- Wall draft + endpoint move (3D and 2D) drop the 45°-from-start angle
  snap. It was useful for picking a direction during the very first
  draft, but during a perpendicular endpoint drag it pulls the cursor
  onto a 45° ray from the fixed corner instead of tracking the grid.
- Shift now selects the fine grid step (`WALL_FINE_GRID_STEP = 0.05`)
  for precision placement in every wall snap call site, replacing the
  former "Shift = bypass angle snap" semantics with a consistent
  "Shift = finer snap" convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fence: drop 45° angle snap from draft + endpoint move, Shift = fine step

Mirrors the wall convention shipped in e89a8228:

- `snapFenceDraftPoint` gains an optional `step` override.
- Fence draft (3D `tool.tsx` and 2D `floorplan-panel.tsx`,
  `use-floorplan-background-placement.ts`) snaps to the active grid
  step only — no 45°-from-start snap. Shift switches to
  `WALL_FINE_GRID_STEP` for precision placement.
- Fence endpoint move (3D `actions/move-endpoint.ts` and 2D
  `floorplan-affordances.ts`) drops `start`/`angleSnap` so a
  perpendicular drag tracks the grid instead of pulling onto a 45°
  ray from the fixed endpoint. Shift switches to the fine step.

Also fixes the matching wall click path in
`use-floorplan-background-placement.ts:215` that was missed in
e89a8228, plus its locally-injected `snapWallDraftPoint` signature.

Side-handle perpendicular slide (`fence/move-tool.tsx`) was already
grid-snap-only without 45°, so it's untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan menu: show Move button for selected walls

Removes the `node.type !== 'wall'` exclusion that hid the Move icon
on the 2D floor-plan floating menu for walls. The dispatcher already
has a working path for walls — `def.affordanceTools.move` routes to
`MoveWallTool` (perpendicular slide + linked-wall cascade) — so the
menu just needs to expose the button.

The original opt-out called the menu entry "redundant" because walls
also have side-arrow handles, but the user wants the same icon walls
get the same affordance as every other selected element in the
floating menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan wall move: axis-lock to wall normal, match 3D MoveWallTool

The 2D `wallFloorplanMoveTarget` was applying the raw cursor delta in
XZ, so dragging a selected wall in the floor plan let it free-float
sideways and lengthwise. The 3D `MoveWallTool` constrains the same
drag to the wall's perpendicular axis (sideways slide only) — this
brings the 2D path into parity.

- Captures the wall's centre and the `getPerpendicularWallMoveAxis`
  normal at session start.
- Each tick, projects `originalCentre + rawDelta` onto the axis,
  snaps that absolute scalar to the active grid step, and translates
  the wall by `axis * perpDelta`. Same math as the 3D tool.
- Shift bypasses snap (raw projection), matching the 3D convention.
- Degenerate zero-length walls fall back to free XZ motion (rare;
  they're already destined for deletion via the junction planner).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan move overlay: commit at last pointermove, not pointer-up

The overlay used to re-run \`session.apply\` with the pointer-up
coordinates before committing, on the assumption that pointer-up
might fire without a preceding pointermove. Side effect: when the
pointer-up coord crossed a grid-snap boundary relative to the last
pointermove, the snap flipped to a different cell and the moved node
visibly jumped at release from where the drag had painted it.

Trust the last pointermove instead — modern browsers reliably emit a
final pointermove right before pointerup, and "what you saw is what
gets committed" is the UX users expect. The previous sub-pixel
drift fix loses to the visible boundary-jump it caused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan: door wall-hit placement, fence/stair move fixes, wall auto-ceiling sync

- Door / window placement: registry layer entries no longer swallow
  pointer events while a door / window tool is active, so clicking
  ON a wall now triggers placement (previously only clicks NEAR a
  wall worked — the wall's registry-entry `<g>` was stopping the
  pointer event before it reached the SVG background handler that
  emits `wall:click`).
- Fence floor-plan move: 3D `MoveFenceTool` now respects
  `movingNodeOrigin === '2d'` on unmount. Without the guard, the 2D
  overlay's commit would call `setMovingNode(null)`, unmounting the
  3D tool, whose cleanup then ran `restoreOriginal()` and reverted
  the just-committed positions — the "fence reverts on commit"
  symptom. Mirrors the wall move-tool's existing guard.
- Stair floor-plan move: anchored, delta-based motion (was
  position-jumps-to-snapped-cursor), and reads `getWallGridStep()`
  instead of hard-coded 0.5 so the stair snaps to the editor's
  current grid step in real time. Matches the 3D `MoveRegistryNode`
  commit position.
- Stair segment length arrow: drop the placement `rotationY` —
  `axis: 'z'` already auto-rotates the chevron by `-π/2`, stacking
  another `-π/2` spun the tip to `-X` (sideways) instead of `+Z`
  (forward off the run). Matches shelf / roof-segment.
- Stair segment system: merge `useLiveNodeOverrides` when rebuilding
  geometry, chain transforms, merged mesh, and slab elevation, so
  width / length / height drags show the live value on the mesh and
  the store only gets the final tracked write on commit.
- Stair length arrow position: offset 0.06 m past the front edge so
  the head clears the stair fill and reads as pointing forward off
  the run rather than lying across the edge.
- Stair default railing mode: `'both'` for new placements (was
  `'right'`).
- Wall floor-plan move: anchored at first cursor sample (was raw
  centre) so the floating-menu drag-icon offset doesn't jump the
  wall to a different snap cell on grab.
- Wall move: live auto-slab + auto-ceiling preview via
  `useLiveNodeOverrides`. The store stays at pre-drag values during
  the drag; commit writes the final plan in one atomic
  `applyNodeChanges` (creates / updates / deletes deferred from
  per-tick to commit so undo rolls the whole topology change back
  in one step). Adds `planAutoCeilingsForLevel` + `AutoCeilingSyncPlan`
  exports mirroring the existing auto-slab planner.
- Wall draft: expose `WALL_ENDPOINT_SNAP_RADIUS` (0.7 m) for
  endpoint snap intent — strongest user intent (closing polygons,
  attaching to corners) wants a more generous radius than the
  generic join snap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* open-pr skill: refresh existing PR description instead of bailing

Adds a 3b branch to the open-pr skill: when gh pr view finds an
existing PR, regenerate the body from current branch commits/diff
while preserving Screenshots verbatim and the user's checklist
tick state, then apply via gh pr edit. Previously the skill would
print the URL and exit, leaving stale descriptions on long-lived
feature branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* floorplan: capability/hook dispatch, generic resize + endpoint state

Removes per-kind `node.type ===` arms from the floorplan layer
(door/window opening placement, wall live-override merge, building
ambient context) in favour of new NodeDefinition capabilities and
hooks. Collapses 12 `resizing*` editor-store fields into one
`activeHandleDrag`, the wall/fence endpoint-move dispatch into a
kind-keyed table, and renames now-shared wall utilities to
segment-generic names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 3d wall measurement: hide label for selected walls

Item measurements still render; the wall branch is left in place so
re-enabling is a one-line gate flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: set EDITOR_LAYER on arrow handles to exclude from thumbnails

Arrow meshes portaled into the 3D scene were missing EDITOR_LAYER, so
ThumbnailGenerator's camera (which calls cam.layers.disable(EDITOR_LAYER))
would render selection handles into captures.

Add a useEffect in NodeArrowHandlesForNode that traverses the portal root
group and sets EDITOR_LAYER on every child. The effect re-runs whenever
descriptors change so newly created meshes (e.g. when handle count changes
for the same selected node) get the layer tag immediately.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: open-pascal <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-05-27 14:06:06 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 open-pascal
parent 6b0fe5b99c
commit c00a2469fd
127 changed files with 10005 additions and 1452 deletions
@@ -28,6 +28,40 @@ export function resolveLevelId(node: AnyNode, nodes: Record<string, AnyNode>): s
return 'default' // fallback for orphaned items
}
/**
* Returns the building id that contains the given level, or `null` if
* the level is unparented or no enclosing building exists.
*
* Most scenes record the relationship via `level.parentId →
* building.id`, but older serialisations occasionally drop `parentId`
* even though the building's `children` array still references the
* level. The fallback scan covers that case.
*
* Used by `FloorplanRegistryLayer` to discover building-scoped kinds
* (`def.floorplanScope === 'building'`) without hardcoding any kind
* name in the editor layer.
*/
export function resolveBuildingForLevel(
levelId: AnyNodeId,
nodes: Record<AnyNodeId, AnyNode>,
): AnyNodeId | null {
const level = nodes[levelId] as AnyNode | undefined
if (!level) return null
const directParent = (level as { parentId?: AnyNodeId | null }).parentId ?? null
if (directParent) {
const candidate = nodes[directParent]
if (candidate?.type === 'building') return candidate.id as AnyNodeId
}
for (const candidate of Object.values(nodes)) {
if (candidate?.type !== 'building') continue
const children = (candidate as { children?: AnyNodeId[] }).children
if (Array.isArray(children) && children.includes(levelId)) {
return candidate.id as AnyNodeId
}
}
return null
}
// Call this once at app initialization. Returns an unsubscribe function that
// detaches the scene-store listener (useful when the editor is unmounted so
// the spatial grid singleton does not hold stale references to old scenes).
+7
View File
@@ -40,6 +40,7 @@ export {
export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export {
initSpatialGridSync,
resolveBuildingForLevel,
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
@@ -53,11 +54,13 @@ export {
} from './lib/door-operation'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
type AutoCeilingSyncPlan,
type AutoSlabSyncPlan,
detectSpacesForLevel,
initSpaceDetectionSync,
isSpaceDetectionPaused,
pauseSpaceDetection,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
resumeSpaceDetection,
type Space,
@@ -100,6 +103,7 @@ export {
export {
default as useLiveNodeOverrides,
type LiveNodeOverrides,
getEffectiveNode,
} from './store/use-live-node-overrides'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene'
@@ -172,11 +176,14 @@ export {
} from './systems/wall/wall-mitering'
export {
constrainWallMoveDeltaToAxis,
getLinkedWallUpdates,
getPerpendicularWallMoveAxis,
getPlannedLinkedWallUpdates,
planWallMoveJunctions,
type WallMoveAxis,
type WallMoveBridgePlan,
type WallMoveJunctionPlan,
type WallMoveLinkedWallTargetPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export type { SceneGraph } from './utils/clone-scene-graph'
+29 -10
View File
@@ -47,6 +47,12 @@ export type AutoSlabSyncPlan = {
delete: Array<SlabNodeType['id']>
}
export type AutoCeilingSyncPlan = {
create: CeilingNodeType[]
update: Array<{ id: CeilingNodeType['id']; data: Partial<CeilingNodeType> }>
delete: Array<CeilingNodeType['id']>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const ROOM_CURVE_TOLERANCE = 0.04
@@ -650,12 +656,10 @@ function syncAutoSlabsForLevel(
}
}
function syncAutoCeilingsForLevel(
levelId: string,
export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
sceneStore: any,
) {
): AutoCeilingSyncPlan {
const manualCeilings = existingCeilings.filter((ceiling) => !ceiling.autoFromWalls)
const manualSignatures = new Set(
manualCeilings.map((ceiling) => polygonSignature(ceiling.polygon.map(pointFromTuple))),
@@ -782,16 +786,31 @@ function syncAutoCeilingsForLevel(
)
}
if (ceilingsToDelete.length > 0) {
sceneStore.getState().deleteNodes(ceilingsToDelete)
return {
create: ceilingsToCreate,
update: ceilingsToUpdate,
delete: ceilingsToDelete,
}
}
function syncAutoCeilingsForLevel(
levelId: string,
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
sceneStore: any,
) {
const plan = planAutoCeilingsForLevel(roomPolygons, existingCeilings)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
}
if (ceilingsToUpdate.length > 0) {
sceneStore.getState().updateNodes(ceilingsToUpdate)
if (plan.update.length > 0) {
sceneStore.getState().updateNodes(plan.update)
}
if (ceilingsToCreate.length > 0) {
sceneStore.getState().createNodes(ceilingsToCreate.map((node) => ({ node, parentId: levelId })))
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
}
}
@@ -101,6 +101,7 @@ function buildFixture() {
function makeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {},
upsert: () => ID(''),
delete: () => {},
+245
View File
@@ -0,0 +1,245 @@
// In-world resize / move arrow descriptors. Each `NodeDefinition` may
// declare a `handles` list (or a `(node) => list` function for shape-
// dependent affordances). The editor mounts a single generic component
// that reads these descriptors and renders the arrows / drag logic — no
// per-kind handles file needed.
//
// Pure data + small per-descriptor callbacks: no Three.js, React, or
// editor imports here so this stays in core. The descriptors are
// evaluated by the editor at drag time (`apply` etc.) so the callbacks
// run in the editor's context — they see the live node and the scene
// API but otherwise do not import 3D libraries.
//
// Layered intentionally:
// - axis-resize : symmetric scaling around center (column W/D, height)
// - edge-resize : anchored on one edge, the other follows the pointer
// (door width: drag right edge, left edge stays)
// - vertical-resize: linear-resize specialised for world-Y (height arrow
// anchored at bottom; window top-edge anchored at
// bottom; window bottom-edge anchored at top)
// - radial-resize : 1:1 outward growth of a radial field (column radius)
// - arc-resize : curved/spiral stair sweep / inner-radius / rise
// - endpoint-move : wall / fence endpoint drag (snapping is bespoke,
// so it delegates to a kind-supplied callback)
import type { AnyNode } from '../schema/types'
import type { SceneApi } from './types'
/**
* Editor-facing verbs that handle descriptors can invoke.
*
* Parallel to {@link SceneApi} but exposes EDITOR state mutations (move
* tools, endpoint dragging, etc.) instead of scene-data writes. Descriptors
* receive a concrete implementation from the editor at drag time — `core`
* only carries the interface so node definitions can call into editor
* affordances without importing the editor package.
*
* Minimal verb set today; grow it as new descriptor variants land
* (engageCurve for wall/fence curving, etc.).
*/
export type EditorApi = {
/**
* Hand the node to its registered move tool (the same path the floating
* menu's Move icon uses). Implementations clear any in-progress endpoint
* or curving state so the move starts from a clean slate.
*/
engageMove: (node: AnyNode) => void
/**
* Engage endpoint drag for kinds that own start / end anchors (walls,
* fences). No-ops for kinds without endpoints.
*/
engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void
}
export type HandlePortal = 'self' | 'parent' | 'grandparent'
export type HandleAxis = 'x' | 'y' | 'z'
export type HandleAnchor = 'center' | 'min' | 'max'
/** 3D position + rotation of the arrow in its portal target's local space. */
export type HandlePlacement<N> = {
/**
* `sceneApi` is supplied so descriptors that depend on cross-node state
* (elevator height resolving level entries, future cross-kind handles)
* can compute placement against the live scene. Existing descriptors
* that only need `node` can ignore the second argument.
*/
position: (node: N, sceneApi: SceneApi) => readonly [number, number, number]
/** Optional Y rotation (radians). Defaults to 0. */
rotationY?: (node: N, sceneApi: SceneApi) => number
}
export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing'
/**
* Visual decoration shown alongside a handle while the user is hovering
* or dragging it. Today: a thin horizontal ring at a node-local radius —
* the curved-stair width / inner-radius arrows use this to trace the
* outer rim / inner pillar so the user sees what the drag affects.
*
* Pure data: the editor's arrow renderer reads it and mounts the visual.
*/
export type HandleDecoration<N> = {
kind: 'ring'
/** Node-local radius of the ring (XZ plane). */
radius: (node: N) => number
/** Node-local Y of the ring. Defaults to 0. */
y?: (node: N) => number
}
/**
* Linear resize along a single local axis. Covers width / depth / height
* arrows whose visible behaviour is "drag the +axis edge, the dimension
* grows."
*
* `anchor` controls which side stays fixed:
* - 'center' : symmetric — both edges move ±delta (column width/depth).
* - 'min' : the -axis edge is fixed; drag the +axis edge by `delta`
* grows the value by `delta` (column height with origin at
* base; door height with bottom anchored).
* - 'max' : the +axis edge is fixed; drag the -axis edge.
*
* `apply(node, newValue)` returns the partial patch. Use it to write
* sibling fields too (e.g. door 'max' anchor re-centers `position[0]`).
*/
export type LinearResizeHandle<N> = {
kind: 'linear-resize'
/** Local axis. The arrow's chevron points along +axis. */
axis: HandleAxis
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
min?: number | ((node: N, sceneApi: SceneApi) => number)
max?: number | ((node: N, sceneApi: SceneApi) => number)
placement: HandlePlacement<N>
/**
* Defaults to 'self' (arrow lives in the selected node's own mesh).
* 'parent' uses the parent mesh — used by doors/windows whose handles
* need to ride the wall's rotation.
*/
portal?: HandlePortal
cursor?: Cursor
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration<N>
}
/**
* 1:1 outward growth — dragging the arrow outward by `delta` grows the
* value by `delta` (the visible edge follows the pointer). Use for radii
* and other fields where the conceptual model is "the +axis edge IS the
* thing being moved" rather than "the size IS being scaled."
*/
export type RadialResizeHandle<N> = {
kind: 'radial-resize'
axis: HandleAxis
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
min?: number | ((node: N, sceneApi: SceneApi) => number)
max?: number | ((node: N, sceneApi: SceneApi) => number)
placement: HandlePlacement<N>
portal?: HandlePortal
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration<N>
}
/**
* Curved / spiral stair sweep arrows. The renderer raycasts a horizontal
* plane through the arrow's Y and emits the angular delta (radians,
* signed, normalised to [-π, π]) around the node's local origin.
*
* Unlike the linear variants, `apply` receives the raw cursor delta
* (not a `newValue`) because sweep handles typically write multiple
* fields off the delta (`sweepAngle` AND `rotation` — re-orienting the
* arc so the opposite edge stays world-fixed). Descriptor-internal
* math handles the per-end sign and any clamping; the renderer stays
* out of it.
*/
export type ArcResizeHandle<N = any> = {
kind: 'arc-resize'
/**
* Marks the drag mode. Only 'angular' uses the polar plane renderer;
* 'radial' and 'vertical' degenerate to `linear-resize` (axis 'x' /
* 'y') so descriptors should prefer that for those cases.
*/
axis: 'angular'
/** Optional metadata for descriptors that bundle two handles per kind. */
end?: 'start' | 'end'
apply: (initialNode: N, delta: number, sceneApi: SceneApi) => Partial<N>
placement: HandlePlacement<N>
portal?: HandlePortal
/** Optional visual guide shown while the arrow is hovered or dragging. */
decoration?: HandleDecoration<N>
/**
* Visual override. Defaults to the standard chevron (used by the
* stair-sweep extend handles). 'rotate' renders a two-headed curved
* arrow icon, intended for whole-node rotation handles.
*/
shape?: 'chevron' | 'rotate'
}
/**
* Wall / fence endpoint drag. Snapping and adjacency belong to the kind,
* so the descriptor declares the placement and hands the world-space
* pointer position back to `apply`. The kind can splice walls, snap to
* a grid, merge with a neighbour, etc., and returns the partial patch.
*/
export type EndpointMoveHandle<N> = {
kind: 'endpoint-move'
endpoint: 'start' | 'end'
placement: HandlePlacement<N>
/** Called with the world-space hit on the ground plane. */
apply: (
node: N,
worldPoint: readonly [number, number, number],
sceneApi: SceneApi,
) => Partial<N>
portal?: HandlePortal
}
// Default to `any` so type-erased renderers can hold `HandleDescriptor[]`
// without each variant's contravariant `currentValue: (node: N) => ...`
// callback fighting the union widening. Per-kind defs supply a real N.
/**
* Click-to-engage affordance. The descriptor doesn't drive a drag — its
* single job is to mount a click target at `placement` and dispatch a
* verb on the editor API when the user clicks. Used by wall side-move
* (engage move tool) and wall corner pickers (engage endpoint move).
*
* The renderer picks the visual from `shape`. Default `'arrow'` reuses
* the chevron shape every resize handle uses. `'corner-picker'` renders
* a dashed vertical leader + billboarded hex disc + ring, anchored at
* `placement.position` and extending up to `nodeHeight(node)`.
*/
export type TapActionHandle<N = any> = {
kind: 'tap-action'
placement: HandlePlacement<N>
/**
* Dispatched on pointer-down. Use scene/editor APIs to read state +
* trigger the desired action.
*/
onActivate: (node: N, scene: SceneApi, editor: EditorApi) => void
/** Visual override; defaults to the standard chevron arrow. */
shape?: 'arrow' | 'corner-picker'
/**
* Required when `shape: 'corner-picker'` — controls the dashed leader's
* vertical extent. Pure callback so the descriptor doesn't need to
* import 3D libs.
*/
nodeHeight?: (node: N) => number
portal?: HandlePortal
cursor?: Cursor
}
export type HandleDescriptor<N = any> =
| LinearResizeHandle<N>
| RadialResizeHandle<N>
| ArcResizeHandle<N>
| EndpointMoveHandle<N>
| TapActionHandle<N>
/**
* Static array, or a function for shape-dependent cases (column
* crossSection / supportStyle, stair-segment segmentType, etc.).
*/
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[])
+17
View File
@@ -1,7 +1,9 @@
export {
discoverPlugins,
getSelectableKinds,
isRegistryMovable,
isRegistrySelectable,
kindsWithFloorplanScope,
loadPlugin,
nodeRegistry,
type PluginDiscovery,
@@ -15,6 +17,21 @@ export {
collectDescendants,
type SpatialQuery,
} from './relations-resolver'
export type {
ArcResizeHandle,
Cursor,
EditorApi,
EndpointMoveHandle,
HandleAnchor,
HandleAxis,
HandleDescriptor,
HandleList,
HandlePlacement,
HandlePortal,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
} from './handles'
export { createSceneApi, type SceneStoreLike } from './scene-api'
export type {
Affordance,
+64 -3
View File
@@ -3,6 +3,23 @@ import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const
// True in dev / test builds, false in production. Tries Vite's
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
// to `process.env.NODE_ENV !== 'production'` for Node test runners.
function isDevMode(): boolean {
try {
const meta = import.meta as { env?: { DEV?: boolean } }
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
} catch {
// import.meta unavailable in some CJS contexts — fall through.
}
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
return process.env.NODE_ENV !== 'production'
}
// No environment signal — be safe and treat as production.
return false
}
class NodeRegistryImpl implements NodeRegistry {
private readonly defs = new Map<string, AnyNodeDefinition>()
@@ -28,9 +45,6 @@ class NodeRegistryImpl implements NodeRegistry {
// Internal — exposed via registerNode below.
_register(def: AnyNodeDefinition): void {
if (this.defs.has(def.kind)) {
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
}
if (typeof def.kind !== 'string' || def.kind.length === 0) {
throw new Error('[registry] NodeDefinition.kind must be a non-empty string')
}
@@ -39,6 +53,21 @@ class NodeRegistryImpl implements NodeRegistry {
`[registry] NodeDefinition.schemaVersion must be a positive integer (kind: "${def.kind}")`,
)
}
// Duplicate-kind handling depends on environment:
// - **Production**: throw. The plugin-authoring contract
// (`wiki/architecture/plugin-authoring.md`) guarantees that two
// plugins shipping `kind: 'couch'` is a startup-time error, not
// a silent overwrite — collisions need to be visible.
// - **Dev (HMR)**: replace with a warning. Saving `def.ts` would
// otherwise either crash on re-execute or skip it entirely,
// leaving stale descriptors pinned in memory.
if (this.defs.has(def.kind)) {
if (isDevMode()) {
console.warn(`[registry] re-registering node kind "${def.kind}" (HMR)`)
} else {
throw new Error(`[registry] duplicate node kind: "${def.kind}" already registered`)
}
}
this.defs.set(def.kind, def)
}
@@ -85,6 +114,38 @@ export function isRegistrySelectable(kind: string): boolean {
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
}
/**
* Kinds whose `def.floorplanScope` matches the requested scope. Used by
* `FloorplanRegistryLayer` to discover building-scoped kinds (e.g.
* elevator) without hardcoding kind names in the editor layer. `'level'`
* is the default, so `kindsWithFloorplanScope('level')` includes kinds
* that didn't set the field at all.
*/
export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
const declared = def.floorplanScope ?? 'level'
if (declared === scope) result.push(kind)
}
return result
}
/**
* Returns true when the kind is movable from a 2D floor-plan handle —
* either via `capabilities.movable`, an explicit
* `def.floorplanMoveTarget`, or an `affordanceTools.move` 3D mover that
* the floating action menu can engage. Replaces the kind-name ternary
* chain in `floating-action-menu.tsx`.
*/
export function isRegistryMovable(kind: string): boolean {
const def = nodeRegistry.get(kind)
if (!def) return false
if (def.capabilities.movable !== undefined) return true
if (def.floorplanMoveTarget !== undefined) return true
if (def.affordanceTools?.move !== undefined) return true
return false
}
export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error(
@@ -38,6 +38,7 @@ function makeNode(kind: string, idStr: string, extra: Partial<AnyNode> = {}): An
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {},
upsert: () => id(''),
delete: () => {},
+4
View File
@@ -50,6 +50,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
return store.getState().nodes[id] as N | undefined
},
nodes() {
return store.getState().nodes
},
update(id, patch) {
captureIfNeeded(id)
store.getState().updateNode(id, patch)
+194 -6
View File
@@ -3,6 +3,7 @@ import type { BufferGeometry, Object3D } from 'three'
import type { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
// ─── GeometryContext ─────────────────────────────────────────────────
//
@@ -146,6 +147,24 @@ export type FloorplanStyle = {
strokeLinejoin?: 'miter' | 'round' | 'bevel'
strokeOpacity?: number
fillOpacity?: number
/**
* SVG `pointer-events`. Default (undefined) lets the renderer pick its
* normal behaviour — `visiblePainted` for filled shapes, `stroke` for
* line / hit-line. Set `'none'` to make a primitive completely
* passthrough — useful for chrome that should be visible but never
* trigger selection or drag (e.g. a wall's body once it's already
* selected, where only the side-arrows / corner handles should grab
* the pointer).
*/
pointerEvents?: 'none' | 'auto' | 'all' | 'stroke' | 'fill' | 'visible' | 'visiblePainted'
/**
* CSS `cursor` for the rendered primitive. Defaults to inheriting the
* registry entry wrapper's `cursor: 'pointer'`. Override to neutralise
* a hover affordance — e.g. a selected wall body that catches the
* pointer (to block fall-through to the slab below) but should not
* advertise itself as a drag target.
*/
cursor?: string
}
// ─── ToolHint ────────────────────────────────────────────────────────
@@ -272,6 +291,12 @@ export type FloorplanGeometry =
/** Stroke width in screen pixels — converted to plan units by the dispatcher. */
strokeWidthPx: number
cursor?: string
/**
* Override the default `pointer-events="stroke"`. Use `'none'` when
* a kind wants to keep the line painted (for hit-debugging or layout
* stability) but route grabs through other affordances instead.
*/
pointerEvents?: 'none' | 'stroke' | 'auto'
}
/**
* Endpoint manipulation handle — the 5-circle stack from the legacy
@@ -341,6 +366,50 @@ export type FloorplanGeometry =
kind: 'move-handle'
point: FloorplanPoint
}
/**
* Directional move handle drawn as an arrow pointing AWAY from the
* owning node, rotated by `angle` (radians; 0 = +x). Used by wall to
* place two arrows on perpendicular sides at the wall midpoint —
* mirrors the 3D `WallMoveSideHandles`. Routes through the same
* `onMoveHandlePointerDown` → `setMovingNode` path as `move-handle`.
*/
| {
kind: 'move-arrow'
point: FloorplanPoint
/** Rotation in radians; 0 points along +x in plan coords. */
angle: number
/**
* Optional affordance routing. When set, pointer-down on the arrow
* starts a `def.floorplanAffordances?.[affordance]` session with the
* given `payload` (same dispatch path as `edge-handle`) instead of
* the default `setMovingNode` flow. Used by doors for the in-plane
* width-resize handles that visually mirror the move arrow shape but
* drive a different mutation.
*/
affordance?: string
payload?: unknown
}
/**
* Curved two-headed rotation arrow — the 2D counterpart of the 3D
* `arc-resize` handle's `shape: 'rotate'` gizmo. Visually a short arc
* with arrowheads at each end pointing tangentially in opposite
* directions, so it reads as "rotate either way" rather than "drag
* along a line." Always routes through an affordance (rotation has no
* sensible default Move semantics).
*
* `angle` is the radial-outward direction in plan coords — the icon's
* local +X axis points away from the pivot, with the arc curving
* around it. Emitters typically compute this as
* `atan2(handle.y pivot.y, handle.x pivot.x)`.
*/
| {
kind: 'rotate-arrow'
point: FloorplanPoint
/** Radial-outward direction from the rotation pivot, in radians. */
angle: number
affordance: string
payload?: unknown
}
/**
* Centered length / distance label. Renders as a small rounded
* background plate with text, oriented along `angle` (radians). The
@@ -418,20 +487,40 @@ export type FloorplanAffordanceSession = {
/** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
affectedIds: AnyNodeId[]
/**
* Run a single drag tick. Implementations call `scene.updateNodes` to
* preview the next position. Snap logic, linked-node cascade, and
* angle locking live here.
* Run a single drag tick. Two patterns are supported:
* - **Scene-write preview**: implementation calls `scene.updateNodes`
* each tick; the dispatcher captures a pre-drag snapshot and runs
* a single-undo dance on commit (revert → resume → re-apply diff).
* Suitable for affordances whose commit is a pure diff of the
* affected fields.
* - **Live-override preview**: implementation publishes per-frame
* overrides to `useLiveNodeOverrides` (or another preview store);
* `useScene` stays untouched during the drag. The session must
* also expose `commit()` below, since there's no scene diff for
* the dispatcher to write back.
*
* Snap logic, linked-node cascade, and angle locking live here.
*/
apply(args: {
planPoint: FloorplanAffordancePoint
modifiers: FloorplanAffordanceModifiers
}): void
/**
* Called on pointer-up. Return `true` if the scene's current state
* should be committed; `false` reverts to the snapshot (e.g. wall too
* short, vertex collapsed onto neighbour).
* Called on pointer-up. Return `true` if the drag should commit;
* `false` reverts to the snapshot (e.g. wall too short, vertex
* collapsed onto neighbour).
*/
canCommit(): boolean
/**
* Optional atomic-commit hook — mirror of the same field on
* `FloorplanMoveTargetSession`. When present, the dispatcher
* reverts to the pre-drag baseline (no-op if `apply()` never wrote
* to scene), resumes history, then calls `commit()` instead of
* re-applying a diff. The session owns the full final write
* (typically `applyNodeChanges` or `updateNodes`) plus clearing any
* live overrides it published in `apply()`.
*/
commit?(): void
}
export type FloorplanAffordance<N> = {
@@ -493,6 +582,23 @@ export type FloorplanMoveTargetSession = {
* area, overlap detected, ...).
*/
canCommit(): boolean
/**
* Optional atomic-commit hook. The default overlay path snapshots
* each affected node before drag and writes a diff back on commit —
* fine for kinds whose commit is a pure position update, but
* insufficient when commit needs to also create or delete nodes
* (e.g. wall move emits bridge wall creates + collapsed wall deletes
* via `planWallMoveJunctions`).
*
* When present, the overlay reverts to the pre-drag baseline,
* resumes history, and calls `commit()` instead of the default
* `updateNodes(finalUpdates)`. The session is responsible for the
* full final write (typically `applyNodeChanges`) plus any
* post-commit selection / metadata. The overlay still emits the
* standard place SFX and clears `movingNode` after `commit()`
* returns.
*/
commit?(): void
}
export type FloorplanMoveTarget<N> = (args: {
@@ -592,6 +698,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* the legacy `floorplan-panel.tsx` monolith.
*/
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
/**
* Which scope the floor-plan layer walks to find instances of this
* kind. Default `'level'` — the layer's DFS from the active level id
* picks the node up via its parent chain. `'building'` — the kind
* lives as a sibling of levels (elevator is the canonical example:
* elevators are parented to the *building*, not a level, but the
* floor-plan should still surface them for every level inside that
* building). For `'building'`-scoped kinds the layer iterates every
* instance whose parent matches the active level's building, and
* synthesises a `GeometryContext` whose `parent` is the active level.
*/
floorplanScope?: 'level' | 'building'
/**
* 2D drag affordances keyed by the string identifier emitted on
* `endpoint-handle` (and similar interactive floor-plan primitives) via
@@ -622,6 +740,30 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path.
*/
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder
* runs. The floor-plan layer calls this when present and passes the
* returned map both as the builder's `ctx` source AND as the
* effective node (so the kind's own override lands in `effectiveNode`).
*
* Used by wall, whose miter joins read sibling walls via
* `ctx.siblings`: during a 2D drag the moved wall + its linked
* neighbours publish per-frame `{ start, end, curveOffset }`
* overrides, and the floor-plan must merge those into every wall
* the builder can see — otherwise miter math snaps back to the
* committed positions while the cursor moves. Kinds whose previews
* are self-contained leave this unset and the layer hands the raw
* `nodes` through.
*
* Return the input `nodes` unchanged when no override is relevant
* so the caller can short-circuit.
*/
floorplanSiblingOverrides?: (args: {
nodeId: AnyNodeId
nodes: Record<AnyNodeId, AnyNode>
liveOverrides: Map<string, Record<string, unknown>>
}) => Record<AnyNodeId, AnyNode>
system?: SystemContribution
tool?: LazyComponent
/**
@@ -679,6 +821,24 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* capability too).
*/
keyboardActions?: KeyboardActions
/**
* In-world resize / move arrows shown when this kind is selected.
*
* Pure descriptors — no React, no Three.js. The editor's generic
* `<NodeArrowHandles>` reads this list and mounts the matching arrow
* components with shared drag plumbing, replacing per-kind
* `<XxxSideHandles>` files for the common cases.
*
* Static array, or a function for shape-dependent affordances
* (column `crossSection` / `supportStyle`, stair-segment `segmentType`,
* curved-vs-straight stairs). See `./handles.ts` for the variant union.
*
* Bespoke chrome that doesn't fit the descriptor model (wall corner
* leader dashes, fence curving, items with `attachTo`) stays as a
* custom React component mounted alongside.
*/
handles?: HandleList<z.infer<S>>
}
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
@@ -790,6 +950,26 @@ export type Capabilities = {
floorPlaced?: FloorPlacedConfig
roofAccessory?: RoofAccessoryConfig
paint?: PaintCapability
/**
* Kind is placed by clicking on a wall (door, window). When set, the
* floor-plan layer lets wall background clicks pass through during
* placement / move-on-wall — the placement tool's `wall:click` event
* needs the SVG's `findClosestWallPoint` handler to run; without
* this the wall's registry entry would swallow the click via
* `handleSelect`. Read by `FloorplanRegistryLayer` when `movingNode`
* is set, so the active move can suspend wall selection.
*/
wallOpeningPlacement?: boolean
/**
* Instances of this kind contain levels. When such a node is being
* moved, the floor-plan layer falls back to the moving node's id as
* the ambient building context — so the floor under the cursor keeps
* rendering dimmed throughout the gesture even though the explicit
* selection may have been cleared as part of the move handoff. Set
* on building; future container kinds (e.g. annexes) opt in by
* declaring the same flag.
*/
floorplanLevelContainer?: boolean
}
/**
@@ -1086,6 +1266,14 @@ export type SnapServicesLike = unknown
export type SceneApi = {
get: <N extends AnyNode = AnyNode>(id: AnyNodeId) => N | undefined
/**
* Snapshot of the full nodes record. For descriptors / placement
* callbacks that need to walk many siblings or resolve cross-node
* structure (elevator level entries, building level chains, etc.)
* without N round-trips through `get`. Returns the live reference —
* do not mutate.
*/
nodes: () => Readonly<Record<AnyNodeId, AnyNode>>
update: (id: AnyNodeId, patch: Partial<AnyNode>) => void
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
delete: (id: AnyNodeId) => void
@@ -26,6 +26,7 @@ function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
const nodes = { ...initial }
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: (nid, patch) => {
calls.updated.push([nid, patch])
const existing = nodes[nid as string]
@@ -43,6 +43,7 @@ function makeNode(kind: string, idStr: string, parentId: string | null = null):
function makeFakeScene(nodes: Record<string, AnyNode>): SceneApi {
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
nodes: () => nodes as Readonly<Record<AnyNodeId, AnyNode>>,
update: () => {},
upsert: () => id(''),
delete: () => {},
@@ -5,6 +5,7 @@ export type LiveNodeOverrides = Record<string, unknown>
type LiveNodeOverrideState = {
overrides: Map<string, LiveNodeOverrides>
set(nodeId: string, values: LiveNodeOverrides): void
setMany(entries: ReadonlyArray<readonly [string, LiveNodeOverrides]>): void
get(nodeId: string): LiveNodeOverrides | undefined
clear(nodeId: string): void
clearAll(): void
@@ -18,6 +19,19 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
return { overrides: next }
}),
// Batch update — one Map clone + one zustand notification regardless
// of entry count, so a drag publishing to N linked walls re-renders
// subscribers (WallSystem, FloorplanRegistryLayer) once per tick
// instead of N+1 times.
setMany: (entries) =>
set((state) => {
if (entries.length === 0) return state
const next = new Map(state.overrides)
for (const [nodeId, values] of entries) {
next.set(nodeId, { ...(next.get(nodeId) ?? {}), ...values })
}
return { overrides: next }
}),
get: (nodeId) => get().overrides.get(nodeId),
clear: (nodeId) =>
set((state) => {
@@ -28,4 +42,16 @@ const useLiveNodeOverrides = create<LiveNodeOverrideState>((set, get) => ({
clearAll: () => set({ overrides: new Map() }),
}))
/**
* Merge any live override for `node` into a fresh copy. Spread semantics —
* override fields win, untouched fields stay. Returns the input unchanged
* when no override exists, so the caller can use the result directly
* without an extra "did anything change" check.
*/
export function getEffectiveNode<T extends { id: string }>(node: T): T {
const override = useLiveNodeOverrides.getState().overrides.get(node.id)
if (!override || Object.keys(override).length === 0) return node
return { ...node, ...override } as T
}
export default useLiveNodeOverrides
@@ -101,6 +101,88 @@ function getMoveWallRelation(
return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction'
}
/**
* Apply a junction plan to a list of linked walls, producing the per-wall
* endpoint updates. Mirrors the 3D `MoveWallTool`'s drag-preview behavior
* so the 2D move handler can drive the same scene topology.
*
* `linkedWallTargetPlans` take precedence over `linkedWallsToMove` — when
* the planner emits a target plan for a same-direction-consumed wall the
* matchPoint/targetPoint pair encodes the pivot, not the original ↔ next
* endpoint mapping.
*/
export function getLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: Array<{
wall: TWall
matchPoint?: WallPlanPoint
targetPoint?: WallPlanPoint
}>,
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
if (matchPoint && targetPoint) {
return {
id: wall.id,
start: pointsEqual(wall.start, matchPoint) ? targetPoint : wall.start,
end: pointsEqual(wall.end, matchPoint) ? targetPoint : wall.end,
}
}
const targetStart = targetPoint ?? nextStart
const targetEnd = targetPoint ?? nextEnd
return {
id: wall.id,
start: pointsEqual(wall.start, originalStart)
? targetStart
: pointsEqual(wall.start, originalEnd)
? targetEnd
: wall.start,
end: pointsEqual(wall.end, originalStart)
? targetStart
: pointsEqual(wall.end, originalEnd)
? targetEnd
: wall.end,
}
})
}
export function getPlannedLinkedWallUpdates<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
plan: WallMoveJunctionPlan<TWall>,
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
): Array<{ id: TWall['id']; start: WallPlanPoint; end: WallPlanPoint }> {
const movePlans = new Map<
TWall['id'],
{ wall: TWall; matchPoint?: WallPlanPoint; targetPoint?: WallPlanPoint }
>()
for (const wall of plan.linkedWallsToMove) {
movePlans.set(wall.id, { wall })
}
for (const targetPlan of plan.linkedWallTargetPlans) {
movePlans.set(targetPlan.wall.id, {
wall: targetPlan.wall,
matchPoint: targetPlan.originalPoint,
targetPoint: targetPlan.targetPoint,
})
}
return getLinkedWallUpdates(
Array.from(movePlans.values()),
originalStart,
originalEnd,
nextStart,
nextEnd,
)
}
export function planWallMoveJunctions<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
linkedWalls: TWall[],
originalStart: WallPlanPoint,