50 Commits
Author SHA1 Message Date
ab76686b8b editor: improve floorplan modes and annotations (#549)
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled
* 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

* fix(editor): anchor floorplan cursor to snapped point

* fix(nodes): preview floorplan edits through live overrides

* feat(editor): add context-aware floorplan modes

* fix(nodes): render crisp wall selection hatching

* fix(editor): cap floorplan handles at extreme zoom

* fix(editor): keep zone labels upright after rotation

* refactor(editor): make referenced annotations registry-driven

* refactor(nodes): colocate contextual dimension builders

* fix(editor): use mode-driven angle snapping

* fix(nodes): use mode-driven move snapping

* chore(editor): update react scan tooling

* refactor(floorplan): streamline construction documentation

* refactor(floorplan): remove wall assembly roadmap

* fix(floorplan): migrate retired scene data

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 18:57:59 -04:00
f6d28f6794 Fix pass: walkthrough, deck-coupled wall items, live-preview elevation, room snap modes, import validation (#537)
* fix(core): elect wall slab support from the carrying profile, not face coverage

An elevated deck drawn against a house wall covers the wall's outer face
line end-to-end (boundary contact counts), so the max-across-polylines
election handed the wall origin to the deck: every wall-hosted window/door
rode along whenever the deck height changed, and placement local-Y was
clamped above the deck top. Elect from the carrying profile instead (per
arc segment: highest support per face, min across supported faces), with
the pointer cap applied inside the profile so a capped-away deck still
falls back to the floor that carries the wall. Also pass curveOffset/
thickness/supportSlabId at the window/door tool query sites so their
cursor agrees with the rendered wall frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): honor live node overrides in spatial-grid support queries

Group drags publish translated slab polygons and wall endpoints to
useLiveNodeOverrides only; the committed spatial index made floor items
and walls re-elect support against the pre-drag slab footprint, so
multi-selection moves and room-preset placement jumped vertically until
the validating click committed the batch. Support queries now read
live-effective slab/wall records and bypass the rendered-polygon cache
while a slab or wall on the level has an override; the committed cached
path stays the fast path otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): give the room-preset stamp tool a polygon snap context

The host app's room stamp drives placement with tool='room', which has no
registry entry, so snapContextOf resolved null: Shift never cycled the
snapping mode and the HUD chip stayed hidden during room preset placement.
A tool-level context map hands the stamp the no-angle polygon set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): only synthesize walkthrough fallback floors for ground levels

Every slab-less visible level got a >=30x30m opening-free fallback floor
box at its elevation, so a walkthrough spawned on an upper level (the
no-spawn-node fallback ray picks the highest surface) stood on a phantom
plane it could never descend from. Match the baked-GLB viewer policy:
only the lowest level of each building (derived baseY === 0) gets the
fallback; upper levels rely on their real slabs, whose stair openings
are cut into the geometry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): fold useLiveTransforms slab deltas into support queries

The slab move tool and the room-preset stamp publish a translation DELTA
to useLiveTransforms (no polygon override), so the spatial index still
elected support against the slab's committed footprint: furniture riding
a room-preset preview (or sitting on a dragged deck) dropped to ground
under the visually-moved deck until the validating click. Effective slab
records now apply the live delta to polygon/holes/elevation, mapped
exactly once at each query's loop entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): reapply floor lift every frame for nodes with live previews

FloorElevationSystem only wrote mesh Y for dirty nodes, but the React
commit that rebinds a dragged node's base-Y group position can land
between frames, after priority-2 systems consumed the dirty mark — the
lift then vanished until the next pointer tick re-dirtied the node,
blinking the Y of items dragged over elevated slabs. Nodes holding a
live override or transform now get the lift reapplied every frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): stop the entry camera swap from insta-cancelling walkthrough

Entering walkthrough with a persisted orthographic camera swaps it to
perspective, which recreates the interaction callbacks and re-ran the
pointer-lock effect: its cleanup called exitPointerLock, and the unlock
handler read that as "user left walkthrough" — instantly cancelling the
fresh entry and arming the browser's ~1.25s re-lock cooldown (hence
needing multiple button presses). The effect is now mount-stable (the
changing callback rides a ref, deps down to [gl]), and the entry lock
request swallows async cooldown rejections like the P-resume path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): don't block build-JSON import over plugin-node children

Exports from projects with plugins carry nodes like trees:tree whose ids
sit in level.children; the import validator parsed parents against the
static children id union, so one tree id hard-failed the level schema and
blocked the whole import — while the same data loads fine from the DB
(setScene never runs this gate). Parents are now validated against a copy
with non-static-schema child ids filtered out; those nodes keep surfacing
through the unknown-types warning and the imported payload is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): treat registered plugin kinds as first-class in import validation

Nodes of runtime-registered plugin kinds (trees:tree, trees:grass) were
lumped into the unknown-types warning even when the plugin is loaded.
The validator now consults the node registry: registered kinds validate
against their own registered schema (corrupt plugin nodes still block),
count under stats.pluginTypes, and raise no warning — only genuinely
unregistered types do, which stays correct for hosts without the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: appease biome (format + forEach block body)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:31:09 -04:00
77442861d9 editor: complete floorplan construction documentation (#531)
* 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

* feat(floorplan): add construction dimension strings

* feat(floorplan): coordinate opening dimensions

* feat(floorplan): add opening documentation

* feat(floorplan): add construction dimensions and notes

* feat(floorplan): add interior dimensions and curved note leaders

* feat(floorplan): improve construction dimensions and document plan

* feat(floorplan): harden construction document output

* feat(floorplan): add annotation collision diagnostics

* feat(floorplan): size export annotations in paper space

* feat(floorplan): automatically separate overlapping labels

* fix(floorplan): resolve dense label overlaps

* fix(floorplan): remove stale collision warning overlays

* fix(floorplan): treat mark pills as collision obstacles

* feat(floorplan): place short dimension values outside

* fix(floorplan): preserve dimension string order

* fix(floorplan): avoid architectural geometry in label layout

* feat(floorplan): add dimension side fallback leaders

* docs(floorplan): update chapter 17 implementation status

* fix(floorplan): dimension subdivided interior walls

* feat: add associative floor plan dimensions

* feat: add continuous construction dimension strings

* feat: add structural floor plan grids

* feat: coordinate columns with structural grids

Snap column placement and movement to structural axes and intersections, derive associative grid references, and preserve floor-plan rotation by allowing secondary-button pointer moves through the grid drafting layer.

* feat: add architectural room documentation

Add room-role metadata, editable documentation fields, centered room labels, and persisted live/PDF visibility while preserving generic zone behavior.

* feat: generate architectural room schedules

Add registry-driven room schedule rows with unit-aware areas and heights, natural room ordering, enclosure resolution, and document-quality warnings.

* feat: add reliable room clear dimensions

Derive unit-aware clear dimensions from proven modeled inside wall faces for straight rectangular rooms, including rotated and split-wall enclosures, while suppressing unproven datums.

* feat: add architectural stair documentation

Add level-aware UP/DN graphics, derived flight and rail notes, plan break and overhead conventions, linked destination-level projection, and persisted live/PDF visibility.

* feat: add typed specialty construction notes

Add schema-validated specialty payloads, standardized plan notation, contract-scope metadata, configurable overhead outlines, and editor authoring controls.

* feat: add curved and circular dimensions

Add associative radius, diameter, center, chord, arc-length, angular, and coordinate modes with unit-aware notation, repeated-feature labels, 2D authoring, and document controls.

* feat: coordinate floor plan drawing types

Add persistent floor, foundation, reflected-ceiling, roof, and site plan views with per-dimension show, omit, reference, and foundation-controller behavior across live and PDF output.

* feat: add associative curved wall dimensions

Bind radius, center, chord, arc-length, and angular construction dimensions directly to curved wall geometry so annotations update when the host curve changes.

* fix: render automatic curved wall dimensions

The wall floor-plan builder explicitly skipped curved walls, leaving the associative authoring workflow as the only dimension path. Render a concentric arc-length dimension automatically and keep it governed by automatic-dimension visibility.

* fix: use radius callout for curved walls

Replace the automatic arc-length annotation with the source-standard radius method: computed center mark, radial leader, curve arrow, and R value. Keep adjacent linear strings responsible for locating the curve tangencies and depth.

* Implement construction dimension string editing

* Add construction dimension standards controls

* Apply drawing standards to automatic dimensions

* Add floorplan overhead and reference visibility controls

* Add view-specific dimension segment suppression

* Add persistent drawing sheet model

* Plot floorplan exports at fixed scale

* Apply paper-space annotation profiles

* Compose floorplan PDF sheets

* Support sheet paper sizes and preflight

* Persist pinned annotation layout overrides

* Expand annotation collision obstacles

* Add floorplan annotation preflight surface

* Add reusable drawing sheet general notes

* Add drawing sheet keyed note instances

* Add drawing sheet document markers

* Expand construction note leader terminators

* Add wall assembly layer model

* Resolve wall assembly datum references

* Add wall assembly floorplan graphics

* Add opening documentation dimension policies

* Add finish-face room clear dimensions

* Extend room clear dimensions to rectilinear rooms

* Add construction module advisories

* Add clearance advisory profiles

* Add dimension completeness audit

* Expand dimension completeness audit

* Include preflight issues in completeness audit

* feat: complete floorplan construction documentation

* refactor: remove construction note node

* feat: refine floorplan documentation and unit display

* fix(editor): improve floorplan PDF dimensions

* fix(floorplan): refresh annotation collision layout

* fix(floorplan): keep annotations clear and restore registry boundaries

* feat(floorplan): refine construction dimension references

* fix(floorplan): align documentation tools with architecture

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 14:02:17 -04:00
bf89b5bcf2 feat: vertical building model — stored level heights, wall inversion, decks (#526)
* feat(core): stored storey heights groundwork — pure slab-support module + level height schema

Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free
packages/core/src/systems/slab/slab-support.ts (severs level-height ->
spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure
mesh-free equivalent of the viewer's stacked level height, and add the
optional LevelNode.height field plus the storey service (getStoredLevelHeight,
getLevelElevations per-building prefix sums). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): storey height badge + edit popover on level rows

Each floating-level-selector row shows its storey height; clicking opens a
popover with 2.5/3.0/3.5 presets and a free slider writing level.height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification

Pass 3 in migrateNodes: derive and store each legacy level's exact stacked
height (never snapped), compact ordinals per building anchored at zero so
basements stay basements, classify wall tops against the derived plane
(|plane - top| < 0.20 strictly -> plane-bound, else explicit height
materialized), and drop the blind totalRise 2.5 stair default on legacy
scenes only. Epsilon and strictness validated by a prod census.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: pin wall tops to the storey plane; stored heights become the only vertical truth

Wall-top inversion: a wall without an explicit height now tops out at its
storey plane (resolveWallTop); slabs lift only the base. Window/door caps
resolve the real top through the same slab election instead of Infinity.
All level stacking (viewer, elevator, first-person, stair openings, MCP
scene queries) reads stored LevelNode.height; the four divergent live
derivations and level.metadata.height are deleted. Stair totalRise becomes
optional and derives from the storey height when absent. MCP create-level
stops writing its elevation param into the ordinal. Level creation sites
write explicit heights; templates carry their true derived heights.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control

Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound
walls elect the slab (pure clampSlabElevationForWalls + registry handle
bounds + shared panels for 2D/3D parity); ceiling heights clamp under the
plane and auto-ceilings derive from resolved wall tops. Every remaining
wall.height ?? 2.5 fallback resolves through resolveWallTop /
resolveWallEffectiveHeight (panels, overlays, measurements, quantities,
spatial grid, MCP reports); template walls matching their storey become
plane-bound. Wall panel gains a Top control (Follows storey / Custom
height) derived purely from height presence; the store update path now
deletes keys passed as explicit undefined so plane-binding round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification

Floor-placed nodes and walls gain a nullable supportSlabId. Elections
surface the winning slab (getSlabSupportForItem, candidates query) and
prefer a still-valid persisted host, falling back silently when the host
is gone or reshaped away; deleting the host strips references in the same
undo commit. Item-side support now tests the rendered slab polygon (like
walls) through a per-level cache invalidated by the spatial-grid sync.
Also adds the resolveStairTotalRise unit tests from the stage-1 gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: persist support hosts at commit; thread wall host preference everywhere

Floor-placed commits (draft pipeline, per-kind creation tools, registry
move tool) and wall create/move/endpoint commits persist supportSlabId
via shared resolveSupportSlabPatch helpers — only when overlapping
supports disagree on elevation, clearing it otherwise or when the node
leaves the floor. WallSlabSupport surfaces electedSlabId; every wall
support read site passes wall.supportSlabId as the preferred host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: split slab into placement + thickness; pools become explicit recess intent

slab.elevation stays the walking surface; new thickness grows downward so
the solid occupies [elevation − thickness, elevation]. Migration writes
thickness := elevation for solids (byte-identical intervals, including
degenerate zero) and recessed: true for legacy negative pools. Geometry
branches on recessed instead of the elevation sign; presets keep today's
intervals; free elevation edits move the body without coupling thickness
(the deck semantic). Dead viewer SlabSystem component deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp ceilings to covering-slab undersides across levels

getLevelAbove + getCoveringSlabUndersideAt give the first cross-level
query; ceiling writes clamp to min(storey plane, lowest covering
underside) − 0.01, and the space-detection reconcile now clamps manual
ceilings down (never up) when a deck above intrudes — a flush deck
reactively lowers the ceiling below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: mezzanine and balcony build-tab tools

One-gesture composites over the kernel: draw a deck footprint and commit
deck slab + railings + stair (mezzanine) or deck + railings (balcony) in
a single undo step. Fences gain supportSlabId and lift onto their host
deck; railing runs split around the stair mouth; edges near wall
centerlines are treated as closed. Stairs target the deck via explicit
totalRise with no level-to-level opening sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: adaptive slab vertical editing; level vocabulary in UI copy

Dragging a grounded slab's top stretches it (elevation and thickness move
together — gaps impossible); floating decks move with thickness preserved
and land grounded at zero; pools keep the drag-through-zero gesture.
User-facing copy says level, not storey (Follows level, Level height).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity

getWallPlaneTop samples the wall span against the level above's slabs, so
a thick or flush upper floor shortens the walls below it instead of
colliding (automatic attach, no dialog). Level-height edits now dirty the
level's walls, stairs, ceilings, and fences; covering-slab changes dirty
the level below. The effective-height helper triplicated across editor
overlays moved to core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: ceilings follow the level top by default

ceiling.height becomes optional — absent means the ceiling tracks
min(level top, covering slab underside) − 0.01 live, so level-height
edits no longer require ceiling fixes. Ceiling panel gains the same
Follows level / Custom height control as walls; auto-from-walls ceilings
are created height-less and their height-derivation machinery is deleted;
migration drops stored heights within 0.20 of the bound on legacy scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: wall plane clamp missed max-side boundary walls

Auto slabs derive their polygon from wall centerlines, so covering-clamp
samples sat exactly on the boundary where ray-cast point-in-polygon is
side-dependent (min edges in, max edges out) — walls clamped or not by
orientation. getWallPlaneTop now clips the wall's thickness band against
the covering polygon (boundary-inclusive, arc-aware) and the ceiling
bound's point sampling gained an explicit on-boundary test. Verified
against the reported repro scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stairs attach to decks; pointer-decided placement surface

Stairs gain deckSlabId — rise follows the deck's elevation live (straight
flights re-converge via a write-sync mirroring auto-openings), the panel
shows a unified To destination with Follows deck / Custom rise, and the
mezzanine tool attaches instead of baking a stale rise. Item placement
under an elevated deck no longer flickers: grid events fed a feedback
loop (the grid plane rode the ghost's elected height), so the support
election is now capped at the surface the pointer ray actually hits,
with a ground sentinel keeping under-deck commits deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove the mezzanine/balcony composite tools

Decks ship as catalog presets instead; the kernel the presets rely on
(fence deck-hosting, stair deck attachment, clone remaps, pointer-decided
placement) all stays. The tool code lives at e30042db for reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks

Floating decks keep their drawn polygon (and stop being seam candidates
for grounded neighbors) instead of growing into nearby walls. Panel
elevation edits are pure placement; the viewport drag stretches a
grounded slab up to 0.4 m then unsticks it into a thin deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: deck-attached stairs land flush with the deck surface

The rise now subtracts the stair's own elected base (same election the
visual lift uses), so base + rise always equals the deck walking surface;
the auto-sync defers a microtask so it reads a settled spatial grid and
re-converges on both deck and base-slab moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: draw walls and fences on elevated decks

