Commit Graph
20 Commits
Author SHA1 Message Date
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
ae87ca5475 feat: add production measurement tools (#505)
* feat: add persistent measurement tools

* feat: make measurements associative

* fix: finish measurements with Escape

* feat: improve measurement snapping guides

* feat: clarify measurement axis feedback

* feat: smart measure lens, zone reports, and direct measurement editing

- Smart measurement lens: registry-owned wall/slab/zone hover reports with a
  single top-center HUD, click-to-pin, latest-event back pressure, and no
  scene writes
- Conservative derived zone quantities (footprint, perimeter, proven
  enclosure, gross wall/floor surface, flat-room volume) with the
  selected-zone blueprint panel
- Direct editing of committed measurements via selected-only 2D/3D vertex
  affordances with midpoint insertion, cancellation, and one-write history
- Shared measurement surface-query session; 2D tracing joins the
  slab/ceiling magnetic pipeline with registered-corner snapping
- Angle arcs on the smaller angle, indigo active/black resting hierarchy,
  screen-sized normal-aligned contact rings

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

* fix: make measurement snapping always magnetic

Measurement drafting and committed-edit paths gated wall, semantic, and
axis magnetism on isMagneticSnapActive(), which is only true in the
'lines' snapping mode — in the default 'grid' mode corners and wall
intersections barely attracted (3D association fell to the 0.012 m
verify tolerance, 2D wall radii to the 0.05 m connect stick).

Measurement is an analysis tool whose anchors exist to bind real
geometry, so its snapping no longer consults the construction
snapping-mode chip: 2D/3D drafting and committed vertex edits are always
magnetic, Alt is the temporary bypass in both views (releasing the axis
pull, wall magnetism, and the 2D projected-geometry pull, and shrinking
association to contact tolerance). A discrete 2D wall snap (endpoint /
midpoint / crossing) now outranks the locked axis pull, and committed 2D
edits route the fallback through the raw pointer so free drags no longer
quantize to the construction grid. Volume extrusion height keeps its
mode-driven grid quantize.

Codex adversarial review confirmed the diagnosis and plumbing; its 2D
Alt-depth and grid-quantize findings are applied. New
surface-plan-snap tests pin the magnetic override seam.

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

* fix(measurement): stabilize area surface intent

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 19:01:01 +02:00
a524da1574 Fix room surfaces, wall openings, and paint scope (#498)
* chore(core): point material catalog at KTX2 tiers for wood/flooring/roofing finishes

All 48 remaining webp/jpg/png finish entries now reference _512.ktx2 maps
and 256px _thumb.webp previews, matching the fabric/leather/concrete/metal
convention. flipY set to false on the converted entries — compressed
textures can't be flipped at upload.

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

* fix: stabilize room surfaces and wall openings

* style(core): format KTX2 material catalog

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:35:04 -04:00
4fca38a3ef fix: make room walls and slabs join cleanly (#497)
* fix(editor): wall endpoint move — detach/attach modifier sync and zero-move drop

- restore linked walls to original positions the moment alt-detach engages
- re-run the endpoint preview on alt keydown/keyup so re-attach doesn't wait for a mousemove; preview, HUD badge, and commit share one alt source
- second click at an unmoved position cleanly drops the endpoint (no history entry) instead of leaving the interaction stuck
- 2D floorplan parity: clear stale linked overrides on detach, re-apply drag sessions on modifier changes

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

* fix(core): demote orphaned auto slabs/ceilings to manual instead of deleting

Deleting a wall that encloses a room no longer destroys the room's
slab/ceiling (paint, holes, elevation). Unmatched auto surfaces are only
deleted when >=60% of their footprint is still covered by a detected room
(rooms-merged case); otherwise they are demoted to manual nodes.

- demoted slab polygons are baked (inset by SLAB_OUTSET + AUTO_SLAB_INSET)
  so the rendered footprint doesn't jump between the auto and manual paths
- auto-creation suppression now also matches manual nodes by mutual
  footprint coverage, so re-closing the room doesn't stack a duplicate
  auto surface on the demoted one

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

* fix(editor): hide alt-to-detach hint when the moving endpoint has no linked walls

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

* fix(editor): split host walls in every snap mode, stop wall chain on existing walls

Splitting a room with a wall now splits the walls it lands on and migrates
hosted doors/windows/items — matching what already happened to the slab and
ceiling. The split machinery existed but was gated on magnetic snapping
('lines' mode) while the wall tool defaults to 'grid'; split resolution now
always runs, with the join radius scoped to the active mode (0.35 magnetic,
0.05 connect snap otherwise), and the whole commit lands as one undo step.

The drawing chain now terminates when a committed segment ends on a wall
outside the current chain (T-junction), like the room auto-close — users
don't draw overlapping walls. Applied in 3D and 2D, and the 2D-only path
gains the previously missing wallClosesRoom parity check.

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

* feat(core): per-edge build-time slab offsets — slabs tile at wall centerlines

Replaces the stored-polygon render offsets (auto: centroid inset 0.02,
manual: flat outset 0.05) with one uniform per-edge rule computed at
geometry build time, for auto and manual slabs alike:

- edge shared with a sibling slab (collinear-overlap test, T-junction
  sub-segments included) → small relief inset; adjacent rooms tile
  exactly and can never overlap
- edge on a wall centerline with no slab neighbor → expand outward by
  that wall's thickness/2, flush with the facade
- free edge → rendered exactly as drawn

Slab demotion no longer bakes polygons (offsets never live in node
data); no stored-data migration. New slab system marks level slabs
dirty when wall geometry/thickness or sibling slab footprints change.

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

* fix(viewer): import KTX2Loader from its deep path, not the jsm aggregate

The Addons.js aggregate re-exports LottieLoader/TTFLoader whose CDN URL
imports (lottie-web, opentype.js) abort bun test in every package that
transitively imports the viewer.

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

* feat(editor): split host wall when an endpoint move lands on its interior

Moving a wall endpoint onto another wall's mid-span now splits that wall
and migrates its doors/windows/items, matching the draw tool — previously
the room closed (detection planarizes internally) but the wall node stayed
whole. New resolveEndpointWallSplit reuses the draw path's split pipeline;
endpoint write + split commit as one undo step. Applied in 3D and 2D.
Corner drops still join without splitting; straddling openings skip the
split; alt-detached commits split the stationary former sibling correctly.

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

* feat(core): slab-wall adoption — absolute edge projection, band snapping, rendered-footprint wall elevation

Slab edges near a wall now render projected onto the wall's exact
geometry instead of getting relative offsets, healing legacy scenes
(face-aligned edges, old baked outsets, hand-drag slop) with no data
migration:

- adoption band: an edge collinear with a wall centerline within
  thickness/2 + 6cm (both sides) is wall-backed; nearest centerline
  wins, a sibling slab in the same band forces the interior seam
- wall-backed exterior edges project to the outer face; interior edges
  to the centerline minus the relief gap; free edges render as drawn
- slab edge resize: wall snapping is now edge-based, not cursor-based —
  fixes the drop landing short by the grab offset (0.34m arrow gap in
  3D, hit-stroke slop in 2D); the snap translates the edge onto the
  wall centerline (canonical stored position), beacon/preview/commit
  agree, full band in lines mode, 5cm stick otherwise; 2D shows a
  dashed stored-boundary skeleton when it diverges from the fill
- wall elevation tests the slab's rendered footprint instead of the
  stored polygon with a 0.1mm epsilon, so walls sit on legacy slabs
  and re-elevate when slabs are reshaped

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

* fix(core): subcut slab edges per backing span

A slab edge backed differently along its length (offset rooms sharing a
wall over part of the span, a wall shorter than the edge, collinear
walls of different thickness) is now subdivided at the backing-span
breakpoints; each sub-span classifies and projects independently, with
a perpendicular step connector at intra-edge transitions that lands
inside the crossing wall's footprint. Breakpoints closer than 5cm merge
so no sliver geometry reaches the ring, and same-target spans re-fuse
(curved-wall sampling doesn't balloon vertex counts; whole-edge cases
render bit-identically to before).

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

* fix(core): wall elevation picks the slab that supports it, not the highest graze

A wall touching a raised slab only at an endpoint no longer lifts
entirely to that slab. Elevation selection is now coverage-based:
per-slab support is the wall's centerline+face length covered by the
slab's rendered footprint minus holes; slabs within 0.1mm of elevation
pool their support (party walls spanning two rooms still lift); the
wall sits on the highest elevation covering >=50% of its length, else
the best-covered group (ties prefer higher), else 0. Sub-5cm grazes
are ignored outright.

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

* fix(core): interior slab edges meet exactly at the wall centerline

Removes the 2cm interior relief inset: both rooms' seam edges project
onto the same centerline (or the symmetric sibling midline when no wall
backs the seam), so adjacent slabs tile with a shared edge — the 4cm
slit under shared walls, visible at its open end on the facade, is
gone. Safe against z-fighting because slab side quads are single-sided
(FrontSide is enforced repo-wide for the MRT scene pass) and the
coincident seam faces have opposite normals; junction step pockets grow
to 5cm but remain strictly inside the intersecting wall footprints.

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

* fix(core): elevation-aware interior slab seams — close the vertical band slit

With unequal room elevations, both slabs stopping at the shared wall's
centerline left the lower room's half of the wall band open between the
lower slab top and the wall base (the wall seats on the higher slab).
Interior seams across a wall are now elevation-aware: equal elevations
keep the exact centerline seam; unequal elevations project BOTH slabs
to the wall face on the lower side — the higher slab runs through the
band under the wall, the lower butts the same plane. Wall-less unequal
seams keep the sibling midline (a visible step face is correct there).
Slab elevation joins the level dependency signature so height edits
rebuild sibling slabs live.

Verified against real local scenes: all wall bands solid where the
seam rule applies (remaining pockets are the known flat-wall-base
limitation, tracked separately).

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

* fix(plugin-trees): run wind displacement before the instance transform

The r184->r185 upgrade broke the plant wind: r185 fixed TSL's statement
emission order so a material's positionNode now runs *after* the
instancing transform (r184 emitted it before — verified in the compiled
WGSL of both versions). The wind nodes were tuned against the r184
order, reading positionLocal as geometry-local coordinates, so on r185
the displacement moved into level space: sway no longer scaled with the
per-instance scale (scaled-down trees thrashed like a storm, leaf cards
visibly detaching from branches), leaf phase followed world placement,
and STEM_BEND's height term read the floor elevation, so grass/flowers
on upper levels slid around rigidly.

Restore the r184 semantics explicitly: WindNodeMaterial assigns the wind
node to positionLocal inside setupPosition() before super applies the
instance transform, instead of using positionNode. The emitted WGSL is
statement-for-statement identical to r184's.

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

* fix(core): resolve slab joins across floor elevations

* fix(editor): keep slab resize arrows visible

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:28:29 -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 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 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 967a905e3b feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5)
Brings slab, ceiling, and wall onto the unified slot contract the shelf
established, so each declares its paintable slots with a declarative default and
(slab/ceiling) is painted through the registry capabilities.paint dispatch.

- Shared helper packages/nodes/src/shared/slot-paint.ts: a node.slots-based
  PaintCapability factory (commit/resolve/effective generic; preview injected).
  Distinct from surface-paint.ts, which writes the legacy inline node.material.
- slab: schema slots; def.geometry resolves node.slots.surface -> legacy
  material -> declared default, tags the mesh userData.slotId; slabPaint +
  capabilities.slots. Retires DEFAULT_SLAB_MATERIAL in the slab path.
- ceiling: schema slots; material builders extracted to ceiling/materials.ts
  (shared by renderer + paint preview, built BackSide so the hover preview is
  visible from below); renderer resolves the slot; ceilingPaint + slots.
- wall: WALL_SLOT_DEFAULT in core; the viewer's getMaterialsForWall renders an
  unpainted face with its declared default instead of the themed wall role;
  capabilities.slots (interior/exterior). wallPaint's inline interior/exterior
  fields are unchanged (node.slots migration is a later step).
- selection-manager + material-paint: drop slab/ceiling from the legacy
  single-surface arms (now registry-driven).

Behavior change (intended, matches the shelf precedent + the phase-5 plan):
colored-mode UNPAINTED slab/ceiling/wall surfaces now render their fixed slot
default (#e5e5e5 / #f5f5dc / #ffffff) instead of the theme role colour. The
textures-off (monochrome) role collapse is unchanged — the escape hatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07: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 Rabot 03f57b1f4f Fix stair opening previews and slab cutouts 2026-06-07 00:46:51 -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
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 6a1d853dd8 IconRef: add url kind, point registered kinds at palette assets
User pointed at the palette's PNG icons in the bottom toolbar — they
expected the inspector title to use the same artwork, not the iconify
lucide glyphs. Different visual style.

- IconRef gains `{ kind: 'url'; src: string }`. Plain `<img>` render
  in ParametricInspector (no next/image — the inspector is
  `'use client'`).
- All currently-registered kinds switched to URL refs matching their
  palette `iconSrc`:
    fence    → /icons/fence.png
    slab     → /icons/floor.png
    ceiling  → /icons/ceiling.png
    wall     → /icons/wall.png
    spawn    → /icons/site.png
    shelf    → /icons/column.png (placeholder, same as palette)
- Kind-owned panels (slab/ceiling) already pass URL strings to their
  own PanelWrapper; unchanged.

Door/window/item will get URL refs when they register at Stage A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:41:45 -04:00
Wassim SAMADandClaude Opus 4.7 1e15e10185 Phase 5 Stage D: re-port curve + whole-item move tools 1:1 from legacy
Each tool is now a direct copy of the legacy implementation, relocated
under @pascal-app/nodes/<kind>/ and dispatched via the registry's
def.affordanceTools. No DragAction abstraction, no clever live-drag
exception, no novel snap pipeline — same code, same UX, same
performance, same history dance.

Ports:
- fence/curve-tool.tsx    (legacy CurveFenceTool, 1:1)
- fence/move-tool.tsx     (legacy MoveFenceTool, 1:1 — including
                           the mesh.position + useLiveTransforms
                           exception that the legacy uses for fence
                           specifically)
- wall/curve-tool.tsx     (legacy CurveWallTool, 1:1)
- slab/move-tool.tsx      (legacy MoveSlabTool, 1:1)
- ceiling/move-tool.tsx   (legacy MoveCeilingTool, 1:1 — preview
                           fill + outline overlay preserved)

Drops the obsolete DragAction-based action files
(packages/nodes/src/{fence,wall,slab,ceiling}/actions/{curve,move}.ts)
and their now-empty actions/ directories where applicable. Fence
keeps actions/move-endpoint.ts since that port works.

Editor public surface gains `getWallGridStep` + `snapScalarToGrid`
(transitional exports — Stage F moves them into @pascal-app/nodes).

ToolManager + MoveTool dispatch unchanged: the same legacy-fallback
branches now mount the registry component because the affordances are
declared, but the rendered behavior matches the legacy because the
implementations are copies.

Per-kind progress: fence D  (curve / move-endpoint / move / placement
all kind-owned), slab D , ceiling D , wall D 🟡 (curve only,
endpoint/move/placement still legacy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:53:51 -04:00
Wassim SAMADandClaude Opus 4.7 da63081f73 Phase 5 Stage D: revert curve + whole-item move affordances to legacy
User-reported regressions made it clear that my D-ports of the curve
and whole-item move tools introduced more friction than they removed.
The legacy CurveFenceTool / CurveWallTool / MoveFenceTool /
MoveSlabTool / MoveCeilingTool ship more polish than the new ports do
right now:

  - Legacy curve tools pre-snap the pointer position to the 0.5m grid
    BEFORE projecting onto the chord normal, and they Shift-toggle to
    a free-place mode. The ports skipped both — finer math, but the
    user-visible UX regressed (laggy because the cascade resolver fires
    per move, history feels broken near the no-op threshold).
  - Legacy whole-item moves use scene.update per tick which keeps
    hosted children visually aligned. The live-drag mesh.position port
    cleared the offset before the GeometrySystem could rebuild,
    producing a one-frame teleport on commit.

Drop the affordance registrations for those tools — the ToolManager /
MoveTool dispatch falls back to the legacy per-kind tools when the
registry doesn't declare the affordance. Stage D progress preserved
for: fence move-endpoint (linked cascade + alt-detach), slab/ceiling
boundary + hole editors, fence placement, slab placement, ceiling
placement.

The kind-owned files (curve-tool.tsx, move-tool.tsx, actions/curve.ts,
actions/move.ts) stay on disk for the next iteration — when they
reach parity with legacy UX, re-add the affordance entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:42:40 -04:00
Wassim SAMADandClaude Opus 4.7 b2e5d84986 Phase 5 Stage D slab: port placement + move + boundary/hole editors
Replicates the fence Stage D recipe for slab — three drag affordances
+ one placement tool, all routed through the registry:

  affordanceTools:
    'boundary-edit' → thin <PolygonEditor> wrapper (vertex/edge drag)
    'hole-edit'     → same for a single hole polygon
    move            → DragAction with single-undo dance
  tool: () => placement (multi-click polygon with axis/45° snap)

`PolygonEditor` + `PolygonEditorProps` exported from
`@pascal-app/editor` as Stage D transitional surface (Stage F cleanup
moves them into `@pascal-app/nodes`).

ToolManager mount sites for SlabBoundaryEditor / SlabHoleEditor now
route through `getRegistryAffordanceTool` with legacy fallback.

Slab move action does not use the live-drag exception (polygon CSG
rebuild every tick) — matches legacy behavior; optimization is a
separate task once we measure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:27:31 -04:00
Wassim SAMADandClaude Opus 4.7 969b154b08 Phase 5 depth-first: spawn C, fence B+C, slab B+C, ceiling C
Depth-first session: drive registered kinds through Stage B (pure
def.geometry, drop system re-export) and Stage C (def.floorplan,
short-circuit legacy inline rendering in floorplan-panel.tsx).

spawn → C
 - buildSpawnFloorplan wired on definition (was written but deferred
   to avoid double-render).
 - floorplan-panel.tsx's floorplanSpawnEntries useMemo short-circuits
   to [] when nodeRegistry.has('spawn').

fence → B
 - generateFenceGeometry exported from viewer; buildFenceGeometry
   wraps it in a Group+Mesh with DEFAULT_STAIR_MATERIAL.
 - def.geometry set; renderer + system fields dropped.
 - Deleted nodes/src/fence/{renderer.tsx,system.tsx}.

fence → C
 - buildFenceFloorplan: polyline along centerline (sampled for curved
   fences via sampleWallCenterline from core). Stroke width = node.thickness.
 - floorplan-panel.tsx's floorplanFenceEntries short-circuits.

slab → B
 - generateSlabGeometry exported from viewer; buildSlabGeometry wraps
   it in a Group+Mesh + cached material (preset / custom / default
   pattern preserved from legacy renderer).
 - def.geometry set; renderer + system fields dropped.
 - Deleted nodes/src/slab/{renderer.tsx,system.tsx}.

slab → C
 - buildSlabFloorplan: SVG path with outer polygon + hole subpaths
   (uses getRenderableSlabPolygon from core for wall-clipping parity).
 - floorplan-panel.tsx's slabPolygons short-circuits.

ceiling → B INTENTIONALLY SKIPPED
 - Ceiling renderer renders React children (hosted items) + uses TSL
   shader materials + named meshes that other systems poke
   (getObjectByName('ceiling-grid')). Pure def.geometry can't preserve
   that. Ceiling keeps def.renderer (the custom escape hatch) — same
   pattern item uses. Documented in ceiling/definition.ts.

ceiling → C
 - buildCeilingFloorplan: dashed-outline path with hole subpaths
   (visually distinct from slab since ceilings are above).
 - floorplan-panel.tsx's ceilingPolygons short-circuits.

Per-kind progress after this session:
 - shelf: B  C  (Stage E since brand-new)
 - spawn: A  C 
 - wall: A  (B blocked on ctx.levelData design)
 - fence: A  B  C 
 - slab: A  B  C 
 - ceiling: A  C  (B intentionally not applicable)
 - door / window / item: A  (B+C pending in future sessions)

Known test issue: `bun test` in packages/nodes fails to load
`three-bvh-csg` through the viewer's transitive imports (UMD/ESM
mismatch in Bun's test runner). The Next.js editor build works fine
because it bundles differently. Fix requires either dynamic imports
(breaks sync def.geometry contract) or test env config — deferred.
Other tests (schema, geometry, parity) pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:14:22 -04:00
Wassim SAMADandClaude Opus 4.7 4891f681f3 Phase 5 batch kind: slab migrates to registry (always-on)
Same shape as fence — thin renderer + system re-export, capabilities
declared, panel slider-drag fix recipe applied. Pure geometry +
floor-plan ports are later milestones.

Files added (packages/nodes/src/slab/):
 - schema.ts: re-exports SlabNode from core.
 - parametrics.ts: elevation slider only. Polygon + holes edited
   via floor-plan boundary / hole editors, not number inputs.
 - definition.ts: capabilities (no `movable` — slab move is bespoke
   whole-translation through MoveSlabTool that integrates with the
   boundary editor; capability-driven dispatch keeps the legacy mover),
   surfaces.top with elevation-as-height for stacked items, relations
   (hosts: ['item'], cascadeDelete: 'descendants'), toolHints (trace /
   finish / cancel for the placement tool).
 - renderer.tsx: thin placeholder mesh + markDirty on mount + node
   events + cached material via the same getSlabMaterial pattern as
   the legacy renderer (preset apply on shared material instance).
 - system.tsx: re-exports the legacy SlabSystem from viewer.
 - index.ts: barrel.

Files changed:
 - packages/viewer/src/index.ts: exports SlabSystem (already had
   DEFAULT_SLAB_MATERIAL, applyMaterialPresetToMaterials, createMaterial
   from earlier exports).
 - packages/nodes/src/index.ts: appends slabDefinition unconditionally
   to builtinPlugin.nodes.
 - packages/editor/src/components/ui/panels/slab-panel.tsx: applied
   the panel slider-drag fix recipe from plans/editor-node-registry.md
   prophylactically (nodeRef pattern, useScene.getState().updateNode
   inside handler, drop subscribed updateNode dep). Slab's elevation
   slider is the only drag-driven control in the panel — would have
   triggered the same Maximum update depth cascade as wall/fence.

No behavior change. Slab now mounts via the registry path, but the
legacy SlabSystem still does the actual polygon triangulation + hole
CSG work (re-exported, not duplicated).

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