Wall and fence draw tools now publish the pointed surface, so the draw
plane rides the deck top (no more perspective-skewed floor hits) and
previews sit on the deck. Fences gain real support election: a pure
resolveFenceSupportSlabPatch persists the deck host at draw and reshape
commits; wall commits thread the pointer cap so aiming under a deck
elects the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: unit-aware height presets

Level, ceiling, and slab preset buttons show clean values per display
system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead
of converted metric labels; metric presets unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stairs converge to their resolved rise; deck attachment disables the cutout

syncStairRises now converges every follows-mode straight stair (level or
deck) plus deck-attached custom rises — detaching a stair from a deck
re-derives its height, and ordinary stairs finally track level-height
changes. Attaching via the panel writes slabOpeningMode none and hides
the cutout controls; detaching restores the destination cutout and
clears the stale explicit rise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: stacked-slab move hopping — one ray, one surface, one XZ

The hop was hysteresis: the election consumed the riding grid plane's
perspective-skewed hit, giving two self-consistent fixed points for one
pointer ray. getPointedSupportSurface now returns the ray's crossing of
the pointed surface and both the support cap and the cursor XZ derive
from that single computation, so items stay on the surface the pointer
aims at and sit exactly under the cursor across storeys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: single stair per click; tools restore select mode on exit

The stair tool subscribed to both node clicks (synthesized on pointerup)
and the native-click grid event with none of the guards sibling tools
carry — one physical click over any node surface committed twice. A
commit gate + follow-up click swallow fix the double dispatch, and the
stair and column tools now restore select mode on exit instead of
leaving the dead build-mode-without-tool state that ignored every click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: compact multi-selection panel with host footer slot

Selecting multiple nodes now docks the collapsed-by-default panel on the
right: N selected header, kind breakdown, and Duplicate/Delete mirroring
the floating pill. A new multiSelectionFooter slot lets the host app dock
actions below it, exactly like inspectorFooter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:57:30 -04:00
Aymeric Rabot e1a4dba740 feat: improve collaboration feedback and previews 2026-07-20 20:25:30 +02:00
352954aafc editor: group manipulation — Photoshop-style multi-selection move, rotate, duplicate (#481)
* feat(editor): group R/T — keyboard rotate for multi-selections

Extract the rigid rotate/translate math from the group gizmos into
group-transform-shared (rotateGroupPatches / translateGroupPatches) and
add a keyboard group-rotate path: R/T on a multi-selection steps the
whole group ±45° around its bbox center, welded junctions and
connected-component expansion included, committed as one updateNodes
batch (one undo step). Single-selection R/T arms (reference, door/window
flip, registry keyboardActions) are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): polygon participant kind — slabs/ceilings/zones join group transforms

classifyParticipant learns a 'polygon' kind ([x,z] vertex arrays + optional
hole rings): slab/ceiling/zone now ride along in group move and rotate —
the two 3D gizmos and the keyboard group R/T all flow through the shared
rotateGroupPatches/translateGroupPatches, so the wiring is the classifier
plus the rotate gizmo's spread sampling. Zone's 3D renderer merges its live
polygon override so it previews during the drag (slab/ceiling already
rebuild via getEffectiveNode in their systems). Classifier + patch unit
tests included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): 2D floorplan group drag — Photoshop-style multi-selection move

Plain pointer-down on a transformable member of a multi-selection now
slides the whole selection rigidly in the floor plan (the 2D parity fill
for the 3D GroupMoveHandle), instead of collapsing the selection to the
pressed node. A plain click (no drag) still collapses on release; modified
clicks, Cmd-drag direct move, and reshape affordances keep their paths.
The move-handle dot routes through the same group session when its node
is part of the multi-selection.

The session shares group-transform-shared verbatim: welded LinkedNeighbor
endpoints, connected-component expansion, live previews via
useLiveNodeOverrides.setMany, grid snapping on the delta plus Figma
alignment through applyFloorplanAlignment (guides in every mode except
off, magnetic pull in lines mode), interaction scope handle-drag with the
shared group-move label, and a single batched updateNodes (one undo).
A dashed group bbox overlay shows what rides along and tracks the live
delta. clientToPlan moves to lib/floorplan/plan-coords for reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): group manipulation polish — helper text + shortcuts dialog

Multi-selection select-mode HUD now advertises the group gestures (drag
any member to move the whole selection, R/T to step-rotate), and the
keyboard-shortcuts dialog documents both under Selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): restore zone renderer useLiveNodeOverrides import

Formatter stripped it between the import and usage edits; nodes'
tsc --build catches it where the cached turbo typecheck did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): tag the 2D group selection box for drivability

data-group-selection-box on the overlay <g> so smoke drivers and devtools
can assert the multi-selection chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): multi-selection shows highlight only — hide per-node edit chrome

While 2+ nodes are selected the group is manipulated as one rigid piece,
so per-member edit affordances hide in both views: the 2D overlay pass
strips handle/dimension chrome centrally (stripHandleChrome — body
highlight, hit targets, and zone name text stay), and in 3D the slab /
ceiling boundary editors and slab hole click-to-edit outlines mount only
for a sole selection (the arrow-handle rig, wall side arrows, selection
affordances, and floating menu were already single-gated). Transformable
members show a move cursor in the floor plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): 3D body-drag group move replaces the move gizmo cross

Pressing any transformable member of a multi-selection in 3D and dragging
past the threshold now slides the whole selection on the ground plane
(group-move-3d.ts, armed from the selection manager's pointer-down choke
point) — same participant snapshot, welded junctions, grid + alignment
snapping, live overrides, and single-undo commit as the 2D session; the
2D dashed bbox rides the same delta in split view. A plain click still
collapses the selection to the pressed node. The purple GroupMoveHandle
cross is deleted, and the hover cursor advertises the gesture: 'move'
over any transformable member in both views (3D selection-manager cursor
+ 2D entry cursor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): group action menu — move / duplicate / delete the whole selection

Multi-selections get a floating Move / Duplicate / Delete pill in BOTH
views (GroupFloatingActionMenu anchored above the 3D group bbox,
FloorplanGroupActionMenu anchored above the 2D dashed group box); the
single-node menus are now sole-selection only. All three actions target
the whole selection via shared group-actions:

- Move starts a group pick-up: the selection rides the cursor
  (delta-relative, grid + alignment snapped) across BOTH surfaces —
  floorplan via the scene CTM, 3D via a ground-plane raycast through a
  three-context bridge published by the selection manager — until a
  click places it as one undo step; Escape / right-click cancels.
- Duplicate clones the selection through the clipboard pipeline
  (subtrees + id remap, without touching the user's clipboard — new
  duplicateNodesToLevel), selects the clones, and picks them up;
  cancelling deletes the clones again. The pick-up scopes to the
  selection (no component expansion / welded links) because the clones
  sit exactly on the originals, and falls back to participant-data
  bounds because the clones' meshes haven't mounted yet.
- Delete removes everything selected with the keyboard arm's
  bulk-confirm semantics.

Verified end-to-end with the scripted smoke (16/16): chrome hiding,
2D/3D/split group drags, R/T round-trip, one-step undo, menu move /
duplicate / delete, 3D move cursor. (Headless runs of the suite can
deadlock in SwiftShader's GL readback on the split-view resize —
environment-only; run headed.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group transforms no longer re-create auto rooms

Every group commit (2D/3D body drags, pick-up, rotate gizmo, keyboard
R/T) now wraps its single updateNodes in pauseSpaceDetection /
resumeSpaceDetection: the wall-driven room auto-detection rolls its
baseline forward instead of treating rigidly-moved walls as a new room
and duplicating its floors/ceilings. Room creation stays where it
belongs — building and editing walls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): polygon hosts carry their attached items in group transforms

Ceiling-mounted items are children of the ceiling, but polygon hosts
(slab/ceiling) move by rewriting vertices — no group transform — so,
unlike wall children which ride the wall mesh, their children were left
behind by group moves/rotates. collectParticipants now snapshots a
polygon participant's positioned children (level-frame coords, since the
host group sits at the origin) so they ride every group path. Unit test
included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): click picks up the group; the 2D dashed box is the drag handle

Clicking a selected member of a multi-selection (no drag) now enters the
group pick-up — the same click-to-move the sole-selection has — instead
of collapsing the selection, in 2D and 3D alike; clicking outside still
deselects. The 2D dashed selection box is itself the group's handle:
move cursor across its whole area, press-drag anywhere inside slides the
group, a plain click picks it up, and holding Cmd/Ctrl/Shift lets clicks
pass through to the entries so membership toggling keeps working. HUD
hint + shortcuts dialog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): 3D dashed group selection box doubles as the drag handle

The multi-selection now shows a dashed wireframe box in 3D (sibling of
the 2D dashed rect): move cursor across its whole volume, press-drag
anywhere on it slides the group, a plain click picks it up, and holding
a selection modifier passes the press through so members inside can
still be toggled. Rides the shared live-drag delta so it tracks the
group mid-gesture in both panes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): mid-move R/T, 2D corner rotate, realtime opening symbols, box cursor + Esc hint

- R/T while carrying a group (body drag or pick-up) now rotates the
  carried snapshots around the rest pivot — identical to the idle
  keyboard rotate — instead of leaking into the store mid-session; the
  session keydown owns the keys in capture phase and the group-move HUD
  advertises them.
- The 2D dashed box grows corner rotate handles: drag a corner to spin
  the group in 15° steps, Shift for free rotation — the floor-plan
  sibling of the 3D rotate gizmo, sharing its scope, HUD hints, and
  single-undo + space-detection-paused commit.
- Door / window floor-plan symbols now track wall drags in realtime:
  their defs merge the walls' live overrides (wallFloorplanSiblingOverrides)
  so ctx.parent is the effective wall instead of the stale store one.
- The 3D selection box asserts the move cursor across its whole volume
  on pointer move (the old ''-guard lost to app default cursors).
- Multi-select HUD + shortcuts dialog mention Esc / click-outside to
  clear, and the mid-move rotate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): rotate cursor + spinning box on 2D corner rotate; Delete ends a carry

- The 2D corner handles show a proper curved-arrow rotate cursor (inline
  SVG data URI, black glyph with white halo, grab fallback).
- The dashed selection box spins live with the group during a corner
  rotate: the rotate session publishes pivot+angle through the shared
  drag store and the box applies the SVG rotate transform.
- Delete / Backspace during any group session (drag, rotate, pick-up)
  reverts the session first and then lets the global Delete arm remove
  the selection — no more dangling carry after deleting mid-move; the
  duplicate flow's cancel still discards the clones instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): per-node direct manipulation stands down for multi-selections

Cmd is both the selection-toggle key and the single-node direct-move /
direct-rotate trigger, so a Cmd+click that wobbled past the drag
threshold over a selected member (slab or any kind) armed the legacy
single-node move and yanked that one member out of the group. All four
per-node direct-manipulation entry points — the 3D Cmd-drag move and
Cmd+right-drag rotate in the selection manager, and their 2D siblings in
the registry layer — now require the pressed node to be the SOLE
selection; with a multi-selection the gesture is simply not registered
(the group sessions own plain drags, Cmd+click keeps toggling). The
shortcuts dialog notes the single-selection scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group gestures own their ending pointerup — no synthesized click leak

use-node-events synthesizes a selection click on EVERY canvas pointerup
(deliberately more forgiving than R3F's onClick). Body drags were safe
only because inputDragging suppresses it, but the pick-up flows leaked
it as a race: the release that started a pick-up could collapse the
multi-selection to the pressed node first (then only that node was
carried), and the placement press re-selected whatever sat under the
cursor after commit.

The group sessions now claim their gesture-ending pointer events in the
CAPTURE phase and stop propagation, so the canvas never sees them and no
click is synthesized: the pick-up claims the placement press on
pointerdown and commits on its pointerup; the 3D and 2D drag/rotate
sessions stop their ending pointerup (split view: a 2D-started release
over the 3D canvas had the same hole).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): screen-rect marquee tests projected oriented bounds, not double-inflated AABBs

The 3D screen-rect box select intersected the marquee with each node's
world AABB re-boxed in screen space — two nested axis-aligned inflations.
Any oblique camera padded every node's claim, and rotated geometry (now
common: group rotation) made it flagrant — a diagonal wall's world AABB
spans a full square, so marquees selected objects visually far from the
cursor.

Membership now tests the marquee against the convex hull of the node's
ORIENTED (local-frame) bounding box projected to screen — tight under any
rotation and camera, uniform for every kind. Pure helpers
(convexHull2D / rectIntersectsHull) live in marquee-geometry.ts with unit
tests covering the diagonal-wall regression, containment both ways, and
edge-only crossings. The plane marquee variant was already precise and is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): keep box select alive after group gestures

The capture-phase stopPropagation the sessions used to silence the
canvas's synthesized selection click also killed the window bubble
listeners that clear the box-select pointer suppression — and the mouse
pointerId is constant, so one group gesture left the marquee dead until
blur. The sessions now use the designed suppression instead: they hold
inputDragging raised through the release event's dispatch (lowered on a
0ms timer) so use-node-events suppresses the click itself, and every
other pointerup listener runs normally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): marquee membership by plan footprint; 2D item highlight; site line steps back

- The 3D screen-rect marquee now projects itself onto the active level's
  floor plane and tests the DATA kinds exactly there — walls/fences by
  their segment, slabs/ceilings/zones by their polygon — matching the
  plane-marquee tool's semantics. This kills the two remaining
  imprecision sources the hull pass couldn't: vertex-baked rotation
  (polygon/fence geometry lives in level coords, so after a group
  rotation even the local bbox is an inflated axis-aligned square) and
  ceiling parallax (an elevated plane projects to a screen quad offset
  from its room, selecting 'from far'). Mesh-transform kinds (items,
  columns) keep the projected oriented-bbox hull test; plan-space
  segment/polygon intersection helpers join marquee-geometry.ts with
  unit tests.
- Selected items finally read as selected in the floor plan: palette
  stroke + heavier weight on the footprint, plus a selection ring drawn
  above the thumbnail (the move dot used to be the only cue, and it
  hides in multi-selections).
- The site's dashed property line drops to 20% opacity while a multi or
  in-flight marquee selection exists so it stops fighting the dashed
  group selection box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): 2D marquee plan-footprint membership; item marquee preview; overlays re-measure after undo

- The 2D screen marquee intersected each entry's getBoundingClientRect —
  an axis-aligned DOM box, inflated for rotated polygons and diagonal
  walls (the same double-inflation class as the 3D bug). It now maps the
  marquee through the scene CTM into a plan quad (rotated views included)
  and tests the data kinds exactly — walls/fences by segment,
  slab/ceiling/zone by polygon — reusing the shared marquee-geometry
  helpers; other kinds keep the DOM-rect fallback.
- Items now show the marquee preview tint in 2D (highlighted viewState =
  the same palette stroke + thumbnail ring the selection shows, slightly
  lighter), instead of giving zero feedback until the drop.
- Everything that measures the selection's meshes (2D/3D dashed boxes,
  the 3D group menu anchor, the rotate gizmo pivot) re-measures once the
  meshes settle after a scene change via useMeshSettleEpoch — an undo
  after a move no longer leaves the box at the pre-undo position
  (computeGroupBox reads mesh world bounds, which lag the store commit by
  a frame or two).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): dim the actual site boundary line during multi-selections

The earlier dimming targeted registry-layer site entries, but the site
lives at the scene root — outside the level DFS and the building-scoped
sweep — so no entry ever matched and the dashed property line kept full
opacity next to the dashed group selection box. Move the dimming to
FloorplanSiteLayer (the component that actually draws the line) and
drop the dead registry plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:10:35 -04:00
22af31dea8 editor: snapping overhaul — shared-wall rooms, deterministic slab elevation, cursor-true transfers (#458)
* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default

- Alignment guides display in every snapping mode (grid/lines/angles/off);
  magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate
  decouples guide display from the snap delta across all placement/move/draw
  producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/
  window, MEP, 2D floorplan drafting, surface snap).
- Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG -
  building rotation), matching the on-screen aligned-to-north view.
- Item placement rotate hint collapsed to a single 'R / T  Rotate' row.
- Default item snapping mode changed lines -> grid.

* feat(walls): detect rooms across shared walls + tight connect-snap in all modes

Room detection: planarize the wall graph before face-finding — split straight
walls at T-junctions where another wall ends mid-span, so a room closed against
the middle of an existing wall is detected, not just isolated 4-wall rooms.

Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph
via wallClosesRoom(), so drafting stops when a segment seals a room against the
existing structure — not only when the chain returns to its own start.

Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05)
that also runs in grid/off/angles — within range of a wall (body or corner,
uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close
in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in
non-magnetic modes to the same connect distance so a corner dot no longer
magnetises the cursor from far.

Tests: cover T-junction detection + wallClosesRoom; update the item-default and
wall-split tests for the mode-driven behaviour.

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

* feat(surfaces): gate alignment guides to connect distance in non-magnetic modes

Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap
(resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the
connect distance are fed to the alignment resolver, so guides form to nearby
points and far corner dots stop lighting up from across the plan. Filtering the
candidates (local-frame, like the cursor) rather than the resolved guides avoids
the floor-plan view rotation baked into the guide coords. Lines mode keeps the
full-range guides; the surface wall connect-snap is unaffected.

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

* fix(slabs): deterministic wall-slab overlap via clipped overlap length

Wall elevation used ray-cast point samples that landed exactly on the
slab boundary for perpendicular walls, so whether a butting wall
followed the slab's elevation depended on which side of the slab it
touched. Clip the wall centerline and face lines against the polygon
and require >=5cm of on/inside length instead: walls along the slab
edge follow it on every side, point contact never does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start

The horizontal snap lattice sat exactly at the followed surface Y, so it
z-fought elevated slab tops while moving items. The visual mesh now rides
1mm above; the grid event plane keeps the true height.

Moving a wall-hosted item showed a horizontal grid until the first
pointer move published a wall surface — the mesh fallback assumed UP.
Derive the host wall normal from the item mesh's world orientation
(local +Z faces out of the wall) so the lattice is wall-aligned from
the first frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): unify selection shortcuts + truthful select-mode hints

Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and
appends on box-select release), matching the 2D floorplan. The HUD rows
describe what actually works in both views: move is plain left-drag (the
grip/dot), the two vague selection rows collapse into one Cmd/Shift
or-group, and the modifier-held variants (freely / with guides / bypass
snaps) are gone — guides follow the snapping mode now. Key pills join
with + for combos and / for alternatives; Shift renders as the shift
icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration

The group-rotate gizmo never received pointer events through the shared
invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled
context; its handlers now live on the visible arrow plus a plain
default-layer invisible torus, so hover and drag work with a fat target.

Group drags begin a handle-drag scope: each gizmo hides while its
sibling drags (the frozen corner goes stale), idle hints leave the HUD,
and the drag gets contextual hints — Shift free-rotation for rotate, the
snapping chips for move.

Group move joins the snapping-mode system via the 'item' context: Shift
cycles the mode, Ctrl the grid step (both read live mid-drag), and
'lines' runs the same Figma-style alignment as single-node moves against
the group's bbox anchors. Chip clicks now tick like the keyboard cycles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): group-move ticks in every snapping mode

Mirror the single-node move's sfx: emit per delta change rather than
only on grid crossings, so lines/off get the same rate-limited texture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(items): keep host transfers glued to the cursor

Moving between hosts used to diverge from the pointer and mangle state:

- Grab offsets: every surface anchor (wall / ceiling / shelf / item
  surface / floor) preserved the grab offset by re-seeding from the
  item's carried-over position on each new host, landing it far from
  the cursor. The grab offset now survives only on the original host
  and only until the item anchors anywhere else — after that every
  host (the original included) centers the item under the cursor.
  Applied uniformly to the placement coordinator and the window/door
  move tools.

- Rotation: detaching from a rotated shelf/table back to the floor kept
  the HOST-local yaw as the level yaw, visibly spinning the item. The
  detach now re-expresses the item's world yaw in the level frame.

- Elevation: the same detach wrote the level parentId to the store but
  not the draft ref, so the floor-elevation resolver bailed on its
  parent-must-be-a-level guard — the snap grid and the item stopped
  following slab elevations for the rest of the drag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(items): latch grab-offset forgetting across every host kind

Review follow-up (PR #458): unify the per-surface grab-forget rule into
one grabForgotten latch. A wall/ceiling item could not actually reach a
shelf or the floor (item-surface enter rejects attachTo assets), but a
wall item CAN anchor on a roof face — and returning to its original
wall then restored the stale grab offset. Roof-wall transitions (and
floor landings after a host visit) now trip the latch in the
coordinator and the window/door move tools alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:18:37 -04:00
bf25af6add viewer: Fix Dutch roof trim artifacts (#452)
* 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

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

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

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

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

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

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

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

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

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

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

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

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

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

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

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

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

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

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

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

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

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

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

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

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

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

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

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-06-30 16:41:13 -04:00
Wassim SAMADandClaude Opus 4.8 43fa2b1253 Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:22:47 -04:00
Wassim SAMADandClaude Opus 4.8 5933a00247 fix(editor): placement grid — wall-anchored, perf, first-frame wall seed
Validated live by Wassim.

- Wall grid is anchored to the wall PLANE (its foot), not the moving ghost, so
  sliding a door/window only moves the reveal patch — the lattice stays a fixed
  snap reference instead of "following" the opening.
- DoubleSide so the lattice renders when an opening is handled from the far side.
- depthTest is conditional: ON for the floor (the ground occludes a sub-floor
  lattice) and OFF on a wall (visible through the wall from the opposite side).
- Resolution change is a uniform write only — `cellSize` no longer rebuilds the
  uniform + material (which recompiled the shader and stalled on every step).
- Y follow snaps instantly (was a lerp); `gridY` state only updates on change.
- Reveal radius 5 → 12.
- Door/window publish the wall surface on mount (+ claim the pointer for the
  wall) so the grid is vertical from the FIRST frame — no horizontal flash.
- Export the active-placement-surface module so the opening tools can publish.
- Drop the editor-side "Show Grid" setting: the 3D grid is now purely a
  placement aid (shown only while placing/moving in grid-snap mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:59 -04:00
Wassim SAMADandClaude Opus 4.8 67a558a883 feat(editor): route door/window facing triangle through the unified overlay
Door and window are placed via preset and moved with a bespoke wall-bound
`move-tool` (affordanceTools.move), not the draw tool — so the previous inline
triangle never showed for the paths actually used. Migrate both:

- move-tool (move + preset, the community path): publish the on-wall ghost pose
  to `useFacingPose` in the same building-local frame the ghost renders in,
  dropped to the floor under the wall (the ghost Y is the opening centre); clear
  on every off-wall / hide / reveal / unmount path
- draw tool (standalone from-scratch path): publish the on-host pose, clear on
  fallback/hide; frame depth read via a ref to keep the setup effect deps clean

Removes the now-dead `FacingIndicator` public export (the editor-side overlay is
its only consumer, via relative import). The unified overlay now covers every
placement/move path: items, column/shelf, stair, and door/window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:12:08 -04:00
Wassim SAMADandClaude Opus 4.8 68e5c6ca67 feat(editor): unify placement/move facing triangle into one editor-side renderer
Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):

- column/shelf presets + all moves (PlacementBox via move-registry, and
  DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
  (new registry resolver) so the triangle sits before the entry pointing out —
  resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
  continuation (C) toggle, like the other placement tools

Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:48:58 -04:00
Wassim SAMADandClaude Opus 4.8 76096ffe72 fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration
The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move
(freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move
(DOM) de-dup compared event.timeStamp across two event systems with different
clocks, so it never matched and the floor free-follow ran during on-wall
slides too, ping-ponging the host and churning the nodes ref → framerate
collapse in both 2D and 3D. Replace it with a single-clock wall-ownership
window (performance.now, ~4 frames): the floor follow stands down while a
wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh +
useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that
the per-frame churn is gone.

Also completes the door/window modifier-model migration (#10): Shift=cycle /
Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude
ground-line candidates from along-wall opening alignment; emit the move SFX
once per snapped step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:21:37 -04:00
Wassim SAMADandClaude Opus 4.8 04f1b0d59e feat(editor): node-declared per-context snapping + contextual HUD + painter scope
Generify the snapping/modifier HUD off the FSM scope and node declarations
instead of wall-creation-shaped, leaking pills.

- Per-context snapping (`snappingModeByContext`, persisted): wall / item /
  polygon mode-sets with exclusive modes (grid | lines | angles | off), each
  doing exactly what its chip says. Context is node-declared via the new
  `NodeDefinition.snapProfile` ('item' | 'structural'); the resolver maps
  (profile × action) → context with no per-kind switch.
- Scope-driven HUD: helper-manager reads the interaction scope; reshaping
  (endpoint/curve/boundary) and item move get their own chip, no select-hint
  leak. Rotate R/T rounds to 45°; Alt = force-place only (hidden for
  structural kinds); Shift = cycle everywhere.
- Slab/ceiling drafting: Shift=cycle, mode-aware grid/angle, Enter finishes
  (minDraftVertices); polygon boundary vertex/edge drag begins a reshaping
  scope. Fix grid/angle being ignored on boundary edit + slab creation:
  make resolveSurfacePlanPointSnap exclusive (alignment gated on magnetic) so
  grid/angles keep the snapped fallback instead of the raw cursor.
- Painter application scope: node-derived (single/object/matching/room) from
  the hovered node, cyclable via Shift, single-source HUD chip.
- Remove the redundant GridSnapControl from view-toggles (grid step lives in
  the contextual HUD now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:23:15 -04:00
Wassim SAMADandClaude Opus 4.8 f773e6b8c5 feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:04:58 -04:00
a71de82ccb nodes: add MEP movement controls and DWV parity (#438)
* 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

* feat(duct): ceiling-snap drawing + connected-joint endpoint move

Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.

Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.

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

* feat(mep): detach + vertical modifiers for duct/pipe joint editing

Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.

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

* feat(mep): full DWV pipe parity for joint editing

Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.

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

* feat(mep): wall-style arrow handles for duct fittings + segments

Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.

Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.

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

* feat(mep): click-to-latch cube handles for duct + fitting editing

Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).

- Duct segment: per-vertex + run-center cubes reveal axis-locked move
  chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
  per-axis rotation arcs (oriented in place), replacing the old
  height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.

* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag

Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.

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

* feat(mep): run-aligned duct handles, swing snapping, elbow flatten

Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.

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

* feat(mep): per-segment linesets/liquid-lines with joint-follow editing

Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.

Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.

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

* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag

Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.

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

* Add roof accessory placement guides

Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.

* Improve duct and placement routing

* Fix duct vertical movement routing

* Fix duct vertical offsets and roof accessory movement

* Add DWV movement parity and line endpoint controls

* Fix MEP handle review issues

* Fix chimney placement and duct offset cleanup

* Use snapped targets for roof accessory commits

* fix(nodes): repair MEP movement review issues

- auto-fitting: tee branch now follows the drawn lateral angle; update the
  stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
  left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
  and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
  so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
  cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default

* fix: address mep movement review issues

* fix: address follow-up mep review comments

* fix: address additional mep review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
2026-06-23 08:37:15 -04:00
Wassim SAMAD c4b6e74d65 Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
2026-06-18 12:22:26 -04:00
Wassim SAMADandClaude Opus 4.8 8aa179e9cb feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs
Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):

- Per-part painting: door = panel/frame/glass/hardware, window = frame/glass,
  each independently paintable. The recessed door/window body sits behind the
  wall, so the proud invisible cutout wins the scene raycast over the wall and
  the shared resolveSlotByReRaycast() re-raycasts the kind's own subtree to pick
  the exact part under the cursor (panel↔frame↔glass↔hardware). Hover tracks the
  cursor via a  re-eval (idempotent, no flicker).
- Door frame is its own slot (separate frameMaterial); hardware = new flat
  'metal-chrome'.
- Library defaults (generic): panel/frame -> library:preset-softwhite, glass ->
  library:preset-glass (flipped preset-glass to FrontSide — DoubleSide poisons
  the WebGPU MRT pass; it's the only glass we use).
- Catalog: add flat (non-PBR) 'metal-chrome' + 'metal-brass'; drop metal
  metalness 1 -> 0.6 so metals are lit by existing lights (no env needed).
- World-scale UVs (1 unit = 1m) on door/window box meshes via shared box-uv.ts,
  so finishes tile at real-world scale instead of stretching.
- PaintResolveArgs gains an optional  for subtree re-raycasting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:01:37 -04:00
Wassim SAMADandClaude Opus 4.8 267dac7b1a perf(editor): convert UI icons from PNG to WebP
The editor's /icons assets were ~10MB of oversized PNGs (a single
toolbar icon up to 1.4MB). Convert every non-PWA icon to WebP (quality
92) and repoint all /icons/*.png references to .webp across
packages/editor, packages/nodes, and apps/editor. PWA/platform icons
(apple-touch-icon, icon-192, icon-512) stay PNG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:41:50 -04:00
Wassim SAMADandClaude Opus 4.8 9f1627e923 feat(paint-slots): paintable slots for windows + doors (frame/glass, panel/glass)
Windows and doors build all visuals in their viewer systems from module-global
materials, so this threads per-node slot materials + userData.slotId tags
through those builders without restructuring them:

- window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) +
  'glass'; the opening reveal keeps its own material.
- Each system captures per-frame viewer state, then updateWindow/DoorMesh points
  the builder-facing base/glass materials at the node's resolved slot override
  (recomputed per node, so the next node resets without a restore). Meshes are
  auto-tagged in the shared addBox/addShape helpers by which material they got.
- Textures-off still collapses to the role material (escape hatch); a slot
  override only applies in colored mode.
- Editing a referenced scene material re-dirties the window/door (these systems
  aren't covered by GeometrySystem's scene-material re-dirty).
- New paint capabilities (resolve role from userData.slotId, preview by
  userData.slotId) + capabilities.slots; window/door dropped from the paint
  disabled list. Shared previewSlotByUserData helper.

Defaults unchanged: unpainted windows/doors render exactly as before (the slot
fallback is the existing frame/glass material), so no visual regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:54:18 -04:00
a0d3d9c701 feat(editor): door/window placement-feel polish + placement-state refactor (#411)
Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.

UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
  slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
  (footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
  over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
  dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
  floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
  warning) + free-place (lands at the raw cursor but keeps the alignment guides
  visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
  matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
  e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
  on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
  commit places (cursorRotation was π off for the asymmetric ghost). R ownership
  follows the current pointer pane (capture-phase + stopImmediatePropagation in
  the 2D overlay) so 3D and 2D never double-flip or go dead.

Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
  shared/wall-attach-target.ts — the single source of truth the ghost tint AND
  the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
  (door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.

Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:20:44 -04:00
Aymeric RabotandClaude Opus 4.8 c9121d322c Merge origin/main (#407 placement restructure) into opening-proximity-guides
#407 ("Always-visible placement ghosts + true-nearest 2D opening snap")
restructured the door/window placement tools: it split the old
create-in-resolve into a pure resolveWallPlacement() + side-effecting
applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt),
unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall.

Conflict resolution (door/tool.tsx, window/tool.tsx):
- Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget
  (after the draft update + updateCursor), using that scope (wall,
  getSlabElevationForWall(wall)); door includeVertical:false, window true.
- Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback
  path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown.
- Made the window sill snap (resolvePlacementY) event-free and call it from the
  pure resolveWallPlacement, so hover + click both get sill/centre/top snapping;
  Shift bypasses, the moving draft is excluded via ignoreId.
- Dropped the branch's inline onWallClick in favour of #407's onWallClick +
  commitWindowAtWall (no behavior lost).
- Reconstructed both files' import blocks, which the auto-merge had truncated to
  stubs (only tsc caught it).

All other conflicts auto-merged (registry types, floorplan-registry-layer,
both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594
tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex
(adversarial): no semantic regressions; all #407 behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:58:00 -04:00
Aymeric RabotandClaude Opus 4.8 69aa272004 perf(editor): stabilize 3D opening-guide rendering, remove per-tick GPU churn
Reuse one THREE.Line + preallocated position buffer per guide slot, mutating
endpoints in place each drag tick instead of rebuilding the geometry, line, and
two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable
semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that
persists keeps its React element and drei <Html> pill mounted as the guide set
churns, rather than remounting under shifting index keys.

Also: make useOpeningGuides.clear() a no-op when already empty so the common
no-guide hover frame doesn't push a fresh [] and re-render to the same nothing;
dispose the move-tool cursor EdgesGeometry on unmount; and memoize the
placement-tool cursor EdgesGeometry (static fallback dims) so it isn't
reallocated and orphaned on every render during placement.

Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak
regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:36:06 -04:00
1628b728fa Always-visible placement ghosts + true-nearest 2D opening snap (#407)
* feat(editor): always-visible translucent placement ghost for openings + roof accessories

When a host-surface placement tool is armed, the node's real geometry now
follows the cursor everywhere as a translucent ghost: tinted invalid (red)
and unconfirmable off-host, snapping onto its host surface (wall/roof) with
the existing valid/invalid affordances when near. This replaces the old red
wireframe box (door/window) and red DragBoundingBox (roof accessories), so
the armed tool is visible before the cursor reaches a placeable surface.

- New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts):
  clones materials, disables raycast (avoids cursor-ray starvation),
  tints invalid; cleanup disposes only the clones.
- New door/window preview components built from the real geometry via new
  `buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float
  the ghost via a `fallbackPose` that is mutually exclusive with the on-host
  draft + wireframe outline.
- `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory
  tools pass their real preview (invalid-tinted) instead of a box `size`.

Snapping behavior is unchanged (no proximity snap yet).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): magnetic proximity wall-snap for door/window placement

The door/window ghost now follows the cursor over the floor like a moving
item and magnetically snaps onto the nearest wall within range (1.5 m), then
releases back to free-follow when the cursor moves away — instead of only
attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it
snaps onto a new spot, so it reads as moving a physical object that can only
land on walls.

- Plan-space proximity via the existing `findClosestWallInPlan` (the same
  helper the 2D floor-plan move uses): level-scoped, skips curved walls,
  returns wall + along-wall localX + side + wall-local rotation.
- `grid:move` drives the snap and `grid:click` commits when proximity-snapped;
  a direct wall-mesh hover (wall:enter/move) still owns the precise face side.
  Both paths share `applyWallTarget` (create the draft once, reparent only on
  an actual wall change) and a shared commit that refreshes alignment anchors.
- Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate
  (R3F + the grid raycast share the source DOM event) plus a `cameraDragging`
  guard and stale-`hostKind` reset, so a missed wall:leave during a camera
  orbit can't strand the draft.
- Window keeps its sill height on the floor path (the floor cursor carries no
  wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move.
- Shift bypasses the along-wall grid/alignment snap but still attaches to the
  nearest wall, matching the 3D-hover and 2D-move conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): door/window preset placement follows the cursor over open floor

The community preset/catalog flow places doors and windows through the
isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow:
the fresh clone was parented to the level at the origin and only became
visible once the cursor reached a wall, so over empty floor nothing tracked
the cursor. Now the move tools mirror the def.tool placement behaviour:

- Off-wall, the real node rides the cursor like an item (reparented to the
  level, positioned at the building-local cursor) so it's obvious what's
  being placed before it attaches.
- Within range of a wall it magnetically snaps on via findClosestWallInPlan
  (the same plan-space helper the 2D move uses), releasing back to free-follow
  when the cursor moves away, and plays the grid-snap sound on each new snap.
- grid:click commits only when snapped (open floor is a no-op — a door/window
  needs a wall); the wall/roof mesh-hover paths are unchanged and still own
  their own click. A per-pointermove timeStamp gate + cameraDragging guard
  keep the floor handler from fighting a wall/roof hover.
- Windows default to a ~0.9m sill while off-wall (fresh preset clones carry
  position [0,0,0], which buried half the window below the floor).

The wall/roof commit body is extracted into a shared commitToWall so the
mesh-click and proximity-click paths stay identical. Existing-node moves are
fully restored on cancel/unmount (the node stays isTransient through
free-follow). Standalone-editor def.tool placement already had this in a
prior commit; this brings the community move path to parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): 2D floorplan ghost follows the cursor for door/window placement

Mirrors the 3D free-follow in the top-down floor plan: while placing a door
or window, a loose footprint rectangle now follows the cursor over open floor
so it's obvious what's being placed before it snaps to a wall. The instant the
cursor nears a wall, the existing synthesized wall:enter/move path takes over
and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost.

- The opening-placement pointer-move handler in floorplan-panel sets a new
  `openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and
  clears it on a wall hit; a loose width × 0.1m rectangle renders at that point
  inside the floor-plan scene group (same world→SVG transform as every glyph).
- Width comes from the moving node or the kind default (door 0.9 / window 1.5).
- The ghost clears when opening placement ends (tool/mode change, cancel,
  commit) and on level change, so no stale rectangle lingers.

Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's
no host to orient the swing to. The shared door/window def.floorplan builders
are untouched — overloading them with a wall-less fallback would make
roof-hosted doors (parent is a roof segment, builder returns null today) draw
stray rectangles in plan. Keeping the preview in the editor layer avoids that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover

Three placement fixes plus a snapping revision, for both the standalone
def.tool path and the community isNew move path (door + window):

- 2D faithful ghost: the off-wall placement ghost now renders the real
  blueprint symbol (door swing arc / window panes) following the cursor, not a
  bare rectangle. Done by publishing a transient opening on a synthetic wall to
  usePlacementPreview (extended with a `parentNode` fed as the builder's
  ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit,
  on commit, on placement-inactive, and on level change.
- 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms
  for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide
  updated the 3D mesh but left the 2D symbol frozen. It now merges the
  wall-local live position/rotation onto the node (keeping parentId) so the 2D
  symbol slides with the cursor.
- R-flip during placement: pressing R now flips a door/window's facing
  (front ↔ back, rotation += π) before commit — the placement tools own R while
  placing (the global selection-based R/T handler stands down via
  isPlacingOpening so it can't double-fire). No-op on roof faces (front-only).
- 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the
  opening free-follows the cursor over open floor and snaps only when the cursor
  ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its
  0.5 m findClosestWallPoint padding since plan walls are thin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement

Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths
running at once: the floorplan-panel synthesized wall:*/grid:* events (driving
the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget.
They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while
3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner
(faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo
commit), so it now owns 2D opening placement when movingNode is set:

- floorplan-panel: the opening pointer-move branch + the registry grid catch-all
  + the background-click catch-all all now exclude the door/window MOVE case
  (`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they
  still drive pure raw-build placement, which has no movingNode). Without the
  catch-all exclusions the move case fell through to grid:move/grid:click, which
  re-drove the 3D tool's free-follow and consumed the commit click.
- R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`;
  door/window floorplan-move implement it (XOR the wall-derived side + π rotation,
  re-running the last apply). The overlay's keydown calls `session.flipSide()` on
  R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the
  active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double
  flip / double cue on one R press).
- Commit in 2D now flows solely through the overlay's pointerup (no competing
  synthesized wall:click), so click-to-place commits.

The global use-keyboard R/T already stands down during opening placement
(isPlacingOpening), so a selected node can't also flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall

Moving an existing door/window in the 2D floor plan: the move target's `apply`
early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on
its old wall instead of following the cursor between walls (3D free-follows),
and an off-wall confirm click committed the stale last-wall position — looking
like the placement failed.

Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move:
- Off-wall, `apply` free-follows the cursor — hides the real node and floats the
  faithful door/window symbol at the cursor via a synthetic wall published to
  `usePlacementPreview` (the same preview layer fresh placement uses). The real
  node is `visible:false` so the registry layer skips it (no double symbol).
- Back on a wall, it clears the ghost, reveals the real node, and snaps as before.
- `canCommit` returns false while off-wall, so an open-floor click reverts to the
  pre-move snapshot (door returns to its wall) instead of committing in mid-air —
  matching the 3D move, where clicking open floor commits nothing. On a wall the
  commit lands normally.

The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s
`visible:true` restore both guarantee the node is never left hidden after a move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius

The 2D door/window snap felt too aggressive and could grab a wall further away
than the one the cursor was actually nearest. Root cause in
`findClosestWallInPlan`: it compared a candidate's true segment distance against
the previous best's `perpDistance` (signed offset to the wall's infinite line,
not the clamped segment distance). Near a wall end those diverge, so a closer
wall could be rejected / a farther one kept.

- Track the best segment distance and keep the strict minimum — the wall chosen
  is now always the single closest segment to the cursor (true nearest), which
  resolves correctly when many walls sit close together.
- Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old
  radius snapped from far away. The opening now free-follows the cursor until
  it's genuinely near a wall.

Only the 2D move/placement targets use this helper (3D snaps on raycast hover);
wall-attached items share the same improved nearest-wall behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): true-nearest 2D opening snap with dev hit-area overlay

Round 7 of the placement-ghosts work. Make the 2D door/window wall snap
always pick the wall nearest the cursor, fix fresh-placement snapping/slide
in 2D, and add a dev-only overlay that visualises each wall's snap region.

- Extract the plan-space nearest-wall-segment math to core
  (`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment /
  nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan`
  delegates to it, so the snap and the debug overlay share one source of
  truth. WallHit contract unchanged.
- door/window 2D move now resolves the host level via the shared
  `getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement
  parented straight to the level — the last case previously resolved to the
  building, so a new opening never snapped in 2D).
- Cursor resolver switched to absolute mode: query the snap with the true
  cursor, not the original-wall position + grab delta, so it picks the
  cursor-nearest wall (matching the 3D move) instead of a far wall across a
  thin gap.
- 2D move clears any stale `useLiveTransforms` entry for the node each apply:
  the registry layer renders door/window from the live transform in
  preference to the scene node, so a leftover entry from the 3D tool froze
  the 2D slide for fresh / re-armed openings.
- Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor
  at y=0), matching the 3D MoveWindowTool.
- New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws
  each wall's snap hit area as an analytic capsule (no grid sampling), gated
  on a developer-menu toggle.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:24:27 -04:00
Aymeric RabotandClaude Opus 4.8 86e9b3c8bf feat(editor): placement-time + resize-time opening guides
Extend the opening proximity guides to two more interactions:

  - PLACEMENT: the door/window placement tools publish the same 3D guides
    (sill/head, edge proximity, sill alignment, equal-spacing) while a NEW
    opening is being dropped; window placement also snaps its sill to a
    neighbour's sill/centre/top (Shift bypass) — so "two windows aligned" reads
    during placement, not just move.
  - RESIZE: a new `onDrag` hook on the linear-resize handle descriptor lets the
    door/window width/height arrows publish live guides for the edge being
    resized — proximity to neighbours as the width grows, and the live sill/head
    as a window's height changes. The generic LinearArrow stays kind-agnostic;
    only door/window declare the hook.

Refactor (Codex review follow-up): one `publishOpeningGuidesForWallEvent`
wrapper now backs all four wall-event publish sites (door/window move +
placement) over a shared `makeWallToWorld`; window placement's repeated
sill-snap is a single `resolvePlacementY` helper. Opening guides clear on
commit / leave / cancel / roof-hover / unmount (mirroring the alignment-guide
lifecycle) and on resize end.

Codex-reviewed — no blockers; lifecycle/leaks, coordinate frame, sill-snap
precedence, and resize disposal confirmed. Typecheck + biome + 23 core + 170
nodes tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:18:24 -04:00
Aymeric RabotandClaude Opus 4.8 6caba97f1b feat(editor): 3D viewport proximity + sill + equal-spacing guides for openings
Wire the opening-guides service into the 3D door/window move tools and render the
wall-plane guides as the spatial twin of the 2D plan guides:

  - sill / head height (floor → bottom edge, top edge → wall top) — windows only
  - edge-to-edge proximity dimensions to the nearest neighbour each side
  - a sill-alignment line + SNAP when a window shares a neighbour's sill / centre
    / top (competes with the 0.5m grid, Shift bypasses) — the chosen
    "snap + guide" behaviour
  - Figma-style equal-spacing "=" badges across a run of openings

Adds `useOpeningGuides` (editor store) + `OpeningGuides3DLayer` (raw THREE.Line
overlays + Html pills, mounted beside Alignment3DGuideLayer) and a thin
`opening-guides-runtime` helper (collect siblings / sill snap / publish / clear)
called from the door + window move-tools at their per-tick `applyPreview` hook;
guides clear on commit / cancel / leave / roof-hover / unmount.

Guides render in the move cursor's building-local frame (reuses `wallLocalToWorld`)
so they track the dragged opening exactly. Codex-reviewed (roof-hover stale-guide
clear, collapsed-dimension suppression). Placement-time guides reuse the same
helper and are the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:15:52 -04:00
Aymeric RabotandClaude Opus 4.8 1f829d52ed feat(editor): draggable move handle for wall-hosted doors & windows
Doors and windows could only be moved via the floating action menu — their
3D handle rig declared width/height resize arrows but no move grip, and
Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make
direct-drag work for every bespoke-mover kind.

- door/window: add a `tap-action` `move-cross` handle (plane node-normal,
  portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It
  routes through the existing per-kind move tool (3D `affordanceTools.move`,
  2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall —
  so the grip, the floating Move button, and the 2D plan's move dot share one
  pipeline. Grab-drag-release commits without a second click.

- canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move`
  (the 3D-mountable move paths) instead of `movable` only, so doors/windows/
  walls/slabs/stairs/… are draggable in 3D as they already are in 2D.
  Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The
  floating helper auto-syncs (it reads canDirectMoveNode).

- TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross
  [π/2,0,0] into the wall face — previously ignored, so the item wall grip
  rendered flat too. Now door/window/wall-item crosses lie in the wall.

- use-node-events: split the drag-suppression gate. `inputDragging` still
  suppresses SELECTION events (the synthesized release-click would re-select),
  but no longer suppresses SPATIAL events (enter/move/leave) — a
  surface-following move tool runs with `inputDragging` set and needs
  wall:move to track the cursor. General consumers that must ignore drags
  (viewer hover, box-select) already self-gate on `inputDragging`; the
  editor's select-hover and paint-preview enter handlers now gate on it too.

- handle-arrow: make handle hit areas inert while `placementDragMode` is set,
  so a move grip riding the dragged node can't intercept the ray and starve
  the move tool's surface raycast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:51:37 -04:00
Aymeric RabotandGitHub 5411f5abc8 fix(editor): improve guided manipulation and snap affordances 2026-06-11 23:59:15 -04:00
Aymeric RabotandGitHub aab48e053f fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00
Aymeric RabotandClaude Fable 5 ee7bb39605 style: biome format/import-order for refactored tool files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:36:19 -04:00
Aymeric RabotandClaude Fable 5 aa3b0ef758 refactor: release-review cleanup for roof wall openings
Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:

- fix: clone-scene-graph remaps roofSegmentId like wallId in both
  clone paths — duplicated scenes/levels kept pointing roof-hosted
  children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
  four door/window tools into shared/roof-wall-opening-placement.ts
  (resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
  worldToSelectedBuildingLocal); tools keep the stateful lifecycle
  (drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
  markWallDirty→markHostDirty (they hold segment ids too); capability
  cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
  before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
  was the standalone Build tab, which now enumerates the registry
  inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
  "segment-local" comment fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:33:13 -04:00
Aymeric RabotandClaude Fable 5 7879b83df3 feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.

- Children store roofFace + position [u, v, z-from-mid-plane] with
  rotation 0 in-frame — the exact wall-child conventions (the wall
  volume's mid-plane lands on the nominal footprint). A shared
  <RoofFaceHostFrame> derives segment pose + face frame from the
  live-override-merged segment: children follow resize handle drags in
  real time and never jump on commit. No re-anchor cascade needed —
  position is authoritative, the frame is derived. migrateNodes
  converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
  coordinator (surface 'roof-wall'), Shift free-place normalized with
  walls, ItemSystem wall-side push extended to segment hosts, correct
  2D plan glyphs via face→segment→roof pose composition. The roof
  hit resolver + overlap guard moved to @pascal-app/editor (the
  coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
  merged-shell and per-segment CSG paths (full edit mode / painted
  segments used to lose every hole), built from the CURRENT host
  geometry and live-effective children so holes follow segment and
  opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
  into the portal frame instead of composing parent+node registry
  poses — correct for any nesting (the face-frame group broke the
  old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
  duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
  re-anchor clears and every revert restores them.

Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:21:14 -04:00
Aymeric RabotandClaude Fable 5 bdfee058bd feat: doors and windows on roof-segment wall faces
Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.

- core: roof-segment-walls.ts models the four vertical faces as 2D
  frames (u along face, v height) with convex profile polygons that
  mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
  clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
  segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
  subtracts from the wall brush only. cascadesViaHostSegment keeps the
  roof-merge loop from consuming door/window dirty marks (their own
  systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
  preset path), with roofSegmentId cleared/restored across every
  roof<->wall re-anchor and revert; shared hit resolver normalizes
  normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
  mode — the uncut rebuild used to draw over the merged shell's fresh
  opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
  visibility; stale uncut segment CSG inside the hidden segments-wrapper
  blocked the player at openings the merged shell had cut through.

Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:39:13 -04:00
ce6f999310 arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* 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

* editor: per-door-type floor-plan symbols

Render a distinct, static plan symbol for each door type in the
registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`),
independent of the door's live open/close animation:

- single / hinged: fixed 90° swing with a dashed quarter-circle arc
- double / french: two mirrored half-width leaves + dashed arcs
- folding / bifold: static zigzag accordion (~80% span) on the wall face
- sliding: bypass — two overlapping panels on parallel tracks + arrow
- pocket: thin white leaf, ~60% closed, sliding into the solid wall
- barn: surface-mounted panel parked over the wall, dashed closed-ghost
  + slide arrow

The swing arc is dashed in screen-pixel units (the renderer uses
non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection
/ slideDirection as appropriate.

Also includes pre-existing working-tree changes unrelated to the door
symbols: group move/rotate transform and box-select tweaks, and a
regenerated ifc-converter next-env.d.ts.

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

* Fix recessed ceiling fixtures and draw safety

* feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D)

Snap the wall draft / endpoint-move point onto existing wall geometry —
corners, midpoints, wall–wall intersections, and along-wall edges — and
show a beacon at the snap point whose glyph encodes what it caught
(square = corner, triangle = midpoint, ✕ = intersection, circle = edge).

- Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested).
- Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon
  and a 2D SVG glyph beacon, both indigo to match the alignment guides.
- Gated by a new persisted "Magnetic snap" toggle in the Display menu
  (useEditor); honored by draw + commit + endpoint-move in both views.

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

* editor: garage and open-doorway floor-plan symbols

Extend the per-door-type plan symbols in the registry floor-plan
builder (packages/nodes/src/door/floorplan.ts):

- open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel
  (mirrors the 3D system, which renders only the cutout for openings)
- garage sectional: closed leaf + side tracks into the garage + dashed
  parked ghost at the inner end
- garage roll-up: closed leaf + coil barrel (capsule) with a coil hint
- garage tilt-up: closed leaf + dashed parked panel + dashed curved
  up-and-over swing path
- gate the swing arc to actual swing doors (hinged/double/french) so
  other types fall back to the plain footprint

Garage mechanisms sit on the interior (door-local -z) side to match the
3D garage builders, independent of swingDirection.

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

* arch: enforce layer boundaries — registry dispatch, store relocation, shared helper

Three architectural fixes to bring the branch into full compliance:

1. **ceiling-system kind check → CeilingCutCapability**
   Replace `child.type === 'item'` branch in `ceiling-system` with registry
   dispatch. Add `CeilingCutCapability` type to `packages/core` registry types,
   implement `buildCeilingHole` on `itemDefinition`, and rewrite
   `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through
   `nodeRegistry` — viewer never again inspects a node's kind directly.

2. **useAlignmentGuides + useWallSnapIndicator → packages/editor**
   These stores are editor-only UI (snap beacons, alignment guides). Move them
   from `packages/core/src/store/` to `packages/editor/src/store/`, re-export
   from `packages/editor`, and update all 34 consumer files across
   `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`.

3. **findLevelAncestorId extracted to core**
   `item-light-system` had a private `resolveNodeLevelId` that duplicated
   level-ancestor traversal logic. Extract it as `findLevelAncestorId` in
   `packages/core` (spatial-grid-sync), export it, and replace the local copy.

All four packages typecheck cleanly (zero errors).

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

* chore: fix lint, untrack .claude/launch.json

Run bun check --write to clear 8 Biome errors (formatting + import order
+ one unused import). Untrack .claude/launch.json and add it plus
.claude/settings.local.json to .gitignore so local IDE/agent configs
stop landing in commits.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:14:39 -04:00
Aymeric Rabot 73cd6e8614 fix(editor): strip placement metadata on commit 2026-06-08 02:12:01 -04:00
Aymeric Rabot 8dc602caa9 Improve editor manipulation flows 2026-06-08 01:07:53 -04:00
Aymeric Rabot be0f491bbd Fix relative move drag offsets 2026-06-07 18:18:46 -04:00
1419709131 editor: structure build-start/end cues + menu hover/click SFX (#378)
* wip sfx

* wip audios

* feat(editor): structure build-start/end cues + menu hover/click SFX

Sound design pass for the build/items experience:

- Split structure-build into a start cue (draft begins) and end cue
  (commit). Wall/fence fire start on the first click; slab/ceiling/zone
  tick start on every non-closing vertex; roof on the first corner.
- Doors and windows now use the structure-end cue on place + move;
  shelves use item-place instead.
- New menu_hover (round-robin retired in favour of a single sample) and
  menu_click cues wired to the Build/Items main categories, item-catalog
  tiles, the icon rail / tab bar, and the bottom action menu (via the
  shared ActionButton).
- Refresh item_pick / item_place / item_rotate samples; drop the
  now-unused numbered menu_hover variations.

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

* fix(editor): silence forEach return lint in updateSFXVolumes

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

* chore(editor): convert SFX wavs to mp3 to shrink assets

Re-encode all SFX samples as 128kbps mp3 (~94% smaller, transparent for
short UI cues) and point the player at the .mp3 sources. Drops the .wav
originals; the freshly converted item_pick/place/rotate.mp3 override the
older committed mp3s.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:15:24 -04:00
46f94b97b3 editor: alignment guides + floor-plan move/placement parity (#372)
* 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

* feat(editor): 3D alignment guides for item/wall/fence move + placement

Bring Figma-style alignment guides into the 3D editor, reusing the shared
pure resolver (`resolveAlignment`) and ephemeral guide store
(`useAlignmentGuides`) that previously only drove the 2D floor plan.

Core:
- `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner
  anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's
  line and distance read to the candidate's nearest edge, not the far side.
- `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to
  item edges, never centrelines.
- `resolvePointSnap` (point-coincidence variant; kept for future use).

Editor:
- `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill,
  in the project's indigo accent, mounted inside ToolManager's building-local
  group so guides render in the cursor's frame.
- Producers wired in the item move tool, item placement coordinator, and the
  wall + fence endpoint tools; walls and fences cross-align.

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

* fix(editor): align guides snap to nearest real anchor, drop bbox re-span

The 3D alignment guide could place its end dot in empty space: a diagonal
wall (or any rotated / non-rectangular object) has bounding-box corners that
don't lie on the object, and `refineGuidesToGap` re-spanned the guide to
exactly those AABB edges — so the dot floated "along the coordinate" rather
than on the item.

- `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the
  perpendicular axis (after the tightest axis match). Anchors are real points
  (corners / endpoints / midpoints), so the guide always connects to the
  closest actual point — which also yields the facing-edge gap distance.
- All four producers (item move, item placement, wall + fence endpoints) now
  publish the raw resolver guides; the AABB nearest-edge re-span is gone.
- Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers
  (and their tests / exports).

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

* feat(editor): group move handle + shared group-transform core, move-tool polish

Add a group-move gizmo alongside the existing group-rotate handle, both
driven by a new shared `group-transform-shared` module (participant
classification, group-box + corner math, connected wall/fence component
expansion so attached structure transforms rigidly as one piece).

- core: refactor alignment-anchors collection + tests, extend handle registry
- editor: group-move-handle, group-transform-shared; rotate handle reuses them;
  node-arrow-handles gains click-swallow guard; box-select + placement tweaks
- nodes: move-tool updates across ceiling/column/slab/roof/registry; door math
  and panel adjustments; item definition cleanup
- nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the
  wall endpoint tool (fixes missing audio feedback on fence side drag)

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

* fix(editor): keep autosave alive across page unload

The autosave debounces writes by 1s and relies on a `beforeunload` flush
for anything still pending. That flush fired a plain `fetch` PUT, which
the browser cancels the instant the page unloads — so refreshing right
after an edit (e.g. painting a roof material) silently dropped the change
and the reload showed the last persisted scene.

Thread a `{ keepalive }` option through the save callback and set it on
the unload flush so the request survives the unload. Also listen for
`pagehide` (fires where `beforeunload` does not, e.g. mobile Safari /
bfcache) and clear the dirty flag up front so the two listeners don't
double-send. Normal debounced saves omit `keepalive` (its 64KB body cap
only constrains the best-effort unload flush, not regular saves).

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

* feat(editor): paint eraser + reset-all, drop roof cross-role bleed

Material paint gains an eraser (clear a surface back to its default) and a
"Reset all" action that defaults every painted surface on a node — for a
roof that includes each child segment — via a generic
`buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific
material fields without per-kind knowledge.

Also stop a single painted roof surface from bleeding onto the others:
`getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment
renderer no longer cross-fall-back between top/edge/wall. An unset role
resolves only to the legacy catch-all (back-compat) or the theme default,
so painting just the shingle, trim, or soffit stays on that surface.

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

* feat(editor): alignment guides + drag bounding box across tools

Extend the Figma-style 3D alignment guides to the placement and move
tools for columns, elevators, roofs, stairs, ceilings, slabs, fences,
walls, doors, and windows: each collects alignment anchors from the
scene, resolves a snap within the shared threshold, and drives the
`useAlignmentGuides` overlay. Wall openings (doors/windows) only snap
along their host wall via the new `wall-opening-alignment` helper.

Add a shared `DragBoundingBox` overlay (exported from the editor barrel)
that renders the dragged object's bounds during a move, wired into the
column move tool alongside the alignment snap.

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

* fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles

Build the linear resize-arrow's drag plane so it always contains the
handle's axis (view direction minus its along-axis component) instead of
a plane that merely faces the camera. The old camera-facing normal
collapsed when the axis pointed toward the viewer — screen motion barely
changed the axis component, so the resize crawled or stopped tracking the
cursor. Also slim the extruded arrow/handle geometry (shared by the node
arrows, wall side handles, and polygon editor) for a lighter gizmo.

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

* fix(editor): fill-block wall opening highlight + selected frameless openings

Draw the selected-wall opening highlight as a translucent block filling
the cutout volume (front-side culled) instead of a single vertical pane,
so it reads as an occupied slot from any angle — including a top-down
floorplan view where an edge-on pane was invisible. Also highlight a
directly-selected frameless opening (a `door` with openingKind
`'opening'`), which otherwise renders no geometry of its own, and reflect
live drag overrides via `useLiveNodeOverrides`.

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

* fix(viewer): double-side slab hole side-walls

Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall
quad is emitted twice with opposite winding. The slab material is forced
to FrontSide (DoubleSide poisons the MRT scene pass), under which
ExtrudeGeometry's single-sided hole walls get back-face culled and you
see straight through the cut. The doubled quads keep the cut's inner
thickness visible from any angle without z-fighting.

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

* fix(editor): box-select building-scoped nodes like elevators

Building-scoped selectable nodes (e.g. elevators) are children of the
building, not the active level, so the level walk never reached them.
Also walk the level's building children and box-test any registry-
selectable kind by its rendered bounds, matching the column/stair/shelf
path.

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

* feat(editor): shelf placement alignment + column placement ghost

Shelf placement now snaps to Figma-style alignment guides by its
footprint edges (layered on grid snap, Alt bypasses), matching the
existing 3D move tool. Guides refresh after each drop and clear on
teardown.

Column placement migrates to the registry `def.tool` path so it can
render a translucent column ghost at the cursor (like the shelf build
tool) instead of a bare cursor sphere — the editor package can't import
the column geometry, so the tool now lives in packages/nodes:
- extract `ColumnBody` from the renderer and add a `ColumnPreview`
  (cloned translucent material, raycast disabled, origin-positioned)
- new `column/tool.tsx` registry placement tool with the same
  footprint-edge alignment as shelf / column move
- wire `def.tool` + tool hints; drop the now-unreachable legacy
  editor-side `ColumnTool` and its dead file

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

* feat(editor): floor-plan alignment, pivot moves & placement ghosts

Bring the 3D editor's Figma-style alignment experience to the 2D floor
plan across every node kind, fix pivot semantics on move, and add 2D
placement ghosts.

Alignment
- Wall anchors now include ±thickness/2 face corners so columns/items/etc.
  snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D.
- Shared apply-alignment helper (applyFloorplanAlignment /
  alignFloorplanDraftPoint, with excludeIds) used by move sessions,
  structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence
  endpoint drags.
- Door/window/wall-item moves get along-wall edge-to-edge snapping.
- Generic free-translate move path aligns by edges (corner anchors).

Pivot moves (2D)
- Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared
  polygon-centroid mover; stair moves by origin→cursor; matching 3D.
- Shelf/column move targets write position directly (single source of
  truth) so the 3D group no longer sticks on commit.

Placement ghosts (2D)
- usePlacementPreview store + FloorplanPlacementPreviewLayer render a
  kind's def.floorplan footprint following the cursor; wired for column
  and elevator.

Fixes
- Elevator placement no longer deselects the active floor plan
  (preserve levelId through setSelection's hierarchy guard).
- Guides clear on every commit/cancel/unmount path.

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

* refactor(editor): address architecture review for floor-plan work

- Move usePlacementPreview store from core to editor: placement ghosts
  are an editor/tool concern the read-only viewer never needs. Rewire the
  column tool (via the @pascal-app/editor public surface) and the
  editor-internal elevator tool + preview layer (relative imports).
- FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead
  of bulk-reading the nodes map during render.
- wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind
  note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta).

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

* fix(nodes): recreate window draft on wall:move when null after placement

After a successful click-to-place, the click handler deletes the
transient draft and relies on the wall-rebuild → R3F pointer-enter
cascade to create a fresh draft for the next placement. If that cascade
doesn't fire synchronously (e.g. async geometry rebuild) the next
wall:move receives a null draftRef and bails — requiring leave/re-enter
to place again.

Fix: in onWallMove, when draftRef.current is null but we're hovering a
valid wall, recreate the draft immediately (same WindowNode.parse +
createNode path as onWallEnter). This is idempotent: if wall:enter does
fire first, destroyDraft() in onWallEnter cleans up cleanly.

Preserves parity with door multi-place behaviour, matching #367's intent.

* fix(nodes): recreate door draft on wall:move when null after placement

Mirror of the window fix one commit back: after click-to-place the
DoorTool deletes its transient draft and relies on the wall-rebuild \u2192
R3F pointer-enter cascade to spawn a fresh draft for the next placement.
When that cascade doesn't fire synchronously, the next wall:move sees a
null draftRef and bails \u2014 forcing a leave/re-enter to place again.

Recreate the draft in onWallMove when null and over a valid wall on the
current level. Idempotent with onWallEnter (destroyDraft cleans up if
both fire).

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Pascal <open@pascal.app>
2026-06-04 14:03:00 -04:00
0ad0ac1591 chore(editor): delete legacy door/window presets UI (#339) (#343)
Retires the door/window-only `PresetsPopover`, its `presets-context`
adapter, and the `PresetThumbnailGenerator` ahead of the unified preset
system landing via the items catalog. Drops the public exports
(`PresetsPopover`, `PresetsAdapter`, `PresetsTab`, `PresetsProvider`,
`usePresetsAdapter`) and the matching `presetsAdapter` prop / provider
wrappers on `<Editor>`, plus the `preset:generate-thumbnail` /
`preset:thumbnail-updated` event types in core. Door and window panels
now render the regular parametric inspector with no popover trigger;
`materialPreset` stays untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:08:41 -04:00
1fd59dd9cd feat(editor): preset-system primitives (#340) (#341)
* feat(editor): preset-system primitives — presettable, sceneApi subtree round-trip, isolate + setCaptureMode enum, headless exports

Per pascalorg/editor#340 (redesigned: single live canvas, no Viewer scene prop).

Core
- `capabilities.presettable` on `NodeDefinition` + `isPresettable` /
  `isPresettableKind` helpers. Explicit `false` on level / building /
  site / zone / spawn / guide / scan / item; implicit `true` for any
  kind with `def.parametrics`.
- `sceneApi.getSubtreeSnapshot(rootId)` + `materializeSubtree(subtree,
  position, parentId?)` for round-tripping a node subtree through
  catalog storage. Strips id / parentId / absolute root position /
  host refs (`wallId`, `wallT`); fresh IDs minted at materialize time;
  child ordering preserved (FIFO walk).

Viewer
- `<Viewer isolate>` prop + `ViewerHandle.setIsolated(ids | null)`.
  Walks `sceneRegistry`, hides every registered group not in the
  isolated set's ancestor + descendant closure. Building block for
  preset capture + future focus-mode UX.

Editor
- `useEditor.captureMode: CaptureMode` discriminated union
  (`idle` | `standard` | `preset`). `isCaptureMode` stays as a derived
  boolean for the existing read sites; `setCaptureMode` accepts both
  the boolean shape (back-compat) and the enum.
- `preset` capture mode in `SnapshotCaptureOverlay`: drag locked to a
  square, mode-picker hidden, transparent flag forwarded through the
  `camera-controls:generate-thumbnail` emitter event.
- Headless exports: `Inspector` (alias of `ParametricInspector`),
  `FloatingMenu` (alias of `FloatingActionMenu`), `ToolbarLeft` /
  `ToolbarRight` (aliases of `ViewerToolbarLeft` / `ViewerToolbarRight`),
  `useSelection` hook returning `{selectedIds, selectedNode, building/
  level/zone}`, plus re-exports of `useScene` / `useViewer` from core /
  viewer so consumer shells (community, embedders) need only one import.

Out of scope by design (see issue #340 "Out of scope"): a separate
offscreen Viewer rendering an arbitrary subtree. The unified preset
modal captures inside the live canvas via isolation + the existing
snapshot pipeline — no `useScene` factory / React context refactor.

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

* refactor(editor): split snapshot/materialize into pure getSubtree + cloneNodesInto; add def.hostRefFields; auto-stage preset capture square

Per pascalorg/editor#340 redesign discussion: the editor's scene API
should expose *pure* primitives and let the host (community modal,
embedders) own storage shape, position stripping, and host-ref
re-derivation policy.

Editor API delta
- `sceneApi.getSubtreeSnapshot(rootId)` → `sceneApi.getSubtree(rootId)`
  Returns the live subtree verbatim (BFS via `children[]`, no clones,
  no stripping). Callers deep-clone if they need persistence.
- `sceneApi.materializeSubtree(subtree, pos, parent?)`
  → `sceneApi.cloneNodesInto(nodes, { rootId, parentId?, position? })`
  Generic clone-and-insert. Deep-clones via JSON, mints fresh ids
  preserving the prefix, rewires parent/children, stamps position +
  parent if supplied. Host-ref-agnostic — `wallId`/`wallT` etc are
  preserved verbatim.
- New `capabilities.hostRefFields?: string[]` on `NodeDefinition`.
  Declares per kind which schema fields are placement-derived so the
  host strips them at preset-save time. Declared on door (`['wallId']`),
  window (`['wallId']`), item (`['wallId', 'wallT']`).
- New `getHostRefFields(def)` exported from `@pascal-app/core`.

Removed the intermediate token-based payload format (`NodeSubtree`,
`buildSubtreeSnapshot`, `materializeSubtree`, `SubtreeNode`).

UX polish
- `<SnapshotCaptureOverlay>` in `preset` mode now auto-stages a centered
  square crop sized to ~75% of the shorter viewport dimension. The
  user can pan / move / resize within square-aspect, but doesn't have
  to drag from scratch — clicking the capture button works
  immediately on entry.

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

* fix(editor): lock preset capture frame; allow item presets

- SnapshotCaptureOverlay: in `preset` mode, the auto-staged centered
  square is now fully locked — corner handles hidden, the dim layer is
  click-through (no drag-to-move, no drag-to-resize). The user just
  adjusts the camera (orbit / pan / zoom) and clicks capture. The
  letterbox + dashed border stay visible as a cosmetic frame.
- `item.capabilities.presettable` removed (implicit `true` via
  `def.parametrics`). Enables compositions like "table-with-plants",
  "shelf-with-books" where the preset root may be an item and other
  items ride along as descendants. The GLB-kind item catalog is
  unchanged; presets become siblings of GLB rows under the same
  `items` table.

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

* feat(editor): auto-frame camera on preset capture entry; restore on exit

`<CustomCameraControls>` now watches `useEditor.captureMode` and, when
preset capture mode begins, flies the camera to a pose that fits the
union bounds of the isolated subtree inside the locked square crop —
no more hunting for the subject after opening the modal. The
pre-capture pose is stashed and restored on exit so the user lands
exactly where they were.

The user can still pan / orbit / zoom from the auto-staged pose if
they want a different angle before snapping.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:49:50 -04:00
c00a2469fd 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>
2026-05-27 14:06:06 -04:00
bb5ce68254 Viewer render modes: Solid/Rendered + textures + surface-role clay + scene themes + edges (#332)
* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating)

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

* viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered)

Shading-aware material factories (cached per class), reactive selection in
renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for
geometry/door systems. Rendered mode output unchanged.

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

* viewer: Phase 3a render-modes surface roles + clay palette foundation

Adds surfaceRole token to core NodeDefinition, per-kind default roles,
ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing
stays translucent), and the textures-off recolor path for def.geometry kinds
(slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer-
based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b.

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

* viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds

Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/
stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial,
reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof /
underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator=
joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged.

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

* editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence

Per-context shading via renderContext discriminator + shadingByContext (persisted);
<Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the
editor action bar + standalone toolbar + command palette. Editor mounts default to
renderContext=editor / shading=solid.

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

* viewer: fix window-system glassMaterial type to allow clay glazing reassignment

The let was inferred as MeshLambertNodeMaterial from the imported glass constant,
so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed
under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build
(check-types had replayed a stale turbo cache).

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

* viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2

Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill
directional; the hemisphere covers the shadow-side fill it provided, at one
fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered
since the hemisphere now carries soft fill. Intensities are a starting point —
tune visually on the gpu-perf overlay.

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

* viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient

Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and
lower ambient so directional shading reads as form and undersides ground without
AO. Keeps Solid free of any post-processing pass.

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

* chore: biome format render-mode files (lefthook pre-commit)

Formatting-only — import wrapping, dep-array wrapping, single-line ternaries —
across Phase 2-4a files that weren't biome-clean. No logic changes.

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

* viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...)

New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone
mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient).
sceneTheme state (persisted) + cycle-button picker in editor bar + standalone
toolbar, importing the registry from the viewer barrel (single source). Default
'studio' reproduces the prior look exactly; app light/dark 'theme' untouched.

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

* editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar)

Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with
DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line
detail; scene theme lists all themes with a derived color-swatch strip + active
check. Imports the registry from the viewer barrel (single source).

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

* editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel

Replicate the render-mode + scene-theme dropdown pickers (with swatch strip +
active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx.
Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app.

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

* viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy)

EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building
meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes),
rebuilt on geometry-uuid or mode change, line color follows scene-theme
background luminance; sketchy = static TSL vertex jitter. New 'edges' state
(persisted, default off) + Edges dropdown in editor bar + standalone toolbar.

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

* viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode

Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to
LineSegments2 + Line2NodeMaterial so edges have real screen-space width
(soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now
off/soft/strong (sketchy removed).

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

* viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution

material.resolution is undefined under WebGPU (the node material reads the
viewport internally); optional-chain the .set() call so it no-ops there instead
of throwing. Thickness still applies via linewidth.

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

* viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong

Position-only mergeVertices before EdgesGeometry so coplanar triangles from
CSG-cut walls (doors/windows) share vertices and their interior edges are
suppressed — only opening outlines + silhouettes remain. Strong linewidth
3 -> 2px (was too heavy).

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

* viewer: edge overlay — crease-only extractor to fix CSG spiderweb

Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges)
with buildCreaseEdges: weld positions, keep only edges shared by exactly two
faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut
walls/slabs are watertight so real corners + opening outlines survive while the
interior triangulation fans (coplanar or T-junction) are removed. Open meshes
(bare ground plane, billboard leaves) shed their boundary clutter too.

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

* viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges

The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after
CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real
crease while staying far below feature size (wall thickness, openings).

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

* viewer: replace geometry edge overlay with screen-space ink (SketchUp look)

Port the prototype's screen-space ink into the post-processing pipeline: depth +
normal Sobel reading the scene-pass MRT. Crease term (normalized normals,
center-vs-neighbour) + distance-independent depth-step term (raw Laplacian /
(1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it
finally handles CSG-cut walls/openings without the spiderweb or missing-edge
problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode;
MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance.

Removes EdgeOverlaySystem + crease-edges (geometry approach).

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

* viewer: soft/strong ink modes + keep editor overlays out of the ink

Two adjustments to the screen-space ink pass:

1. Soft vs strong now visibly differ. The edge masks saturate, so the old
   `intensity` gain did nothing once a line was detected. Replace it with a
   sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px /
   100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`.

2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get
   inked. The scene pass that feeds the depth/normal MRT now renders only
   SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are
   composited on top after the ink + outlines, so they read as crisp UI and
   never get inked or AO'd.

   New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so
   the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move
   arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER.

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

* viewer: render the grid in the scene pass so geometry occludes it

The depth-gate fix couldn't help the grid: its material is depthWrite:false,
so it never wrote overlay-pass depth and the "didn't write depth -> keep on
top" term forced it on top — hence the floor grid bleeding through walls and
objects.

A full-floor plane can only be occluded correctly by living in the same depth
context as the scene, so move the grid onto its own GRID_LAYER which the scene
pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the
screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The
grid camera layer is enabled in custom-camera-controls and disabled on the
thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER.

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

* viewer: strong ink — match soft's 1px thickness, differ by darkness only

Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it
for both modes and let strong distinguish itself purely by being fully solid
(opacity 1) vs soft's lighter 50%.

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

* viewer: shadow frustum follows the view + a shadows on/off setting

The directional light's ortho shadow camera only covers ±50 around the light
target, which was pinned at the origin — so zones far from origin received no
shadows no matter where the camera moved. Recentre each shadow-casting light
(position + target together, preserving direction) on the view focus every
frame: the orbit-controls target when available, else the camera's ground
projection. The shadow area now tracks wherever the user looks.

Also add a persisted `shadows` toggle (default on) to the viewer store and a
"Shadows" switch in the editor settings panel — the dedicated shadows control
the render-modes plan deferred. Lights gate castShadow on it.

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

* editor: shadows toggle in the standalone toolbar + a touch more shadow strength

The shadows switch I added only lived in the cloud settings panel's Visibility
section, which is hidden in the local/standalone editor (no projectId). Add a
ShadowsToggle button next to the grid toggle in the standalone toolbar so it's
reachable there, matching how Show Grid is exposed in both places.

Also push shadow strength partway toward the aesthetic prototype (which runs
near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and
tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design.

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

* nodes: site ground receives shadows (lit material instead of unlit Basic)

The site ground fill used MeshBasicMaterial — unlit, so it could never show
the directional shadow, and shadows visibly truncated at the slab edge. Swap it
for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry
is the site polygon (slab footprints punched out), so shadows now extend across
the whole site and stop at its boundary, which is the desired bound.

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

* themes: per-theme clay palettes + 2x2 swatch in the theme pickers

Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing)
giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm
walls. The theme pickers (standalone toolbar + community overlay) now render the
aesthetic-style 2x2 swatch of those role tints over the theme background instead
of the old 3-colour strip.

Data + UI only; wiring the tints into the textures-off surface materials is a
separate change.

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

* editor: slimmer theme switcher + cloud-sun icon

Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving
space for "Mediterranean"; the label truncates when it overflows. Swap the
palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark
Sun-Moon toggle) in both the standalone toolbar and the community overlay.

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

* editor: theme switcher icon -> swatch-book

Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone
toolbar and the community overlay.

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

* themes: colour untextured building surfaces by the active scene theme

Untextured walls/roof/slab/ceiling now take the active theme's per-role colour
(theme.clayTints[role], falling back to the colour preset) in BOTH textures
modes. The textures toggle only governs surfaces that actually have an explicit
material/preset — those still show their texture when textures are on. This is
what makes e.g. Mediterranean read as a blue roof + warm walls instead of the
old hardcoded white/grey defaults.

- materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional
  sceneThemeId (theme tint ?? preset palette); theme folded into the cache key.
- wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured
  fallback now resolves to the themed role colour instead of white/grey, in both
  modes; theme threaded into each builder + material cache key.
- wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall
  materials when any change (previously it ignored textures/colorPreset entirely).
- geometry-system: threads sceneTheme into the generic surface-role path + rebuild
  effect. Renderers/preview call sites thread sceneTheme through.

Doors/windows/stairs/columns/items still use their existing defaults — a
follow-up pass.

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

* fix(slab): recessed (negative-elevation) slabs extrude downward again

The registry geometry builder created the slab mesh at Y=0 without applying the
negative-elevation offset, so recessed slabs rendered above the floor plane
(pool geometry is built locally with its floor cap at Y=0 and walls rising to
Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The
runtime slab-system already did this; the static builder path didn't. Mirror it:
shift mesh.position.y by elevation when negative. Positive elevation unchanged.

Unrelated to render modes — bundled into this branch's PR.

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

* fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0

Legacy roof nodes (old format, no `children`) were migrated with a hardcoded
`wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`)
went negative in getRoofSegmentBrushes, producing geometrically invalid brushes;
three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every
frame and emitted NaN positions, so the merged roof geometry failed
computeBoundingSphere and never rendered.

- core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema
  default), so migrated segments have a valid wall height.
- roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can
  never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result
  with NaN positions is discarded (keep the last good mesh, warn once per roof)
  instead of poisoning the buffer + spamming the console.

Unrelated to render modes — bundled into this branch's PR.

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

* fix(column): columns cast and receive shadows

Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/
receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls,
slabs and roofs. Set both on all column shape meshes.

Unrelated to render modes — bundled into this branch's PR.

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

* viewer: default edges to soft

Editor defaults are now solid shading / studio theme / soft edges / shadows on.
Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were
already the defaults; edges was 'off' — make 'soft' the default.

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

* themes: fold light/dark into the scene theme (remove the separate toggle)

The viewer had two overlapping appearance controls: a light/dark `theme` toggle
AND scene themes (which already drive the 3D background + lights). They
conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was
an independent axis still tinting the 2D scene chrome.

Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each
SceneTheme (twilight/night = dark, the rest = light) and drive everything the
old toggle drove off it — canvas backdrop, grid line colours, measurement-label/
cursor/site-edge contrast, the site ground fill, the ground occluder, and the
mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed
body class).

Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark
toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme
switch, the command-palette command, and the ifc-converter preview toolbar
button.

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

* themes: per-theme ground colour + add the "Verdant" nature theme

- Add a `ground` colour to every SceneTheme and drive the site ground fill +
  the infinite ground-occluder off it (instead of the binary
  isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground
  (twilight #4a4566, night #2b3247) so the ground reads as ground rather than
  going near-black.
- Add a new green/nature scene theme "Verdant": soft green sky + lit, with a
  green roof clay tint and mossy ground.

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

* wiki: document the surface-colour / theme system

The colour-per-node/renderer/system model from the render-modes work was
undocumented. Add wiki/architecture/materials-and-themes.md covering surface
roles, colour presets, the textures axis, scene themes (appearance / ground /
clay tints), and the "untextured surfaces are theme-coloured in both modes"
invariant + where each kind wires it.

Also fix two pages that the same work made stale:
- node-definitions: geometry builders receive (shading, textures, colorPreset,
  sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole.
- layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new
  GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the
  overlay pass.

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

* nodes: wire #330 roof-accessory kinds into the surface-role/theme colours

PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent,
box-vent) use custom renderers, so the generic textures-off recolour path never
reached them — they fell back to hardcoded colours. Wire each renderer into the
render-modes system: read shading/textures/colorPreset/sceneTheme and resolve
untextured surfaces via createSurfaceRoleMaterial (and force the role colour when
textures are off), matching column/ceiling.

Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing,
frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof;
solar-panel frame→roof (the dark product-specific cell face is left as-is). Each
definition also gets its dominant `surfaceRole` token.

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

* fix(roof): legacy-roof migration must use a non-zero wallHeight

The render-modes/#330 merge left the legacy roof→roof-segment migration writing
`wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall
CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and
yields NaN positions — so the migrated old roof never renders. Use the schema
default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged-
geometry NaN guard added earlier stay as defense-in-depth.

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

* fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry)

getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an
undefined or NaN pitch (a segment from an older migration that set `roofHeight`
instead of `pitch`, or stale persisted data) slips past that check and computes
Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof
CSG spews "Coplanar clip not handled" and NaN positions, so the roof never
renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the
flat frame. Self-heals bad data regardless of how the segment was produced.

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

* fix(roof): migration guarantees a positive pitch for every roof-segment

Segments saved with neither a valid pitch nor a roofHeight (older/partial
saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch
branch, leaving pitch undefined. The slope-frame guard then resolved them
to a flat frame, so the roof rendered as a slab instead of pitched. Branch
2b now normalises any segment lacking a valid pitch: derive from roofHeight
when present, else fall back to the schema default (40deg). The migration
result is cast (not zod-parsed), so this is the only place the default lands.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:33:00 -04:00
open-pascal 3adae8acea style: apply biome lint fixes after portforward 2026-05-20 00:54:11 +00:00
sudhirandopen-pascal d48be77f5d Reduce wall rebuilds during window and door placement 2026-05-20 00:53:10 +00:00
sudhirandopen-pascal 2ee69e24e2 Organize door panel controls by door family 2026-05-20 00:49:41 +00:00
sudhirandopen-pascal 4a402756fc Fix floorplan labels and door/fence geometry 2026-05-20 00:45:33 +00:00
Wassim SAMADandClaude Opus 4.7 d747d2f0ea Phase 5 Stage E: full kind migration into packages/nodes
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>
2026-05-19 15:14:12 -04:00
Wassim SAMADandClaude Opus 4.7 9bcb25d0aa Phase 5 Stage C continued: wall, door, window now in registry floor plan
Three remaining kinds at Stage C this session:

wall → C
 - buildWallFloorplan: uses ctx.siblings to gather other walls in the
   level, runs calculateLevelMiters, computes plan footprint via
   getWallPlanFootprint. Same visual output as legacy.
 - getFloorplanWall thickness exaggeration inlined (~25 lines from
   editor/lib/floorplan/walls.ts) to keep nodes/wall self-contained.
 - floorplan-panel.tsx's wallPolygons short-circuits to [] when wall
   is registered.
 - Performance note: recomputes level miter data per wall (O(N²) for N
   walls in a level). Acceptable for typical scenes; ctx.levelData?.
   miters optimization deferred to Stage B's wall design pass.

door → C
 - buildDoorFloorplan: inlines getOpeningFootprint math from
   floorplan-panel.tsx (40 lines, pure math). Uses ctx.parent as the
   wall to compute direction + perpendicular for the cutout footprint.
 - Returns null when parent isn't a wall (orphaned doors during
   placement).

window → C
 - buildWindowFloorplan: same shape as door, glass-blue tint to
   distinguish visually.

Both share the legacy openingsPolygons gating:
 - floorplan-panel.tsx's openingsPolygons useMemo filters per kind so
   a partial migration still works (e.g., if only door registers, only
   doors get skipped). When both registered, returns [] entirely.

Item C intentionally deferred — needs parent-chain transform helpers
(buildFloorplanItemEntry / getItemFloorplanTransform from editor/lib/
floorplan/items.ts) exposed publicly or moved into core. A focused
session is the right place to design that boundary.

Stage B for door / window / wall still pending — each is a focused
session per kind (large geometry math extractions, wall needs ctx.
levelData design).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:43:53 -04:00
Wassim SAMADandClaude Opus 4.7 9eced06f32 Phase 5 batch: door + window migrate to registry (always-on)
Both kinds share traits — hosted on walls, cuttable, animated open/
close state via a geometry system + animation system. Stage A
migration: register + wrap-export the legacy renderer + bundle both
per-kind systems. Pure geometry + floor-plan ports are later
milestones.

Files added (packages/nodes/src/door/, packages/nodes/src/window/):
 - schema.ts: re-export from core.
 - parametrics.ts: minimal — dimensions only. Door has 29 sliders +
   segmented controls + presets in its legacy panel; window has 15+
   sliders. Auto-inspector can't cover them at Stage A — legacy
   panel keeps rendering via panel-manager.tsx case fall-through.
   Stage E may extend parametrics or use parametrics.customPanel
   escape hatch.
 - definition.ts: capabilities (no `movable` — wall-bound drag is
   bespoke; capability-driven dispatch keeps legacy MoveDoorTool /
   MoveWindowTool), parametrics, renderer, system. defaults() uses
   `DoorNode.parse({...stub})` to leverage zod's schema-level
   `.default()` annotations — door has 40+ fields, window has 20+;
   listing them inline duplicates the schema.
 - renderer.tsx: wrap-export of legacy DoorRenderer / WindowRenderer
   (thin 33-36 lines each).
 - system.tsx: bundles each kind's TWO systems — DoorSystem +
   DoorAnimationSystem, WindowSystem + WindowAnimationSystem. Both
   per-kind systems mount via RegisteredSystems when the kind is
   registry-driven; `<LegacySystem kind="door|window">` wrappers
   around each individual system short-circuit.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: new public exports for DoorRenderer,
   DoorSystem, DoorAnimationSystem, WindowRenderer, WindowSystem,
   WindowAnimationSystem.
 - packages/nodes/src/index.ts: appends doorDefinition + windowDefinition.
 - packages/editor/src/components/ui/panels/door-panel.tsx + window-
   panel.tsx: panel slider-drag fix recipe applied. Drop the
   subscribed `updateNode` action, drop the `node` dep from
   handleUpdate / previewDoorUpdate / commitDoorPreview useCallbacks.
   Use useScene.getState() inside. Door panel has 29 SliderControls,
   window 15+ — both at high risk of the Maximum update depth
   cascade without the fix.

Phase 5 progress: shelf , spawn , wall , fence , slab , ceiling ,
door , window . Eight kinds on the registry. Item / stair / roof /
zone / containers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:10:17 -04:00