Commit Graph
50 Commits
Author SHA1 Message Date
0fba611a05 feat(export): texture references — stamp sources at load, reference mode in GLB export (#532)
Bake v2 exporter side: library/preset, legacy scene-material, and item-GLB
textures get a validated pascalTextureRef stamped at load (origin-checked,
env-derived). Material-ish sources resolve to 'library-material' (storage
bucket) or 'app-material' (static catalog on the assets CDN) by URL shape.
exportSceneToGlb grows a textures: 'embed' | 'reference' option — reference
mode swaps stamped textures for 1x1 canvas-backed placeholders (skipping the
WebGPU blit) and writes the ref to both texture and image extras via a
GLTFExporter writeTexture plugin. Download GLB stays embed; the bake page
passes reference.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 08:33:38 -04:00
adc1ec89f4 fix(viewer): guard TextureNode updates against null textures (#527)
* fix(viewer): guard TextureNode updates against null textures

three's override-material passes (shadow, prepasses) copy per-object texture
slots onto shared materials whose cached per-mesh node graphs can disagree
about a slot's presence; when a graph with a TextureNode pulls a null slot,
the exception kills the whole render pass and the scene goes black. Patch
TextureNode.prototype.update to skip null values (with a throttled warning)
so the slot just renders textureless for a frame and recovers.

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

* revert(viewer): drop the always-on neutral displacement texture

Giving every standard material a displacement texture put displacement
TextureNodes into the shared shadow/prepass override graphs; in scenes mixing
preset materials with GLB item materials (no displacementMap), an item's
shadow render can hit a cached graph that expects the texture and pull null
— build-order dependent, which is why it broke many-but-not-all projects at
open right after the release. The TextureNode fallback guard covers the
original paint-hover crash (black texel = zero displacement), so the broad
neutral-texture approach is retired.

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

* style: sort guard imports

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:34:07 -04:00
603f5d2242 feat(materials): dynamic library registry, picker source tabs, Other category (#525)
* feat(materials): dynamic library registry, picker source tabs, create-material entry point

Core gains a runtime material registry (registerLibraryMaterials/
unregisterLibraryMaterials/subscribeLibraryMaterials) so embedders can
feed user/community materials; library: refs to registered materials
resolve in the viewer unchanged. MaterialCatalogItem carries an optional
source (pascal|community|mine|workspace). MaterialPicker gets a source
filter row and an optional onCreateMaterialRequest '+ New material'
tile, threaded through MaterialPaintPanel.

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

* fix(materials): underline source tabs in picker, matching catalog browse surfaces

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

* feat(materials): add 'other' category for uncategorized library materials

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

* fix(viewer): rebuild node material when a texture slot is cleared

Reused cached WebGPU materials keep a compiled TextureNode per slot; nulling
the slot for a cold texture load (or a preset without the map) without
needsUpdate leaves the node's per-frame material reference pulling null,
crashing the render pass in TextureNode.update. Cold loads are the norm for
freshly generated library materials, whose maps aren't in the texture cache.

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

* fix(viewer): standard materials always carry a displacement texture

three's WebGPU shadow pass copies each object's displacementMap onto one
shared per-light shadow material while caching per-mesh shadow node graphs
against that shared override. A mesh whose shadow graph was built with a
displacement TextureNode (painted with a generated material — the only
presets carrying height maps) crashes the render pass with "null (reading
'matrix')" the moment a material without a displacement texture is swapped
onto it, which is exactly what the paint hover preview does. Give every
MeshStandardNodeMaterial a shared 1x1 black displacement texel (zero offset)
when it has no real height map, so the copied slot is never null in either
direction.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:00:48 -04:00
Wassim SAMADandGitHub 10c9c6ad27 fix: harden item and baked scene rendering (#522) 2026-07-20 12:15:08 -04:00
16ea186f77 fix(viewer): guard texture.repeat against non-array values (Sentry MONOREPO-EDITOR-EK) (#504)
* fix(viewer): guard texture.repeat against non-array values (Sentry MONOREPO-EDITOR-EK)

* fix(viewer): normalize legacy texture repeats

---------

Co-authored-by: openclaw-agent <agent@pascal.app>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
2026-07-19 17:40:18 +02: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
a590747748 Adjustments pass: three 0.185, undo/cancel semantics, guide & panel fixes (#496)
* fix(editor): only start handle drags on primary button

Right-click over a rotation/move/resize handle started the gesture and
stopPropagation()'d, fighting the camera orbit. Guard every gesture
starter (shared useHandleDrag, group rotate gizmo, wall endpoint/height/
move, fence move, roof trim) with event.button !== 0 before it swallows
the event.

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

* feat(viewer): default units from timezone/locale until user picks

Derive the metric/imperial default from the IANA timezone (US, Liberia,
Myanmar zones -> imperial; anything else -> metric), falling back to an
explicit locale region subtag only when no timezone resolves. Timezone
tracks actual location, unlike navigator.language where en-US is a common
default far outside the US. The unit is only persisted once the user
explicitly sets it, so an untouched preference keeps tracking location;
existing persisted values are treated as explicit and left alone.

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

* fix(viewer): toggle shadows via renderer.shadowMap.enabled, not castShadow

Flipping a light's castShadow at runtime crashes three r184's WebGPU
renderer: toggling off disposes the shadow map's GPU texture, but the
node builder cache evicts with the post-toggle key, so the shadows-on
entry survives still referencing the destroyed texture. Re-enabling
reuses that stale state and every frame submit fails with
GPUValidationError ("Invalid CommandBuffer from CommandEncoder").

Keep castShadow static and drive the user-facing toggle through the
Canvas shadows prop (renderer.shadowMap.enabled), which rebuilds
materials without disposing shadow resources.

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

* fix(core,editor): floor undo history at scene load

Undo could step back past the scene load into the pre-load (empty)
state, wiping the whole project — which autosave would then persist.
Two defects: clearSceneHistory() had zero call sites, so every load left
the empty pre-load state in zundo's pastStates; and setScene wrote the
store twice, recording a half-normalized intermediate as a second undo
target.

- applySceneGraphToEditor, JSON import, and reset-to-default now clear
  history so the loaded scene is the undo floor
- setScene collapses to a single tracked write (final state identical)
- clearSceneHistory also resumes tracking so a load landing inside a
  pause window can't strand undo recording off

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

* build(three): upgrade runtime to 0.185.1, pin @types/three at 0.184

r185 renames directionToColor/colorToDirection to packNormalToRGB/
unpackRGBToNormal and splits SSGI's packed rgba output into separate AO
(getAONode, single channel) and GI (getGINode) textures; wind-node's
positionLocal reads become positionGeometry.

@types/three stays at 0.184.1: the 0.185 typings send tsgo's inference
into unbounded allocation (microsoft/typescript-go#2125 class — it ate
~70GB/90s and OOM-killed the machine). viewer/lib/tsl-compat.ts bridges
the two renamed TSL exports with 0.184-typed signatures; drop it and the
pin together once tsgo copes.

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

* fix(viewer): gate .ktx2 preset-texture loads on detectSupport

KTX2Loader.load throws before detectSupport has run, and materials
created while a standalone capture canvas's renderer was still
initializing cached themselves permanently texture-less — fabric slots
rendered white in item thumbnails. .ktx2 loads now await whenKtx2Ready()
(resolved by the first successful ensureKtx2Support), which is exported
so hosts with standalone canvases can arm it.

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

* feat(viewer): accept host-supplied country for the unit default

applyCountryUnitDefault lets the host app feed an authoritative
IP-derived country (e.g. Vercel's x-vercel-ip-country) into the unit
default. Stronger signal than the timezone heuristic applied at store
creation, still never overrides an explicit user choice.

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

* fix(editor): box select starts over locked guide images

A locked guide's hit-rect swallowed pointer-down via stopPropagation, so
marquee selection couldn't start on top of it. Locked guides now let the
event bubble to the svg root; click-to-select and the unlock affordance
still work because a non-drag release fires onClick as before.

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

* feat(editor): live rotation readout while rotating a guide image

Rotating a guide with the 2D handles gave no angle feedback. Reuse the
registry layer's RotationAngleOverlay (wedge + degree chip) for guide
rotate drags: sweeps from the grabbed corner's bearing at grab to its
current snapped bearing, suppressed under ~0.5deg so a fresh grab doesn't
flash a sliver.

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

* feat(editor): lingo free-text input in the guide set-scale dialog

The real-length field accepts natural measurement text via
@pascal-app/lingo — 5'11", 180cm, 1m80, 12ft — parsed in the dropdown's
unit (a bare number still means that unit, a typed unit wins). A faint
'= 1.80 m' hint previews non-trivial input, unparseable text gets a
clear error, and the odd onBlur force-reset to 0.0001 is gone.

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

* feat(editor): cmd+z mid-interaction cancels the gesture instead of undoing

Undo pressed while the mouse is mid-action (moving, drawing, dragging a
handle) used to history-jump under the live pointer — stale carry, half
gestures committing against a rewound scene. Now it reads as 'abort this
action', exactly like Escape:

- the global undo/redo arms first route through the tool:cancel path
  (covers build drafts, placement ghosts, move tools) and skip the
  history jump when anything was in flight (consumed, scope-active, or
  inputDragging);
- pointer drags that only knew pointercancel (generic handle drags,
  group rotate, wall side/height handles, roof trim) gain the same
  capture-phase Escape/cmd+z keydown the group-move drags already had —
  fixing Escape for them too;
- the existing capture-phase handlers (3D/2D group move, 2D registry
  move overlay) additionally accept cmd+z as cancel.

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

* fix(editor): box select arms over any guide image that won't drag

Follow-up to cd830279, which only let LOCKED guides bubble pointer-down.
An unlocked, unselected guide also swallowed the event for nothing (no
translate drag starts), so marquee selection could never start on top of
it. Now only the one case that uses the event consumes it — selected +
unlocked → translate drag — and everything else bubbles to the svg root.
Click-to-select still works: a non-drag release never crosses the
box-select threshold, so the trailing click fires the guide's onClick.

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

* fix(editor): cmd+z during draft placement cancels instead of undoing

The preset/item placement flow (useDraftNode + placement coordinator)
registers no interaction scope and holds no pointer, so the cmd+z cancel
guard from c699d74e saw it as idle and history-jumped mid-placement.
Paused scene history is the universal tell — the draft cycle (and every
adopted-move session) keeps temporal paused for the whole gesture, and
an undo against a paused store lands on a stale baseline anyway. Treat
!isTracking as in-flight.

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

* fix(editor): cmd+z mid-placement completes the cancel, not just skips undo

4ffda723 stopped the history jump during preset/item placement but left
the draft alive: the item tool passes no coordinator onCancel — it is
Escape's fall-through (switch to select, unmount the tool) that actually
destroys the draft. Extract that fall-through and run it from the cmd+z
path too whenever a gesture is live and nothing consumed tool:cancel,
so cmd+z now behaves exactly like Escape end to end.

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

* fix(editor): node selection clears a lingering reference selection

Selecting a guide clears the node selection (handleGuideSelect), but the
reverse was never wired: clicking a wall with a floorplan reference
selected left selectedReferenceId set, and the panel manager's
reference-first priority kept showing the floorplan panel until it was
closed by hand. PanelManager now drops the stale reference the moment a
scene selection (nodes or zone) appears.

Also: the inspector's expanded state is shared across panel swaps by
design, but it survived close/reopen too — deselecting everything now
resets it, so a fresh selection opens the panel collapsed again.

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

* fix(plugin-trees): wind displacement reads positionLocal, not positionGeometry

The three-0.185 migration renamed positionLocal to positionGeometry in
the wind nodes, but positionLocal was never removed in r185 — and the
two are not interchangeable here. NodeMaterial.setupPosition applies the
instance transform by mutating positionLocal, then overwrites it with
positionNode's output; reading raw positionGeometry therefore discarded
every instance matrix — leaf cards rendered unscaled at tree-local
coordinates (a giant canopy filling the sky) and grass/flower instances
collapsed invisibly. Reading positionLocal (instance transform included)
restores r184 behavior exactly.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:26:35 -04:00
47313263fb feat(viewer): rendering & lighting pass — sun-dominant look, sky backdrop, grounded horizon (#493)
* feat(editor): dev-only window hook for deterministic camera poses

Exposes a getter for the CameraControls impl in development so
screenshot/automation tooling can set exact camera poses.

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

* feat(viewer): rendering pass — sun-dominant lighting, grade, gradient-sky IBL, albedo clamp, SSGI tune

- Shadow intensity clamp 0.55 → 0.9 (sun no longer leaks into shadow),
  2048 shadow maps, PCFSoft filtering
- Scene-referred contrast/saturation grade before ACES output
  (GRADE_PARAMS in post-processing)
- Procedural gradient-sky IBL (cool zenith / warm horizon / ground
  bounce) replaces the venice_sunset HDR fetch; env-only, background
  unchanged
- Near-white albedos clamped to ~0.83 linear (defaults, white palette,
  catalog preset-white/softwhite, schema presets)
- SSGI: 2 slices / 6 steps, radius 1.6, aoIntensity 1.7, giIntensity 2
  (bounce on); studio hemi 0.6→0.45, fill 0.75→0.6

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

* feat(viewer): shadow-caster-only cutaway, glass fresnel, ground fade, specular-map unwiring

- SHADOW_ONLY_LAYER (4) + lib/shadow-only.ts: hidden roofs/levels in
  level-solo (editor) and dollhouse (GLB viewer) stay in the shadow map,
  so interiors keep sun shadows + window light patches; only the sun's
  shadow camera enables the layer
- Glass: fresnel-driven opacity + envMapIntensity on transparent
  standard materials (catalog glass, scene glass, window default)
- Site ground: radial fade into the theme background at the lot
  boundary (TSL colorNode); dead ground-occluder.tsx removed
- Catalog: 22 bogus *specular*→metalnessMap wirings removed (specular
  level maps are not metalness; they darkened/metallized dielectrics)

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

* feat(viewer): PCSS contact-hardening sun shadows via LightShadow.filterNode

Custom TSL filter: Vogel-disk blocker search (textureLoad — the sampled
path would inherit the comparison sampler, which WGSL rejects) →
receiver-blocker penumbra estimate → variable-radius rotated PCF.
shadow.radius scales max penumbra (now 4).

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

* feat(viewer): dark-theme recalibration for the new lighting pipeline

Night/twilight: lifted ambient/hemi beds (the 0.9 shadow intensity
crushed them), brightened theme grounds to a lit mid-tone, and dimmed
the daylight gradient-sky IBL to 0.2 for dark appearances.

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

* feat(viewer): rendering-pass follow-ups — GI denoise, lighter interactive SSGI, sky gradients, scoped solo shadows

- Denoise the SSGI GI bounce (it composited raw — the visible grain) and
  drop giIntensity to 1
- Interactive SSGI back to 1 slice (×6 steps); SSGI_BAKE_PARAMS (2×6)
  for the thumbnail/bake pipeline
- Per-theme backgroundSky: vertical zenith→horizon backdrop gradient in
  the post pipeline; makes the lot-edge ground fade read in every theme
- Level solo: only levels above the soloed floor stay shadow-caster-only;
  below-levels plain-hide (they can't block the sun)

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

* feat(viewer): kill SSGI grain, real horizon — infinite ground disc fading into the sky

- SSGI back to 2 slices × 6 steps: three's own minimum preset without
  temporal filtering — 1×6 was below the floor and the grain showed on
  flat walls; denoise radius 5 on both AO and GI, aoIntensity 1.5;
  bake preset raised to 3×8 (single-frame renders)
- Site renderer: presentation horizon disc (8× lot radius, min 400 m)
  under the lot in the theme ground colour, fading radially into the
  theme background; lot fill back to plain ground colour (the disc
  carries the fade); never pickable (noop raycast)
- Backdrop sky gradient compressed to the upper half of the screen so
  it meets the disc's far fade at exactly the horizon colour — no seam

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

* feat(viewer): laptop-budget SSGI + seamless world-space horizon

- SSGI interactive: 1 slice × 6 steps, GI bounce OFF (denoised AO only)
  — 2 slices was a thermal problem on laptops; the bounce moves to
  SSGI_BAKE_PARAMS (3×8, gi 1) for one-shot renders
- Horizon disc dissolve: albedo fades to black while emissive fades to
  the background colour, so the far end IS the backdrop (no lit-vs-flat
  seam); backdrop gradient graded with the same transform as the scene
- Sky gradient is now world-space: per-pixel view ray reconstructed from
  the scene camera matrices, sky above the true horizon (dir.y 0→0.35),
  pure background below — aligns with the disc at any camera angle
- Studio theme: ground #e9e7e2 / horizon #fbfbfa / sky #dde7ef so the
  fade is actually visible against the white void

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

* fix(viewer): kill shadow grain (PCSS opt-in only) and the inked horizon line

- The eye-level ground grain was measured (high-pass σ on a flat patch:
  2.95 baseline → 1.52 shadows-off → 2.49 ao-off): PCSS's per-pixel IGN
  dither was the dominant source, and it can't be fixed within a laptop
  budget without TAA. Interactive shadows revert to the renderer's
  PCFSoft (clean, cheap); PCSS stays wired behind ?enable=pcss for
  experiments and future bake-time use
- The horizon 'line' was the ink pass edge-detecting the ground disc's
  depth silhouette against the backdrop. Ink now fades with raw depth
  (full below ~150 m, gone past ~350 m) — near silhouettes keep their
  SketchUp line, the horizon dissolves cleanly

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

* feat(viewer): soft blue skies for light presets, slab coplanarity epsilon

- backgroundSky retuned: studio/paper/blueprint/verdant get a soft blue
  zenith (overcast keeps a bluish gray — it's overcast; mediterranean/
  sunset were already blue; dark themes untouched)
- Slabs duplicated at the exact same position z-fight and no camera
  near/far tuning can separate identical depths; each slab mesh now gets
  a deterministic sub-3mm lift hashed from its node id. Render-only —
  node data, snapping and measurements untouched. (Long-term fix is
  reversedDepthBuffer; parked in plans with the depth-consumer audit.)

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

* fix(viewer): walk back perf-costly rendering-pass pieces, soften dark-theme ink

Review feedback on the rendering pass:
- SSGI back to main's params (1 slice / 4 steps / r1, AO-only) everywhere,
  including thumbnails — SSGI_BAKE_PARAMS removed. Kills the added AO grain
  and the extra per-frame cost; bake workers also stop paying for heavier GI.
- Shadows stay visible via plain knobs only (intensity 0.9, radius 4): the
  custom TSL PCSS filter is gone, shadow map back to 1024, filter back to PCF
  (r184's Vogel-disk PCF respects radius, so edges stay soft).
- Night/twilight ink edges: colour now derived from the theme background
  (lifted toward white) instead of a near-white constant, and dark scenes run
  the ink at 70% alpha — no more glowing wireframe on dark backdrops.
- Snapshots: the `transparent` capture flag is now honored — preset/item
  captures keep their alpha, while studio renders and project thumbnails
  composite the theme background + sky gradient (same world-ray math as the
  viewport backdrop, uniform-driven so the cached pipeline serves both).

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

* feat(viewer,nodes): ground the scene — contact vignette + horizon haze, shadow tune

Follow-ups from review:
- Shadow intensity 0.9 → 0.75 (read too heavy) and PCF radius 4 → 2: the
  filter's per-pixel dither spreads with radius, which showed as dots across
  wide penumbras. Both are free knobs.
- New shared backdrop formula (viewer lib/backdrop.ts): background below,
  theme-derived haze band hugging the horizon (background lifted toward
  white — faint glow on dark themes), sky above. Used by the post pipeline,
  the thumbnail pipeline, and the site horizon disc, whose far-field
  dissolve now evaluates the same gradient per fragment view direction —
  ground and backdrop converge to identical colours, so no horizon seam
  from any camera pose.
- Contact vignette on the horizon disc: a soft albedo darkening hugging the
  lot (15%, fading out by ~2.6 lot radii) so the parcel sits on the field
  instead of floating on it. Albedo-only — never tints the dissolve.

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

* fix(nodes): make the contact vignette read on bright themes

A fixed 15% albedo cut disappears into the tone mapper's shoulder on themes
with strong key lights (studio runs intensity 4), and the dissolve's bright
emissive diluted what was left. Scale the vignette with the theme's strongest
light (0.13×, clamped at 0.45) and apply the halo to the in-band emissive as
well — it zeroes out by 2.6R while the dissolve completes at 5R, so the far
field stays the pure backdrop and the horizon seam guarantee holds.

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

* fix(viewer): no more horizon line, sky gradient reaches the horizon

Two things drew a visible line where the infinite ground met the sky:
- SSGI AO grows a band along the geometry↔sky depth cliff (same disease the
  ink pass had). Fade AO to 1 with raw depth over the ink's ≈150→350 m
  window, in both the viewport and thumbnail pipelines — AO is a near-field
  cue, it has no business shading the horizon.
- The backdrop's flat haze plateau sat between two ramps, which the eye
  amplifies into Mach lines. The gradient is now one smooth background→sky
  ramp crossing the horizon, with the haze applied as an exponential glow
  peaking exactly at dir.y = 0 — C¹-smooth on both sides, edge-free.

The sky ramp also starts at the horizon instead of ~11° up, so the theme's
backgroundSky blue actually reads at eye level instead of hiding at the top
of the frame.

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

* feat(viewer): three-stop sky — derived deep zenith, warm horizon haze

The single pale backgroundSky stop read as a white void with blue hiding at
the top of the frame. The backdrop is now three derived stops, inZOI-style:
- pale theme sky arrives fast (full by ≈8° elevation),
- then deepens toward a zenith colour derived per theme in HSL (saturate
  ×1.5, darken ×0.72 — hue stays the theme's own: blue studio, lavender
  sunset, near-black night),
- horizon haze now lifts toward a warm white (#fff4de) instead of pure
  white, giving the junction the slight yellow of sun-scattered atmosphere.

All derived from the existing backgroundSky/background fields — no theme
data changes, and the shared-formula seam guarantee (viewport = captures =
horizon disc) carries over.

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

* fix(viewer): horizon haze derives from the sky, hugs the horizon tighter

White-based haze read as a tall white stripe between ground and sky. Aerial
perspective is sky-coloured light with a little sun scatter, so the haze now
pulls the theme's backgroundSky toward the warm sun tint (50% light themes,
25% dark) and the glow decay tightens (exp −7→−11, weight 0.9→0.8) — the
merge band is skyish, sunish, and half the height, so blue starts right
above the ground line.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:24:58 -04:00
c45808840c nodes: add modular cabinets and wall trim controls (#461)
* 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

* Add modular cabinet node

* Add modular cabinet run and tall cabinet workflows

* Add cabinet paint slots and material groups

* Improve modular cabinet presets and wall snapping

* Improve cabinet wall snapping

* Fix cabinet wall snapping and countertop joins

* Improve cabinet appliance compartments

* Improve fridge visuals and smooth cabinet animations

Fridge exterior is now brushed silver with brass accents and the
interior is all white. Door/drawer animation no longer rebuilds the
full cabinet geometry per frame — animated parts carry pose metadata
that a new per-frame cabinet system applies directly to transforms.

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

* Improve gas hob flames and cooktop knob controls

Port the curved-flame look from reference gas-burner photos: each burner
gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips)
whose spines breathe and flicker per frame at ~30fps, plus a flat ignition
glow and a faded heat halo. Fix the knob pointer notch to rotate with the
knob instead of drifting sideways. Also includes cooktop compartment
presets/panels and cabinet selection/move affordance work.

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

* Fix cabinet UVs and registry actions

* Fix registry move snap typecheck

* Fix cabinet knob animation concurrency

* Fix cabinet material disposal, undo flood, and 2D move parity

- Flag shared cabinet appliance materials (and the viewer's cached material
  factories) as cached so geometry rebuilds no longer dispose materials
  still referenced by other nodes, forcing scene-wide shader recompiles.
- Route the door/drawer open animation through useLiveNodeOverrides with a
  single final commit, so one play is one undo step instead of ~20.
- Add a cabinet-module floorplanMoveTarget: 2D drags now convert through
  planToLocal + magneticSnap like the 3D path, instead of writing plan
  coords into the run-local position (teleporting modules on rotated runs).
- Re-key sibling runs (cabinetAdjacencyRevision) when a run's
  neighbor-affecting inputs change, so countertop overhang joins re-trim
  when a neighbor moves/resizes/deletes.
- Narrow the cabinet panel's scene subscriptions (useShallow module
  selector) so it stops re-rendering on every scene mutation.

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

* Restructure cabinet schema and centralize run math/mutations

Prepares the cabinet node for the queued specialty units (corner L-shape,
sink base, appliance gap, open shelving):

- Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode
  so new unit types extend an enum instead of overloading the stack, and
  split CabinetCompartment into a z.discriminatedUnion so invalid field
  combos (drawer with rack style, fridge with burner state) are
  unrepresentable. Shared box fields now come from one `cabinetBoxFields`
  object so run/module schemas can't drift.
- Extract the straight-line run assumption (sort-by-x, edges, adjacency,
  spans, side-insert, reflow, frame transforms) into run-layout.ts —
  previously spread across definition/geometry/quick-actions/stack/
  move-frame, so corner support would have meant five parallel edits.
- Consolidate the run mutations (add module, wall cabinet/hood above,
  base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi.
  Panel and quick-actions had drifted copies: the panel's add-module
  skipped gap checks, revision-bump scope differed per surface.
- Remove dead code: node-level doorStyle (with a migrateNodes entry —
  geometry only ever read per-compartment doorType), the unreachable
  slot-handle mesh block, the no-op handlePosition 'edge' enum value,
  wallLocalX, and duplicate totalCabinetHeight definitions.

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

* Add cabinet snap tests and split the cabinet panel

- New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold,
  Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset /
  collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss
  fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71.
- De-brittle geometry test lookups: coordinate-encoded mesh names are now
  matched by pattern, so dimension-default changes don't break them.
- Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants
  into compartment-card.tsx, CabinetRunPanel + reflow wiring into
  run-panel.tsx, and the compartment type-transition tables into a pure
  resolveCompartmentTransition in stack-transitions.ts.

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

* Split cabinet geometry.ts into per-appliance modules

Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator
dispatching into geometry/ — shared helpers + cached appliance materials
(shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans +
countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/
oven-microwave/pantry builders. No mesh names, userData stamps, materials,
or math changed; the './geometry' import path is unchanged.

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

* Add undermount sink compartment with CSG-cut countertops

New 'sink' compartment type (single / double / 60-40 bowl layouts)
following the cooktop pattern: zero stack height, countertop-plane
geometry, panel card with a bowl-layout selector, and a Sink Base
preset. Bowl openings are subtracted from both module- and run-owned
countertop slabs via three-bvh-csg; the basin shells, drain plumbing,
and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin
lever) render beneath the cut. Sink modules skip the carcass top panel
and the deck under the sink row so the basin hangs through.

Also scope the viewer mock in roof-surface-placement-guides.test.ts to
only stub useViewer — the bare module stub leaked into later suites and
broke real CSG imports.

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

* Update ifc-converter next-env reference to build-mode route types

Regenerated by `next typegen` during check-types; points at
.next/types instead of the dev-mode .next/dev/types path.

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

* Draw cabinet floor plans with kitchen drafting symbols

Replace the per-module box soup with NKBA/architectural-convention
symbols: the run draws one heavier countertop outline per span
(extended by the overhang), base modules draw their front edge plus
compartment symbols — rounded bowl rects + faucet dot for sinks,
burner/zone rings for cooktops (reusing the 3D layout tables so 2D and
3D always agree) — and appliance modules carry standard upright labels
(DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules
draw as dashed open outlines per the above-cut-plane convention.

* Play cabinet open/close animation on the E interaction key

Add an `e` slot to registry KeyboardActions and dispatch it in the
keyboard hook ahead of the legacy door/window arms, so kinds opt into
the E interaction on their NodeDefinition. Cabinets register it: E on a
module eases its doors/drawers open or closed, E on a run swings every
child module together, and hood-only modules fall through. The panel's
rAF animator moves into cabinet/interaction.ts (live-override frames,
single undo commit) so the Play button and E share one animation, and
the shortcuts dialog now documents E.

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

* Add kitchen island and bar counter support to cabinet runs

Islands: run-level countertopBackOverhang (seating side) and
withFinishedBack (decorative back panel), plus an I-key island placement
mode in the cabinet tool that skips wall snap. Bar counters: optional
barLedge {edge, height, depth} drawing a knee wall and raised slab along
the back or either run end, superseding the seating overhang on its
edge. withWaterfall drops slab-material panels to the floor on exposed
run ends. 2D floorplan outlines, selection bounds, geometry keys, and
run panel controls updated in parity; schemaVersion 3 -> 6.

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

* Merge quick-action arrow and plus into one directional add glyph

The add-left/add-right buttons showed two unrelated icons; a single
plus-on-tail arrow reads as "add on this side".

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

* Extend plugin discovery instead of replacing the host source

Also track the ifc-converter next-env routes reference update.

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

* Add L-corner runs to modular cabinets with run-scoped grouping

An "L Left/Right" quick action spawns a perpendicular base leg, wall leg,
and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the
run stays the single group: selecting a base module selects only it, and
the clicked module never becomes a container. Corner runs re-anchor when
the source module resizes or moves (new MovableParentFrame.onCommit hook,
mirrored in the 2D floorplan commit), run bounds fold in child leg runs,
and deleting one corner member removes only that node while unlink patches
keep the survivors' link metadata consistent.

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

* Delete emptied cabinet run groups in the same delete gesture

Removing a run's last child (module or derived corner leg) left an
empty group node in the scene graph and persisted data. onDeleteCascade
now receives the gesture's pending delete ids so multi-select deletes
count siblings as gone, and both cabinet kinds cascade the orphaned
parent run away in one undo step.

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

* Remove filler front gap treatment

* Apply architecture review fixes across cabinet, plugin-trees, and framework

Move cabinet semantics out of packages/editor behind registry capabilities
(def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons,
catalog tool field), route cabinet selection through a core selection-proxy
helper, and make PanelWorkspace an opaque host tag. Fix the cabinet
placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable
slots, plugin-trees ghost layers/stale level transforms/untyped find-sync
event, and cross-file store-mock pollution plus orphaned dist tests that
broke combined test runs.

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

* Propagate front styling to L-corner runs even when re-layout bails

Group style changes rode entirely on syncDerivedCornerRun, which bails
silently when a later-drawn wall blocks the corner layout or a leg run
gained modules outside the derived-run spec — leaving the L legs styled
stale. Apply the style patch directly to every linked corner run before
attempting the geometric re-layout.

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

* Improve editor viewer integration

* Refactor plugin panels and floorplan invalidation

* UV-unwrap and material-assign roof-segment trim planes

* Fix cabinet placement snap feedback

* Add cabinet wall snap bypass

* Fix cabinet continuous placement flow

* Implement wall band material slots

* Add corner addition preview functionality and related tests

* Fix cabinet and wall band behavior

* Fix architecture review blockers

* Fix post-merge quality check

* fix cursor review feedback

* Fix cabinet placement type and quick action history

* Improve cabinet placement feedback

* Make wall bands configurable

* Add wall trim profiles and default materials

* Fix architecture review issues

* Fix cabinet rotation and wall band visibility

* Fix sink appliance paint persistence

* Bake cabinet animations and preserve wall band material

* Fix cabinet group duplication and module snapping

* Fix cabinet duplication architecture

* Fix cabinet floorplan test and make placement-type hint a live chip

- floorplan.test.ts: pass the required liveTransforms map to
  cabinetFloorplanSiblingOverrides (the runtime callers always supply it)
- ToolHint gains an optional generic `chip` contract (subscribe/value/
  cycle/labels) so a kind can render a tool hint as a live mode chip —
  like the snapping/continuation chips — without the editor knowing the
  kind's store
- cabinet: the I hint now shows the current value (Type: Cabinet /
  Type: Island) and is clickable to toggle

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-07-12 18:33:48 -04:00
f6a58489b8 fix(viewer): survive block-misaligned KTX2 textures on WebGPU (#467)
WebGPU rejects block-compressed textures whose base dimensions aren't
multiples of 4; three's KTX2Loader only warns and transcodes anyway, so a
baked GLB carrying one odd-sized user-item texture (e.g. 299x399) poisoned
every render pass — endless 'Invalid BindGroup … Invalid CommandBuffer'
spam. Route such payloads through a fallback transcode to uncompressed
RGBA32 and repackage the pixels as a plain DataTexture (three's WebGPU
upload path crashes on a CompressedTexture holding rgba8 data).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:39:52 -04:00
bf25af6add viewer: Fix Dutch roof trim artifacts (#452)
* Add roof surface placement support for items

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

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

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

* fixed conflict

* Fix spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

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

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

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

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

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

* Snap new walls to the floor below

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

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

* Set Dutch roof shape defaults on type switch

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

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

* Use green accent for corner and endpoint snap markers

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

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

* Add magnetic wall snapping to the roof tool

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

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

* Update auto-generated Next.js route types path

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

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

* Show cutaway outline while dragging roof trim

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

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

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

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

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

* Separate and extend Dutch roof end slopes

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

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

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

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

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

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

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

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

* Include roof accessories in trim clipping and red cutaway

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

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

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

* Add smooth spline fences with editable curve handles

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

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

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

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

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

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

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

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-06-30 16:41:13 -04:00
Wassim SAMADandClaude Opus 4.8 8aa179e9cb feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs
Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:01:37 -04:00
Wassim SAMADandClaude Opus 4.8 737c4e9d1d feat(paint-slots): concrete-plate finish + try defaults (wall=concrete, slab=wood plank 48, ceiling=soft white)
- material-library: add 'concrete-plate' KTX2 finish (512, fabric/leather-style
  pipeline) + editor-app texture mirror.
- viewer: shared resolveSlotDefaultMaterial(colour|library ref) so a kind's slot
  default can be a catalog finish, not just a flat colour.
- wall default -> library:concrete-plate (interior + exterior).
- slab default -> library:wood-woodplank48 (slab geometry resolves it via the
  shared helper).
- ceiling default -> soft white #f2eee6 (ceiling renders flat-tinted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:25:48 -04:00
Wassim SAMADandClaude Opus 4.8 042f855586 feat(paint-slots): KTX2 finish library — fabric/leather/concrete/metal (phase 4)
Wire KTX2 into the catalog finish path and add 15 textured finishes across four
new families, all as GPU-compressed KTX2 (512px) + a webp picker thumbnail.

- Shared `ktx2-loader.ts`: one KTX2Loader (Basis transcoder) used by both the
  GLB loader and catalog textures; `ensureKtx2Support(renderer)` runs once at
  viewer init (GPUDeviceWatcher) so `.ktx2` finishes load even with no GLB in
  the scene. `use-gltf-ktx2` now reuses the shared instance.
- `materials.ts`: texture loaders pick the KTX2 loader for `.ktx2` urls, the
  image loader otherwise (all three load paths).
- `material-library.ts`: 15 entries (fabric ×6, leather ×2, concrete ×4,
  metal ×3). Neutral albedo for tinting, `flipY: false` (compressed textures
  can't flip), `repeat` per real-world tile size, metals `metalness: 1`.
  Normals encoded UASTC, data maps ETC1S/linear.

Assets generated from raw sources via the new community
`scripts/build-material-textures.ts`. 1024/256 tiers + raws kept out of git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:37:23 -04:00
Wassim SAMADandClaude Fable 5 7afb286e47 feat(paint-slots): authored item materials + unified slot painting
Phase 1 + paint unification of the paint-slots plan.

- core: scene-material data layer (materials map mirroring collections,
  undo/partialize/setScene full-graph support), SceneMaterial schema,
  scene:/library: MaterialRef helpers + parseMaterialRef, slot id helpers
  (deriveSlotId/slotLabelFromId), slots map on ItemNode, hitObject on
  PaintResolveArgs, optional PaintCapability.commit.
- viewer: resolveMaterialRef (library:/scene: -> three material, null on
  dangling).
- nodes(item): renderer keeps authored GLB materials for slot-authored
  assets and applies per-slot overrides per-instance (never mutates the
  shared cached GLB); textures-off still collapses to furnishing role;
  non-authored items unchanged. Item paint capability + registration.
- editor: item joins the unified (nodeId, slotId) paint dispatch; item
  paint target + slot reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:01:08 -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 eb00f9f477 fix: harden editor scene loading 2026-06-09 19:54:42 -04:00
ce6f999310 arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* Add roof surface placement support for items

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

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

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

* fixed conflict

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

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

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

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

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

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

* Fix recessed ceiling fixtures and draw safety

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

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

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

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

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

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

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

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

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

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

Three architectural fixes to bring the branch into full compliance:

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

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

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

All four packages typecheck cleanly (zero errors).

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:14:39 -04:00
Aymeric Rabot 03f57b1f4f Fix stair opening previews and slab cutouts 2026-06-07 00:46:51 -04:00
eb1f2d12c2 feat(nodes): roof accessories — gutters, downspouts & vents + gizmos (#355)
* Add roof surface placement support for items

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

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

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

* fixed conflict

* fix(editor): ceiling-attached item placement from 2D floor plan

The 3D viewer drives ceiling-item placement via ceiling:enter/move/click
raycast events on the ceiling mesh. The floor plan has no such mesh, so
ceiling-attached items (lights, fans) never transitioned out of
surface: 'floor' — the draft sat at floor height while the 2D cursor
moved freely, reading as a 2D/3D sync bug.

Synthesise the same ceiling events from 2D plan points by hit-testing
ceiling polygons on the active level, and publish the building-local
cursor (not world-space) to useLiveTransforms so the floorplan registry
override renders the draft under the cursor regardless of building
position / rotation.

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

* fix(editor): chevron arrows render on SCENE_LAYER so ink-edge shader outlines them

Node arrow handles and polygon-editor edge arrows were tagged for
EDITOR_LAYER, which hides them from the post-processing scenePass — the
ink-edge shader reads the depth/normal MRT from that pass, so the
chevrons rendered flat with no outlined edges. Drop the EDITOR_LAYER
tagging on both, matching the wall-height arrow which already stays on
SCENE_LAYER for the same reason.

Pair with depthWrite: true on the chevron materials so their silhouettes
enter the depth buffer; depthTest stays off to keep the chevron drawn
on top of underlying geometry. Without depthWrite, only the
normal-discontinuity branch of the ink shader can detect the chevron,
and the lines drop out when faces align with whatever sits behind them
in screen space.

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

* fix(editor): ceiling grid overlay no longer blocks selecting items under it

Two issues kept the ceiling grid overlay covering the room after the
user moved on from a ceiling-related action:

1. CeilingSystem treated any selected descendant of a ceiling as
   "reveal the grid" — so after placing a ceiling light and the new
   item became selected, the grid stayed on and its mesh intercepted
   every subsequent 3D click, re-selecting the ceiling instead of the
   items below. Restrict the reveal to directly-selected ceilings.

2. The ceiling top material used the opaque surface-role material, so
   a top-down camera lost view of everything under the ceiling the
   moment the overlay turned on. Swap the top material for the
   transparent grid-pattern material (bottom stays opaque so the
   in-room view still reads as a solid surface).

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

* fix(core): curve- and thickness-aware wall/slab overlap detection

`wallOverlapsPolygon` and `getSlabElevationForWall` previously treated a
wall as an infinitely thin chord from start to end. Two failure modes
fell out of that:

1. Curved walls whose chord lies outside the slab but whose centerline
   bows into the slab interior were missed entirely. The wall stayed at
   Y=0 while the slab elevation moved, and `markNodesOverlappingSlab`
   never re-dirtied it when the slab Y changed.

2. Perimeter walls of a room — whose centerline sits exactly on (or just
   outside) the slab's polygon edge — also missed detection, because
   pointInPolygon on the boundary is unreliable. Half the wall's body is
   inside the slab; it should follow the slab elevation.

Switch `wallOverlapsPolygon` to a wall-shaped input (start/end + optional
curveOffset + thickness), sample the centerline for curved walls, and
add a ±halfThickness perpendicular test for straight walls. Threaded
through `getSlabElevationForWall`, the wall system, and the
`markNodesOverlappingSlab` pass. Legacy chord-only call shape preserved
for callers that don't yet have a wall in hand.

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

* fix(editor): show Move button for legacy-movable kinds in floating action menu

`isRegistryMovable` only sees kinds wired through `capabilities.movable`,
`floorplanMoveTarget`, or `affordanceTools.move`. The legacy tail of
`MoveTool` (tools/item/move-tool.tsx) still handles roof, roof-segment,
stair, stair-segment, building, and elevator, but the floating action
menu was hiding the Move button for them because the registry check
returned false. The mover worked once invoked — the entry point was
missing.

Add a `LEGACY_MOVABLE_KINDS` set alongside the registry check so those
kinds get the Move button until they migrate onto kind-owned
affordances; drop a kind from the set once it does.

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

* feat(nodes): in-world registry handles for roof accessories + axis-stable surface basis

Adds the registry-driven chevron / tracker / rotate-gizmo handle set to
skylight, solar-panel, chimney, and roof-segment so every roof-mounted
kind has consistent in-world manipulation, and fixes the underlying
renderer + surface-basis bugs that made those handles land on the wrong
spot or behave inconsistently across mirrored slopes.

Handles
- `LinearResizeHandle` gains `shape: 'arrow' | 'tracker'`. `'tracker'`
  renders a dashed vertical leader from the surface up to a draggable
  cube, reusing the linear-resize drag pipeline. Roof-segment's
  wall-height handle adopts it.
- Roof-segment: width chevrons split into two asymmetric handles
  (each grows its own edge, opposite stays world-fixed via `apply`
  recomputing `position`).
- Skylight: width × 2 (asymmetric), height × 2 (asymmetric), curb-height
  tracker, rotate gizmo (corner, lifted off surface), and a diagonal
  frame-thickness chevron at the -X+Z corner.
- Solar panel: same six handles operating on total array dimensions
  (back-solving `panelWidth` / `panelHeight` from `columns` / `rows`),
  plus a frame-depth chevron above the array.
- Chimney: registry handle set following the same idioms.

Skylight / solar-panel renderer
- Collapse the previously nested `position → surfaceQuat → rotation-y
  → rotation-x` groups into a single registered transform group whose
  local `position` + composed `quaternion` carry the full pose in
  segment frame. The registry handles read this Object3D's local
  matrix (via `portal: 'grandparent'`), and a split tree exposed only
  the bottom group's local pose so handles landed at the segment
  origin on the roof floor.

Surface basis (solar-panel/geometry.ts)
- `surfaceQuatFromNormal` builds `right` by projecting world +X onto
  the surface plane instead of `up × normal`. The cross-product version
  flipped sign when the normal's Z component flipped (e.g. the two
  slopes of a gable roof), so hosted children's local +X pointed in
  opposite world directions across the ridge and asymmetric chevrons
  anchored the wrong edge. Projecting +X keeps the basis stable across
  mirror-image slopes.

Skylight move-tool ghost
- Switch from the raycast normal (`event.normal × normalMatrix`) to the
  analytical normal (`getAnalyticalNormal`) on every pointer move, and
  mirror the placement tool's transform stack: `position → yaw (roof +
  segment) → surfaceQuat → skylight rotation → preview`. Re-engaging
  Move from the floating action menu now shows the same correctly
  oriented ghost the first-placement tool does.

* feat(nodes): split roof-segment depth chevron into asymmetric front/back arrows

Brings the depth handle in line with the width handles: one chevron on
each Z edge, each anchored to the opposite edge so dragging only moves
its own side. `apply` recomputes `position` along the segment's local
+Z arm (yaw-aware) so the anchored edge stays world-fixed.

Depth also feeds the slope-frame math via `getActiveRoofHeight`, so a
naïve depth change would also raise/lower the peak (constant pitch
across a larger run). We hold the peak fixed by back-solving a new
`pitch` for the new depth via `getPitchFromActiveRoofHeight`, clamped
to the schema's pitch range — the segment grows along the deck plane
without ramping up.

* feat(nodes): in-world handles for dormer + window-bottom clipping check

Dormer joins chimney / roof-segment with chevron handles on the
selected node. Five body handles (width L/R, depth, wall-height
tracker, rotate) plus four window-opening handles (width L/R,
height top/bottom) — the window handles re-emit windowOffsetX /
windowOffsetY in `apply` so the anchored edge stays put as the
dragged edge follows the pointer.

Handle visibility on roof accessories needed an editor-side assist:
the host segment's mesh registers inside RoofRenderer's
`<group segments-wrapper visible={false}>`, which hides anything
portaled into it. Chimney's `portal: 'grandparent'` escape trips a
WebGPU "Color target has no corresponding fragment stage output"
pipeline error on dormer (likely an MRT interaction with the
window-assembly's transparent glazing), so RoofEditSystem now flips
the wrapper visible whenever ANY accessory hosted on a segment of
this roof is selected — and resets each segment mesh to an empty
4-group placeholder on the transition so stale per-segment CSG from
a prior edit doesn't double-render on top of the merged shell.

Window clipping was using wall-top-above-slope as the exposure
threshold, but the window sits in the skirt well below the eave —
so a dormer whose eave barely cleared the host roof rendered a
fully-buried window. `getDormerExposedFaces` now gates on
window-bottom-above-slope; both the CSG cut decision and the
window-assembly render path feed off the same number. The
in-world window chevrons resolve the host segment via sceneApi and
flip to whichever face is currently exposed, so dragging the
dormer across the ridge moves the chevrons to the visible gable.

Also fixes the BoxGeometry vs ExtrudeGeometry mismatch in
`buildDormerFallbackGeometry` — body was indexed, roof was not, so
`mergeGeometries` rejected the pair and spammed the console on every
height-drag frame. Body is now `.toNonIndexed()` before the merge.

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

* fix(viewer): glazing role uses FrontSide to avoid MRT back-face pipeline error

DoubleSide on a NodeMaterial inside the MRT scene pass makes WebGPU
compile a back-face shader variant that doesn't declare outputs for
every MRT target — the validator rejects it and poisons the render
context with "Color target has no corresponding fragment stage output".
The warning was already documented on `glassMaterial` (materials.ts:77),
but `createSurfaceRoleMaterial` was still forcing DoubleSide for the
glazing role.

Manifested on scene open as soon as a dormer was present: the dormer's
window-assembly mounts the glazing material on both gable faces on the
first frame, so the back-face pipeline gets compiled immediately.

Glazing now resolves to FrontSide; the dormer's back gable group flips
180° so its FrontSide normals point outward (the sill no longer needs
its per-face Z mirror since the group rotation handles it).

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

* feat(nodes): in-world handles for box-vent + ridge-vent; freeze non-active arrows during drag

Adds 5 chevron handles to box-vent (width L/R, depth, height, rotate)
and ridge-vent (length L/R, width, height, rotate). Box-vent's renderer
now composes slope tilt + yaw onto the registered ref's quaternion
(mirrors solar-panel) so handle placements use vent-mesh-local coords
directly; ridge-vent's registered ref was already at the vent frame.

Ridge-vent renderer now merges `useLiveNodeOverrides` so the mesh
updates in-flight during a handle drag instead of freezing until commit.
Box-vent / dormer / chimney already did this; ridge-vent was the only
roof accessory not subscribed.

`NodeArrowHandles` now tracks the active drag descriptor + a pre-drag
store snapshot. Non-active arrows render against the snapshot with a
node-local freeze offset that cancels the mesh's `position` drift —
asymmetric resize (width / length L+R) recomputes position to anchor
the opposite edge, and without the freeze every other chevron would
slide along with the moving mesh center. The active arrow's freeze
offset is null, so it tracks the cursor as before. Rotation drags
collapse the offset to zero (position doesn't change), so non-active
chevrons naturally rotate with the mesh.

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

* feat(nodes): gutter accessory — eave-mounted rain channel with three profiles

New `gutter` node kind hosted on a roof-segment. Placement tool snaps
to the eave line of the segment under the cursor (segment-local
`Z = +depth/2`, `Y = wallHeight`); the back wall of the gutter then
sits flush against the fascia and the trough hangs outward (+Z).

Three cross-section profiles share the same outer-outline-minus-cavity
extrude recipe and only differ in the outline curve:

  - `k-style`:    ogee fascia (S-curve) — default residential look
  - `half-round`: semicircular trough — colonial / classical feel
  - `box`:        rectangular u-channel — commercial / industrial

Three in-world chevron handles via the registry:

  - length L + R (asymmetric — drag one end, the other stays world-fixed)
  - size (anchor='max', drops the trough downward as the cursor pulls)

Wiring touches every node-kind ledger: schema (core/schema/nodes/
gutter.ts), AnyNode union, schema barrel, material targets, roof-segment
hosted-accessory comment, event bus (`gutter:*`), nodes barrel +
registry, plus the per-kind file set under `packages/nodes/src/gutter/`
(geometry, schema re-export, parametrics, renderer, preview, tool,
definition, index).

V1 ships gutters only — downspouts deferred so the eave-snap +
cross-section pipeline can be eyeballed before stacking the
downspout-corner placement logic on top.

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

* feat(gutter): roof-panel entry, eave-edge snap, open-top U-channel, move tool

UI surface area:
- Roof inspector "Elements" section now lists existing gutters + an
  "Add Gutter" button. Adds `'gutter'` to the StructureTool union so
  setTool('gutter') typechecks.
- Sidebar tree-node map gets a GutterTreeNode entry; selecting a
  gutter in the outline focuses it just like other roof accessories.
- Floating action menu now shows a Move button on a selected gutter
  via the new `affordanceTools.move`. MoveGutterTool ghost-follows
  the cursor, eave-snaps on each frame, and commits to the new
  segment + side on click. Mirrors the ridge-vent move flow.

Geometry fix:
- Three cross-sections were authored as a closed outline + inset
  hole, which extrudes as a sealed box with a tunnel through it
  (top sealed). Real gutters need an OPEN top. Each profile now
  traces a single U-shape polygon around the channel material:
  outer wall down -> bottom -> outer wall up -> front rim ->
  inner wall down -> inner bottom -> inner wall up -> back rim.
  The interior of the U is empty space, not a hole inside a
  closed shape.

Placement fix:
- Snap now lands on the OUTER drip edge of the roof, not the wall
  line. Segment-local Z = sign * (depth/2 + overhang - 4 cm tuck),
  Y = wallHeight - overhang * tan(pitch) + 4 cm tuck. Sign of the
  cursor's localZ picks the near eave; back eave uses rotation = pi
  so the trough hangs outward in both directions. The 4 cm tuck
  offsets keep the gutter visually attached to the fascia rather
  than floating at the very tip of the overhang.

Hook order fix (regression from the previous commit):
- `NodeArrowHandlesForNode` had its new useState/useMemo hooks
  AFTER the `if (!portalObject ...) return null` guard. The
  registry-resolve useEffect flips portalObject from null to object
  one frame later, so the guard passed on render N+1 and three new
  hooks suddenly appeared in the hook list. Moved them above the
  early return.

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

* fix(viewer/nodes): drop DoubleSide NodeMaterial MRT landmines across slab, vents, gutter, window

DoubleSide on any NodeMaterial inside the MRT scenePass makes WebGPU
compile a back-face shader variant whose fragment outputs don't cover
every MRT target — the validator rejects the pipeline and poisons the
render context with "Color target has no corresponding fragment stage
output but writeMask is not zero", manifesting as
renderPipeline_NNN invalid on scene open. The pattern was already
documented at materials.ts:77 and fixed for glazing in 9400f1c5, but
several roof / floor renderers still requested DoubleSide on
`createSurfaceRoleMaterial` (which returns a `MeshLambertNodeMaterial`)
and on user-supplied materials (which may also be NodeMaterials):

- slab/geometry.ts — fired on every untextured floor; the live culprit
  on scene reload after the gutter renderer was switched to FrontSide.
- gutter/renderer.tsx — the U-channel cross-section is traced as a
  single closed polygon around the material, so ExtrudeGeometry already
  produces outward-facing normals on every visible face; DoubleSide was
  speculative.
- box-vent / ridge-vent renderers — DoubleSide was deliberate (to keep
  back faces of thin extrudes visible from below); now a known visual
  tradeoff. Build the geometry as a closed solid in `geometry.ts` if
  the underside-view becomes noticeable; do not bring DoubleSide back.
- viewer/lib/materials.ts `DEFAULT_WINDOW_MATERIAL` — same fix on the
  fallback window material.

Local `defaultMaterial` constants in box-vent / ridge-vent / gutter
also lose their `side: DoubleSide` for consistency (those are
`MeshStandardMaterial`, hit only when a preset ref fails to resolve,
but the same landmine pattern).

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

* feat(editor): Shift-snap rotate gizmos to 15° increments

Holding Shift while dragging a whole-node rotation arrow now snaps the
delta to π/12 (15°) steps. Scoped to `descriptor.shape === 'rotate'`
so curved-stair sweep handles keep their continuous feel.

* feat(nodes): stair railings track live segment-drag overrides

`StairRailings` was reading each child segment from zustand only, so
width/length/height drag handles (which publish to
`useLiveNodeOverrides` and only flush on release) left the railing
frozen at the pre-drag values until release. Subscribe to the override
map and merge each child's override onto its zustand snapshot so the
railing rebuilds every frame during the drag.

* chore(ifc-converter): next-env routes path moves under .next/dev/types

Auto-generated next-env.d.ts update from the local Next.js dev server —
the routes type now lives under `.next/dev/types/routes.d.ts` rather
than `.next/types/routes.d.ts`.

* fix(gutter): roofType-aware eave snap — 4-way on hip/flat, low side on shed

Gutter place + move tools were hard-coded to snap to ±Z eaves, working
for gable / gambrel / mansard / dutch but missing:

- Hip / flat: 4 eaves, not 2. Clicks on the side slopes (±X eaves)
  collapsed back onto ±Z, so users couldn't place a gutter on a hip's
  side eave at all.
- Shed: only one real eave (the low side at +Z). Clicking on the high
  wall side used to snap to -Z, which is the rake / high end with no
  fascia to hang from.

New shared `eave-snap.ts` module:

- `resolveEaveSnap(segment, localX, localZ)` returns
  `{ eaveX, eaveY, eaveZ, rotation, side }`.
- Hip / flat picker uses `max(|lx|/halfW, |lz|/halfD)` — same
  discriminator `analyticalSurfaceY` uses for hip — to pick which of
  the four slopes the cursor is on, then signs +/-.
- ±X eaves rotate the gutter ±π/2 so its outward axis points away
  from the building.
- Shed always returns +Z (the low side).
- Gable / gambrel / mansard / dutch unchanged (±Z).

Both tools collapsed their duplicated tuck constants + inlined
resolver — the "keep these in sync" comment became a landmine once
the resolver grew non-trivial.

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

* feat(gutter): end caps + corner mitre + ghost-placed parity

- Ghost/move previews mirror the GutterRenderer transform chain
  (roof → segment → snap) and use FrontSide. Removes drift between
  the placement ghost and the gutter that lands on click.

- New endCapLeft / endCapRight booleans (default true) slice a
  solid-outer plug into the extrusion at each enabled end. Inspector
  exposes both toggles; caps subtract from node.length so the
  user-set span is preserved.

- corner-mitre.ts detects sibling gutters meeting within 5 cm on the
  same segment and returns per-end mitre angles. The end-face skew
  holds back walls at the inner corner while front rims extend to
  the outer eave intersection; cap on a mitred end is force-
  suppressed so the L-junction stays open. Renderer pulls siblings
  via useShallow.

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

* feat(gutter): drag-snap corners, live eave Y, hangers

- Length L/R handles now snap to sibling gutter endpoints within 10 cm
  and pull BOTH gutters' lengths to the axis intersection — the
  geometric eave corner — so the 5 cm corner-mitre window fires
  reliably. Sibling adjustment writes through sceneApi.update; the
  drag pipeline's history pause batches it with the main commit into
  one undo step.

- Renderer derives eave Y live from segment.wallHeight, overhang, and
  pitch via the new shared computeEaveY() — instead of trusting
  node.position[1] from placement time. Subscribes to the segment's
  useLiveNodeOverrides entry too, so a wall-height drag on the
  segment moves the gutter on every frame (not just at commit).

- Hangers: new hangerStyle (strap / none) + hangerSpacing fields.
  buildHangers() lays thin 25mm × 3mm × rim-width box straps across
  the rim at the configured spacing, inset by 5 cm from each end and
  skipping any cap slabs. BoxGeometry is converted to non-indexed
  before merge — ExtrudeGeometry isn't indexed, and mergeGeometries
  rejects mixed-index sets. Inspector exposes both fields in a new
  "Hangers" group.

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

* feat(gutter): downspout outlet with real CSG-drilled hole

New schema fields: outletSide ('none' | 'left' | 'right'), outletInset,
outletDiameter. Default 'none' so existing gutters don't sprout
outlets on schema upgrade.

Geometry adds a solid cylindrical stub at bore + 3 mm wall radius
descending 6 cm from the trough floor, profile-aware Z midpoint
(k-style 0.4·size, half-round size, box size/2), X clamped between
the caps. After merging into the channel + caps + hangers, a
three-bvh-csg SUBTRACTION drills a bore-wide cylinder vertically
through the floor and stub — so the result is a real hole in the
trough floor with a hollow tube hanging through it. Drill overshoots
floor + stub by 1 cm each side to keep cut planes from coinciding
with mesh faces (csg-evaluator produces degenerate output on
coplanar cuts).

CSG only runs when outletSide ≠ 'none' — the existing merge path is
the fast path for capped-only gutters.

Inspector exposes the three fields under a new 'Outlet' group.

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

* feat(downspout): new node + gutter inspector list section

DownspoutNode lives next to the other roof accessories. Scene-graph
parent is the same roof-segment the host gutter sits on; logical
attach is via `gutterId`. Schema: length (default 2.5 m), diameter
(default 0.07 m / 3″ — matches gutter outlet default), material.

Renderer mounts a CylinderGeometry under the same transform chain
the gutter uses (segment → gutter-mesh-local → outlet); pulls
`computeEaveY` from the live + drag-override segment so wallHeight /
overhang / pitch changes track on the same frame as the gutter.
`resolveGutterOutletPlacement` (gutter/outlet-lookup.ts) is the
shared helper both the downspout renderer and the inline Add path
use to compute (x, y, z, bore) in gutter-mesh-local space.

Two arrow handles on a selected downspout:
- length: tracker shape (dashed leader + draggable cube). Anchored
  to the outlet, cube at the pipe bottom — readable even when the
  cube ends up below ground.
- diameter: symmetric `z`-axis chevron sitting at a fixed −20 cm Y
  below the outlet with 25 cm of outward clearance past the worst-
  case k-style rim, so it stays inside the gutter's camera frame
  instead of floating mid-pipe.

Inspector UX: new optional `trailingSection` slot on
ParametricDescriptor — a lazy-loaded React subsection rendered
between groups and the Actions section. Gutter's slot loads a
`downspouts-panel` that lists every attached downspout (button per
item, click → select), and an "Add Downspout" button below that
immediately creates a new one parented to the gutter's segment —
matches the roof inspector's gutter list pattern. Disabled with a
helper line when `outletSide === 'none'`. Multiple downspouts per
gutter are allowed.

`StructureTool` union picks up 'downspout' so the placement tool
remains addressable (used by the legacy roof-panel button before the
inspector list took over).

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

* chore: pending floorplan alignment-guide work-in-progress

Snapshot of in-flight alignment-guide files that have been sitting in
the working tree (not authored in this session). New service +
zustand store wire up the data; the floorplan layer + overlay are
the visible consumers. Committed as-is to clear the tree.

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

* feat(core): gutter multi-outlet model + downspout routing options

Replace the gutter's single outletSide/outletInset/outletDiameter triple
with an `outlets` array of `{ id, offset, diameter }` so one run can host
several downspouts on independent drops instead of stacking on one. Each
downspout links to an outlet by `outletId`.

Add downspout routing/styling fields: `standoff` (gap proud of the wall),
`shape` (auto/round/rect), `strapStyle`/`strapSpacing`, and `terminal`
(splash/kickout/straight).

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

* feat(editor): in-world rotate & move gizmos with live-drag dimension pill

Add two new handle-descriptor capabilities to the core registry — a free
`translate` handle (ground-plane or wall-normal move cross) and
`rotationPlane` for arc-resize (yaw vs spin-flat-against-wall) — plus
`measureLabel`, which routes a resize handle's readout to a floating
dimension pill instead of its inline chip, and `overrideTarget`, a
cross-node redirect for handles that edit a sibling's value.

node-arrow-handles implements all four, merging the in-flight drag into
`useLiveNodeOverrides` so the mesh moves in real time and commits only on
release. Item gains in-world rotate + move gizmos (floor items: world-Y
rotate + floor-plane move; wall items: wall-normal spin + wall-face move).

Add the shared MeasurementPill (H · L · T) and formatMeasurement, wired
into the floating action menu (live wall/fence height drag) and the wall /
fence endpoint move tools. Side handles merge live overrides so every
affordance tracks the height mid-drag. Item duplicate now drag-to-places
(no auto-insert) and the placement coordinator rotates in 45° steps to
match the R-key step.

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

* feat(gutter): multi-downspout outlets with CSG drops, routing & profiles

Build out the multi-outlet gutter against the new schema. Each outlet is
drilled through the trough floor via CSG (profile-geometry shares the
trough cross-section), and a downspout links to one outlet by id so
several no longer stack on a single drop. The downspouts panel manages the
outlet list; outlet-lookup resolves a downspout's mount from its gutter +
outlet.

Downspout gains real routing (routing.ts): offset elbows step the run back
from the eave overhang to the wall (standoff escape hatch), auto/round/rect
cross-section following the gutter profile, wall straps, and splash /
kickout / straight terminals, with inspector-editors for the new fields.

Length-snap now snaps only the dragged gutter to the geometric corner —
never moving its corner-mate — so dragging one gutter can't reset another
the user placed deliberately. Adds gutter floorplan (eave-line silhouette).

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

* feat(floorplan): 2D footprints for roof nodes + rotate-handle angle wedge

Add floorplan builders for box-vent, chimney, dormer, ridge-vent, roof,
skylight, and solar-panel, and wire each into its definition, so roof-layer
nodes finally draw a 2D footprint. Roof draws the merged silhouette of its
child segments, and roof-segment now renders proper architectural roof
linework (ridge / hip / break + shed downslope arrow) per shape instead of
a bare rectangle.

Add a `pivot` to the floorplan rotate affordance so the layer can sweep a
live angle wedge + degree readout during a rotate drag — the 2D twin of the
3D rotate gizmo. Column / elevator / shelf / stair pass their pivot through.

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

* fix(editor): select ceiling only via its corner handles, not the grid body

Add `viaHandle` to NodeEvent and set it on ceiling corner-bracket clicks.
The selection manager now ignores non-handle ceiling clicks without
stopping propagation, so a top-down click on the revealed ceiling grid
falls through to the item hosted beneath it instead of re-selecting the
ceiling and swallowing the click. The corner brackets draw with depthTest
off at a high render order so they stay visible and clickable through
occluding geometry.

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

* feat(editor): highlight wall openings on a selected wall

Add WallOpeningHighlights — an indigo accent frame + translucent pane
drawn around each door / window opening of the selected wall, so editable
children (including frameless openings with no visible geometry) are easy
to locate. The accent is deliberately distinct from the white selection
outline, and draws with depthTest off so it reads on top of the wall.

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

* fix(solar-panel): derive surface frame live so panels re-seat on roof changes

Compute the panel's Y and tilt from the parent roof-segment's finished
(deck + shingle) surface every render via getRoofOuterSurfaceFrameAtPoint
— the same helper skylights use — instead of reading the stored
position[1]/surfaceNormal snapshot. Merging the segment's live overrides
means the panel re-seats and re-tilts continuously during a wall-height /
pitch drag rather than floating or burying until the value commits.

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

* fix(first-person): coerce imported item geometry attrs to Float32 for collider merge

mergeGeometries requires every merged geometry to share the same typed-array
constructor per attribute. Imported item GLBs using KHR_mesh_quantization or
interleaved buffers broke the merge against Float32 wall/slab geometry, so
decode each attribute into a plain non-normalized Float32 BufferAttribute
before cloning into the collider world.

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

* fix(editor): lower camera minimum zoom distance to 6m

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

* chore(mcp): bump @pascal-app/mcp to 0.3.0 in lockfile

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

* refactor(nodes): registry-own move dispatch + hoist shared roof helpers

Addresses the architecture-review findings on the gutter/downspout branch:

- Move dispatch: port the bespoke roof/roof-segment/stair/stair-segment
  movers (MoveRoofTool) and the building mover (MoveBuildingContent) into
  @pascal-app/nodes (shared/move-roof-tool.tsx, building/move-tool.tsx) and
  declare them via `affordanceTools.move`. Delete both hardcoded dispatch
  lists — `LEGACY_MOVABLE_KINDS` in floating-action-menu and the roof/stair/
  building arms of MoveTool. `onMove` is now purely `isRegistryMovable`.
  Editor internals the movers need are exported from @pascal-app/editor
  (adds clearRoofDuplicateMetadata); sfxEmitter.emit -> triggerSFX. elevator
  keeps its existing capabilities.movable path (its legacy arm is the lone
  remaining one, now documented).

- Cross-kind imports: hoist resolveRoofSegmentHit (roof/segment-hit.ts) and
  the roof-surface normal math (getSurfaceY/getAnalyticalNormal/
  surfaceQuatFromNormal, formerly in solar-panel/geometry.ts) into
  packages/nodes/src/shared/, so the 8 roof accessories + skylight/box-vent
  stop reaching into sibling kind folders. roof/index and solar-panel/index
  re-export from shared so public surfaces are unchanged.

- Inspector: make ParametricInspector action `enabledIf` reactive by
  subscribing to its boolean result (ParamActionButton), matching the
  existing FieldRenderer/visibleIf pattern.

Type-checked (tsc) and linted (biome) across editor + nodes. Move behaviour
is preserved by construction but not yet runtime-verified in the editor.

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

* feat(nodes): add cupola + turbine-vent roof accessories; group-rotate gizmo; in-ring rotation readout

New roof-accessory node types and editor gizmo work:

- feat(nodes): cupola (roof lantern) and turbine-vent (whirlybird) node
  types — schema, geometry, parametrics, renderer, panel, move/placement
  tools, floorplan, and tests; registered in the node + core schema indexes
  with site-panel tree nodes.
- feat(editor): group-rotate handle — a single rotation gizmo for 2+ movable
  nodes that spins the selection rigidly about its shared bbox center.
- fix(editor): the live rotation readout (degree wedge + chip) now renders as
  a child of the node frame, concentric and coplanar with the guide ring, so
  it sits centered in the ring on pitched roofs instead of floating off to the
  side. Flat-ground gizmos and the group-rotate readout are unchanged.
- refactor(nodes): rework box-vent / ridge-vent geometry + definitions and
  share roof helpers; tidy roof/box-vent panels.

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

* feat(nodes): eyebrow-vent kind; ridge-vent tracks roof height; vent paint + tree-node go registry-driven

New eyebrow-vent roof accessory plus two follow-on cleanups:

- feat(nodes): eyebrow-vent — a louvered roof vent in three styles (`scoop`
  swept eyebrow, `half-round` D-vent, `slant-box` hooded box). Solid louver
  slabs set into a framed/contained front, double-sided geometry, and
  adjustable style / louver-count / dimensions / slant (`backRatio`). Full
  registry wiring: core schema, node dir (definition/geometry/renderer/tool/
  move-tool/panel/floorplan/preview/tests), event type, palette button.
- fix(nodes): ridge vent derives its Y from the segment's current surface
  (`getSurfaceY`) instead of a stored value, so lowering a roof drops the cap
  onto the new ridge automatically.
- refactor(nodes,editor): migrate the vent family (box / ridge / turbine /
  cupola / eyebrow) to `capabilities.paint` via a shared single-surface
  capability, removing the hardcoded `node.type` paint arms from the editor's
  selection-manager + material-paint (matches chimney/dormer).
- refactor(editor): replace the five identical vent tree-node components with
  one `def.presentation`-driven `RegistryTreeNode`.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 10:57:37 -04:00
f7ff60561e feat(editor): preset placement polish — params, move tool, shadows, isolation (#357)
* feat(editor): preset placement polish — params, move tool, shadows

- wall/slab/ceiling/roof create paths consume `toolDefaults` so template
  presets build with their saved params; cleared on tool unmount
- wall draw preview reflects the preset's height/thickness (+ HUD labels)
- box-select picks up registry-selectable kinds (shelf) via bbox
- registry + column move tools: snap to the active grid step, R/T rotation,
  and ignore the stray trailing click that armed the move (no double-place)
- shelf geometry casts + receives shadows like fence/slab

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

* feat(viewer): expose isIsolationActive() for isolation-aware consumers

Tracks whether an isolation filter is currently applied and exposes it so
hosts can avoid acting on the partial view — e.g. skipping project-thumbnail
autosave while a single subtree is isolated (preset capture).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:17:15 -04:00
1fd59dd9cd feat(editor): preset-system primitives (#340) (#341)
* feat(editor): preset-system primitives — presettable, sceneApi subtree round-trip, isolate + setCaptureMode enum, headless exports

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:49:50 -04:00
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
87384cfbab feat(roof-system): six roof-accessory kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) on the registry model (#330)
* Add roof surface placement support for items

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

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

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

* fixed conflict

* feat(box-vent): port to packages/nodes registry shape

Move box-vent from the legacy scattered layout (core schema +
viewer/systems/renderers + editor/tools/panels/sidebar) into a single
`packages/nodes/src/box-vent/` folder following the Phase 5 Stage E
pattern. The kind now self-registers via the built-in plugin.

- schema lives in `core/schema/nodes/box-vent.ts` (referenced by the
  hand-maintained AnyNode union) and re-exports from the kind folder.
- `def.renderer` reads the parent roof-segment from useScene, applies
  the slope tilt + segment yaw + node rotation stack, and follows the
  segment's useLiveTransforms override during a parent drag.
- geometry builder is pure and shared by renderer / preview / tool /
  unit tests. `computeBoxVentSlopeTilt` is lifted as a helper for
  future reuse by other roof-mounted kinds (skylight / solar-panel).
- placement tool listens to `roof:*` events, snaps to the segment
  under the cursor, creates a new BoxVentNode parented to that
  segment.
- BoxVentEvent + `NodeEvents<'box-vent', BoxVentEvent>` added to the
  event bus so `useNodeEvents(node, 'box-vent')` type-checks.

Verified: workspace `bun run build` + `bun run check-types` pass; 13
new unit tests in `__tests__/{schema,geometry}.test.ts` pass.

Worked example for porting the remaining roof-system kinds (ridge-vent,
chimney, solar-panel, skylight, dormer) — see `.claude/PORT-CHEATSHEET.md`.

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

* feat(ridge-vent): port to packages/nodes registry shape

Same pattern as box-vent (`752ace83`): one folder under
`packages/nodes/src/ridge-vent/`, schema in core, registration via the
built-in plugin. No outside-the-folder edits beyond core schema/types,
the event bus, and the plugin index.

- pure geometry builder shared by renderer / preview / tool / tests,
  covering all three styles (curved cap / shingled / metal) and the
  optional end caps.
- custom `def.renderer` reads the parent roof-segment, follows
  useLiveTransforms during a parent drag. No slope tilt — the ridge
  IS the high line of the segment so the transform stack is one
  level shallower than box-vent.
- placement tool snaps the cursor to the ridge (segment-local Z=0)
  wherever the cursor lands on a segment, then commits on click with
  Z=0 baked into the new node's position.
- RidgeVentEvent + NodeEvents<'ridge-vent', ...> added to the event bus.

Verified: workspace build green, 9 new tests pass alongside the
13 box-vent tests.

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

* feat(chimney): port to packages/nodes registry shape (Option C)

Same shape as box-vent (`752ace83`) and ridge-vent (`10d489d6`).

**Scope — Option C.** Chimney lands in the registry with solid
geometry; the CSG-driven decoration (cap flue holes, body cavity,
panels, bands, and the roof-trim that hides the chimney bottom inside
the deck) is preserved in the schema but NOT rendered yet. These
re-light when roof-segment migrates to Stage B and introduces a
`roofCutout` capability the parent segment can read.

Visual consequence: a placed chimney intersects the roof at the deck
line instead of having a clean CSG-cut hole around it. Placement,
move (via the legacy floating-vent-actions until the affordance tool
is ported), paint, inspector edits, undo, and delete all work
correctly.

- pure builder returns `{ body, cap, flues, cricket }` so each piece
  carries its own material (body/top split matches the schema's
  `material` vs `topMaterial`). Body height derived from the parent
  segment's `wallHeight + (flat ? 0 : roofHeight) + heightAboveRidge`.
- custom `def.renderer` reads the parent segment via `useScene`,
  follows `useLiveTransforms` during a parent drag.
- placement tool listens to `roof:*` events, creates a new
  ChimneyNode parented to the targeted segment with segment-local
  coordinates.
- ChimneyEvent + NodeEvents<'chimney', ChimneyEvent> added to the
  event bus.
- ChimneyMaterialRole helper re-exported from core (used by the
  paint-mode picker — keeps the legacy multi-surface signature).

Verified: workspace build green, 11 new tests pass (36 total across
box-vent / ridge-vent / chimney).

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

* feat(solar-panel): port to packages/nodes registry shape (Option C)

Fourth roof-mounted kind, same shape as box-vent (`752ace83`),
ridge-vent (`10d489d6`), and chimney (`45038713`).

- pure builder generates the rows × columns cell grid as a single
  merged BufferGeometry with two render groups (frame + glass) so
  one mesh can take a `[frameMaterial, panelMaterial]` array.
- analytical roof-surface helpers (`getSurfaceY`, `getAnalyticalNormal`,
  `surfaceQuatFromNormal`) live alongside the geometry builder and
  drive both the renderer (when `surfaceNormal` is absent from the
  node) and the placement preview/commit.
- placement tool stores the analytical surfaceNormal on the new node
  so the runtime renderer and the placement preview produce the same
  orientation.
- `solar-panel-presets.ts` moved into core (it was already imported
  from the schema there) and re-exported through `@pascal-app/core`.
- inspector parametrics cover preset, grid, panel dims, mounting
  (flush/tilted with `tiltAngle` shown only when tilted), standoff,
  and frame.
- SolarPanelEvent + NodeEvents<'solar-panel', ...> on the bus.

**Option C still applies**: panels visually sit on the roof surface
but the roof is NOT cut beneath them; the legacy renderer's
useFrame-driven quaternion smoothing is replaced by a static
quaternion computed once per render. Surface tracking under live
parent rotation comes back when roof-segment migrates to Stage B.

Verified: workspace build green, 16 new tests (52 total across the
four ported kinds).

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

* feat(skylight): port to packages/nodes registry shape (stub)

Fifth roof-mounted kind. Schema is complete, but the geometry and
animation surfaces are intentionally stubbed — this commit lands the
registration so the kind is present in palette / inspector / sidebar
/ undo, and follow-up commits flesh out the type-specific geometry
and the animation system.

**Scope.**
- Schema: every field from the archive ports verbatim (25 fields,
  five `skylightType` variants, opening/sliding state, lantern
  proportions, curb).
- Geometry: frame + glass rendered as plain boxes regardless of
  `skylightType`. Lantern slope, opening swing tilt, and sliding
  panel offset from the archive are not yet rebuilt.
- Animation: `operationState` and `slideFraction` round-trip via the
  inspector but don't drive geometry yet and don't interpolate over
  time. The legacy animation lived in `useInteractive.skylight
  Animations`, which doesn't exist on main — re-introducing that
  surface is a focused follow-up.
- Inherits Option C from chimney: no CSG cutout into the roof; no
  frame CSG (4 box rails instead).

**Why ship the stub now**: the framework wiring (schema in core,
event bus entry, plugin registration, inspector descriptor, custom
renderer with parent-segment lookup, placement tool) is the part
that's reusable across all five `skylightType` variants. Wiring
+ box geometry takes the kind from "doesn't exist" to "place / move
/ paint / delete / undo all work" without committing to the harder
type-specific geometry decisions.

Follow-up commits:
- type-specific geometry (lantern slope, opening tilt, sliding offset)
- animation system + `useInteractive.skylightAnimations` extension

Verified: workspace build green, 7 new tests (59 total across the
five ported kinds).

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

* feat(dormer): port to packages/nodes registry shape (stub)

Sixth and final roof-mounted kind. Schema complete; geometry stubbed
as a house silhouette (box body + triangular gable). Same Option C
inheritance as chimney, solar-panel, and skylight.

**Key call: window is inlined, not a hosted child.** The archive's
dormer carries its window opening as parametric fields on its own
schema (`windowWidth`, `windowColumns`, `windowSill`, etc.) — not a
hosted `WindowNode` child. So `relations.hosts` stays unset and the
kind doesn't need a `children` field. The 17 window-* fields stay in
the schema; geometry beyond the silhouette stub picks them up later.

- per-surface material resolution (`getEffectiveDormerSurfaceMaterial`)
  ports verbatim into core with the same cross-fallback semantics
  (top → material, side ↔ wall, then legacy `material`).
- placement tool follows the established pattern (`roof:*` events,
  segment-local commit, analytical surfaceNormal stored).
- `RoofType` import resolved from the existing `roof-segment` schema
  on main (the archive's `./roof-type` file is consolidated there).
- DormerEvent + NodeEvents<'dormer', DormerEvent> on the bus.

**Stub scope.** Geometry renders gable-only regardless of `roofType`;
no window opening cutout, no window frame, no sill, no roof trim
where the dormer meets the host segment. The archive's geometry
relies on `getDormerExposedFaces` + `generateDormerGeometry` from
the legacy roof-system, neither of which exists in `packages/nodes`.
Follow-up commits add per-roofType dormer roofs, the window
opening+frame+sill, and the trim/CSG against the parent segment.

Verified: workspace build green, 12 new tests pass (71 total across
all six ported kinds; pre-existing spawn parity failures unrelated).

All six roof-system kinds now live in the registry shape.

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

* docs: porting cheat-sheet for the roof-system kind migrations

Reference doc kept alongside the six kind ports (box-vent, ridge-vent,
chimney, solar-panel, skylight, dormer) so future kind authors can
follow the same shape. Captures:

- the per-kind folder layout (13 files, what each one owns)
- the three-checkbox composition model (`geometry` / `renderer` / `system`)
- every `NodeDefinition` field with usage notes
- the wiring touch-points outside the kind folder
  (`packages/nodes/src/index.ts`, `packages/core/src/events/bus.ts`,
  the AnyNode union, the core schema exports)
- per-kind decisions for the six roof-system kinds (which checkboxes
  each one ticks, what gets stubbed, what's deferred)
- pitfalls hit while porting (material-cache leaks, group-transform
  mutation, host-kind children fields, Path 1 vs Path 2 floorplan
  move)
- a pre-PR checklist

Kept under `.claude/` (not `wiki/`) since it's a working note for the
in-flight migration, not authoritative project documentation. Move
into `wiki/architecture/` later if it earns its keep.

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

* feat: wire paint targets + skylight animation surface for new kinds

Two small additions cherry-picked from roof-system-archive that the
six kind ports depend on. Both are mechanical and unblock follow-up
work without changing existing behavior.

**Paint targets.** Add `chimney`, `skylight`, `dormer` to
`MaterialTarget` enum so the paint picker surfaces these kinds. Wire
`chimney` and `dormer` into the relevant material-library target
arrays (WALL_TARGETS, SLAB_TARGETS, WALL_AND_SLAB_TARGETS,
ROOF_TARGETS) so wall / slab / roof material catalog entries are
offered when painting a chimney or dormer. Without this the new
kinds' `material` / `materialPreset` fields can be set
programmatically but the user-facing paint flow has nothing to
target.

**Skylight animation surface.** Port `SkylightInteractiveState` +
`SkylightAnimationState` types, `skylights` / `skylightAnimations`
store fields, and four actions (`setSkylightOpenState`,
`removeSkylightOpenState`, `startSkylightAnimation`,
`cancelSkylightAnimation`) onto `useInteractive`. Mirrors the
existing door / window animation surfaces one-for-one. This is the
prerequisite the skylight stub commit (`6dcee1ee`) called out — the
follow-up commit that adds the skylight animation system component
+ wires `operationState` into the renderer's geometry now has
something to consume.

Neither change touches the six kind folders or their definitions —
the kinds will pick up the new paint targets automatically and the
skylight animation surface is dormant until a consumer ports
forward.

Verified: workspace build green, 71/71 kind tests pass.

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

* feat(palette): surface the six roof accessories in StructureTools

Closes the UX gap from the kind ports: box-vent / ridge-vent /
chimney / solar-panel / skylight / dormer are registered in the
registry with `def.tool` and `presentation`, but the top palette
(`StructureTools`) is currently driven by a hand-coded `tools`
array, not by the registry. So the new kinds existed in the
codebase but had no entry point in the running editor — the user
had no way to add them.

- Extend `StructureTool` union in `use-editor.tsx` with the six new
  kind IDs so `setTool('chimney')` typechecks.
- Add six entries to the `tools` array in `structure-tools.tsx`.
  All use the existing `/icons/roof.png` (a kind-specific icon set
  is a follow-up).

The ToolManager already dispatches `nodeRegistry.get(tool)?.tool`
(`tool-manager.tsx:28`), so clicking a new palette button activates
the kind's registered `def.tool` automatically — no further wiring
needed.

Follow-up: a `parametrics.customPanel` on `roofDefinition` that
surfaces inline "Add Chimney / Skylight / Dormer / ..." buttons in
the roof inspector (matching the legacy `roof-panel.tsx` UX). For
now, top palette is the entry point.

Verified: workspace build green, 71/71 kind tests pass.

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

* feat(roof): add inline "Add Element" section to the roof inspector

When a roof is selected, the inspector now shows six quick-add buttons
(Chimney, Dormer, Skylight, Solar Panel, Box Vent, Ridge Vent) in an
"Add element" section between Position and Actions. Closes the
discoverability gap from the kind ports — the user no longer has to
hunt for the kind in the top palette.

- Lives in `packages/nodes/src/roof/panel.tsx` (the roof's existing
  customPanel — it already escapes the auto-derived inspector to render
  Segments + Position + Actions).
- Each button calls `useEditor.getState().setTool(kind)` to activate
  the kind's registered `def.tool`. The ToolManager dispatches via
  `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so this
  reuses the same code path as clicking the kind in the top palette.
- Tools listen for `roof:*` events — after clicking "Add Chimney" the
  user clicks anywhere on a roof segment to commit the new node
  parented to that segment.

Mirrors the legacy `roof-panel.tsx` UX (which had inline Add buttons
that created hidden nodes + entered move mode); the registry-shaped
equivalent activates the placement tool instead so the user sees a
preview that follows the cursor.

Verified: workspace build green, 71/71 kind tests pass.

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

* ux(palette): remove roof accessories from the top palette

Six kinds (box-vent / ridge-vent / chimney / solar-panel / skylight /
dormer) only make sense in context of a selected roof segment — putting
them in the top palette clutters it for users not actively editing a
roof. They're entered through the roof inspector's "Add element"
section instead (added in 275af8f4), which routes to the same
registry-driven placement tools.

- Remove the six entries from the `tools` array in `structure-tools.tsx`.
- Keep `StructureTool` union additions in `use-editor.tsx` since
  `setTool('chimney')` etc. still need to typecheck from the roof
  panel's `activateTool` callback.

Verified: workspace build green.

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

* fix(placement): always resolve a roof segment on click so add commits

Likely root cause of "Add Element clicks not adding anything." The
six roof-mounted placement tools each had a private
`resolveSegmentFromWorldPoint` that returned null when the click's
segment-local (x, z) fell outside `width/2 × depth/2` — but the
visible merged-roof mesh extends past those bounds by the segment's
overhang. Clicks landing anywhere in the eave band, or beyond every
segment's nominal footprint, silently no-op'd: `onClick` early-returned
on `if (!hit) return` and no node was created.

- Extract a shared `resolveRoofSegmentHit` into
  `packages/nodes/src/roof/segment-hit.ts`.
- Bounds check now includes `seg.overhang` on each side, matching the
  visible roof mesh.
- If no segment passes the exact check, fall back to the FIRST
  segment with the click point projected into its local frame. Same
  policy the legacy `roof-panel.tsx` used (it parented all add
  operations to `segments[0]` and let the user move afterward).
- Rewire box-vent, ridge-vent, chimney, solar-panel, skylight, and
  dormer placement tools to use the shared helper. Drop the per-tool
  copies (and the now-unused `RoofSegmentNode` import in 5 of them).

After this, clicking "Add Chimney" / etc. in the roof inspector
followed by a click anywhere on the visible roof commits the new
node every time.

Verified: workspace build green, 71/71 kind tests pass.

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

* fix(roof): mount accessory children of roof-segments so Add actually adds

Closes "Add Element click adds nothing to the scene." After the click,
the new chimney/skylight/dormer/box-vent/ridge-vent/solar-panel node
was being created in `useScene.nodes` with `parentId: <segmentId>` —
but nothing mounted it visually. Two missing pieces:

1. RoofSegmentNode had no `children` array. `createNodesAction`
   appends `newNode.id` to `parent.children` only when the parent
   declares the field (`node-actions.ts:355`). Without it the
   parent-side write was a no-op, so the accessory existed in the
   store but nothing ever fired its `<NodeRenderer>` mount.

2. Even with the schema field, `roof-segment/renderer.tsx` was a leaf
   `<mesh>` — no recursive `<NodeRenderer>` mount of `node.children`.

Fix:
- `core/src/schema/nodes/roof-segment.ts`: add
  `children: z.array(z.string()).default([])`.
- `nodes/src/roof-segment/renderer.tsx`: emit a `<group>` alongside
  the placeholder mesh that iterates `node.children` and mounts each
  via `<NodeRenderer>`. The group carries the same transform as the
  mesh so accessories inherit the segment's local frame — matching
  the segment-local coordinates each accessory renderer assumes.
- `nodes/src/roof/renderer.tsx`: drop the `visible={false}` segments
  wrapper. `RoofSystem` only fills the parent roof's `merged-roof`
  mesh (`viewer/systems/roof/roof-system.tsx:172` via
  `getObjectByName('merged-roof')`), so segment placeholder meshes
  stay empty and don't z-fight with the visible roof. Mounting
  segments inside a visible wrapper is what lets accessory grand-
  children render at all.

Also unblocks the user's `roof/panel.tsx` accessory-list selectors
(which loop `seg.children` for chimneys/dormers/skylights/etc.) by
giving the schema the field they expect.

Verified: workspace build green, 71/71 kind tests pass.

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

* fix(roof-accessories): drop double-applied segment transform from renderers

After the previous fix (segments host accessories via recursive
NodeRenderer), each accessory was being positioned at *twice* the
segment offset — the renderer's outer group still applied
`segment.position` and `segment.rotation`, and the React parent (the
segment's group) was already at that transform too. Result: chimneys,
skylights, dormers, etc. landed in the scene graph but rendered far
off-screen — invisible from any normal camera view.

Fix the six accessory renderers (box-vent, ridge-vent, chimney,
solar-panel, skylight, dormer) to assume the segment's transform is
inherited from the React tree:

- Drop the outer `<group position={segmentPosition} rotation-y={...}>`
  wrapper.
- Apply `node.position` (segment-local) directly to the ref'd outer
  group, with the kind-specific tilt / quaternion / yaw on inner
  groups.
- Drop `useLiveTransforms` lookup for the segment — React tree
  re-renders propagate parent transform changes automatically.
- Keep the `useScene` segment lookup; it's still needed for kind-
  specific math (slope tilt, analytical surface normal, base Y from
  wallHeight) that reads segment fields beyond just the transform.

Chimney's outer group sits at `[0, 0, 0]` because `applyNodeTransform`
in `geometry.ts` already bakes `node.position` and `node.rotation`
into the chimney's vertex positions (which also bake `baseY` from
`segment.wallHeight`). No double application there either.

After this, Add Element → click → place puts the accessory at the
clicked spot on the roof, visible and selectable.

Verified: workspace build green, 71/71 kind tests pass.

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

* fix(scene): migrate existing roof-segments to add `children: []`

Closes "Add Element click adds to side panel but not to scene graph."
The previous fix added `children: z.array(z.string()).default([])`
to RoofSegmentNode, but that default only applies when zod parses
the segment fresh. Every roof-segment already in a loaded scene
(saved before the schema change) carries no `children` field at
runtime.

`createNodesAction` (`node-actions.ts:355`) appends the new child id
to `parent.children` only when `'children' in parent && Array.isArray(...)`
is true. For un-migrated segments that check fails — the chimney /
skylight / dormer / etc. is added to `useScene.nodes` (so it shows
up in the sidebar tree) but the parent-side write is a no-op, so
the segment's children array remains undefined, the segment
renderer's recursive `<NodeRenderer>` finds nothing to mount, and
nothing appears in the 3D scene.

Mirror the existing shelf migration (`use-scene.ts:351`): when the
scene loads, patch every roof-segment whose `children` isn't an
array to `children: []`. Existing scenes get the field on next
load; new segments get it from the schema default. After this the
"Add Element" click commits visibly.

Verified: workspace build green.

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

* feat(chimney): port archive's bespoke tabbed panel as customPanel

The auto-derived inspector worked but couldn't reproduce the
archive's tabbed UI (Cap / Flues / Shoulder / Bands / Cricket /
Panels) where each sub-section gets its own dedicated controls
laid out as a grid of selectable cards. Users selecting a chimney
got a flat parametric form instead of the dense bespoke editor.

- Drop the legacy `packages/editor/src/components/ui/panels/chimney-panel.tsx`
  into `packages/nodes/src/chimney/panel.tsx` (the kind's customPanel
  slot). Rewrites:
  * Helper imports collapsed to one barrel from `@pascal-app/editor`
    (`ActionButton`, `ActionGroup`, `PanelSection`, `PanelWrapper`,
    `SegmentedControl`, `SliderControl`, `triggerSFX`).
  * `sfxEmitter.emit(...)` → `triggerSFX(...)` (same SFX, registry-
    safe export from `@pascal-app/editor`).
  * Inline a 3-line `cn` helper since editor doesn't re-export the
    legacy `lib/utils` one.
  * `ChimneyPanel` becomes `default export` so customPanel's lazy
    loader can pick it up.
- Wire `chimneyParametrics.customPanel = () => import('./panel')` so
  the registry's parametric inspector defers to the bespoke component.
- Keep `groups` in `chimneyParametrics` for MCP / fallback consumers
  (the parametric data is still authoritative).

User-visible: clicking a chimney now opens the tabbed inspector with
the exact category layout from the archive.

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

* feat(chimney): CSG-trim the body against the roof so it carves cleanly

Closes "chimney is not getting trimmed just like in roof-system."
Previously the chimney mesh was rendered as solid geometry that
intersected the roof shell visually at the deck line — Option C
debt called out in `45038713`. Now the body is CSG-cut against the
parent segment so only the portion above the shingles is visible,
matching the archive's UX.

- `packages/viewer/src/lib/csg-utils.ts`: port `csgEvaluator`,
  `csgGeometry`, `csgMaterials`, `computeGeometryBoundsTree`,
  `prepareBrushForCSG`, and the `Brush` / `SUBTRACTION` re-exports
  from `roof-system-archive`. Lives in viewer because
  `three-bvh-csg` + `three-mesh-bvh` are viewer-only deps.
- `packages/viewer/src/index.ts`: expose the CSG primitives + the
  existing `getRoofSegmentBrushes` (which was already defined on
  main but not in the package surface). Adding `getRoofSegmentBrushes`
  to the export — internal already; this just opens it for kinds
  living in `@pascal-app/nodes`.
- `packages/nodes/src/chimney/roof-trim.ts`: new helper
  `trimChimneyBodyAgainstRoof(body, segment, node)`. Wraps the body
  in a `Brush`, runs a two-pass `SUBTRACTION` (chimney - wallBrush -
  shinSlab), returns the trimmed `BufferGeometry`. Returns the input
  unchanged on any CSG failure so the chimney still renders.
- `packages/nodes/src/chimney/renderer.tsx`: memoize a `trimmedBody`
  alongside the existing geo memo (keyed on the segment shape fields
  that drive the roof brushes) and pass it to the body mesh instead
  of `geo.body`. Disposal updated to release whichever buffer is
  actually live.

Deferred (Option C still): bands and panels CSG. They were the same
flow but operate on additional pieces; they re-light in a follow-up
once the chimney's bands / panels geometry comes back online.

Verified: workspace build green, chimney unit tests pass (14/14).

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

* feat(skylight): port full v1 implementation (panel, 5-type 3D, CSG cut, animations)

Brings the skylight node from a box-only stub up to feature parity with the
roof-system branch. Mirrors the chimney port pattern established in 9fd42e33
and 65eec685.

UI
- packages/nodes/src/skylight/panel.tsx — bespoke tabbed inspector
  (type card picker + per-variant controls: lantern height/scale,
  opening angle/side/motor, sliding direction/track width, curb, frame,
  position, rotation). Wired via parametrics.customPanel.

3D
- packages/nodes/src/skylight/{renderer,geometry,frame-csg}.tsx —
  full 5-variant geometry (flat / walk-on / lantern / opening / sliding)
  with frame ring CSG and type-specific glass (lantern pyramid +
  cylindrical frame bars; opening hinged glass with optional motor
  housing; sliding two-pane on tracks).
- packages/nodes/src/skylight/preview.tsx — uses the real frame-csg
  builder so placement ghost matches the committed mesh.

Placement / move
- packages/nodes/src/skylight/tool.tsx — commits hit.localY so the
  skylight lands on the outer shingle surface, not the bare-rafter
  analytical Y (was sinking into the deck).
- packages/nodes/src/skylight/move-tool.tsx — kind-owned drag wired
  via def.affordanceTools.move. Uses SkylightPreview as the ghost so
  drag and duplicate both show the real frame following the roof
  raycast. Reparents across segments and dirties old+new for CSG
  re-cut.

CSG cutout
- packages/viewer/src/systems/roof/roof-system.tsx — buildSkylightCutBrush
  added; the per-child loop in updateMergedRoofGeometry subtracts every
  skylight from shin/deck/wall in segment-local before the segment
  transform stacks on (matches v1).
- Ported v1's getRoofOuterSurfaceFrameAtPoint helper (raycast against the
  actual outer-shingle module mesh) and made both the cut and the
  renderer read surface point + normal from it — keeps frame and cut
  aligned on every roof type incl. hip 4-faces, gambrel, mansard, dutch.
- mergeVertices on the cut box before computeBoundsTree — without it
  three-bvh-csg silently no-ops on the BoxGeometry after applyQuaternion
  tilts the cut ~90° about the surface normal (hip short faces).
- Renderer wraps content in an outer <group position={segment.position}
  rotation-y={segment.rotation}> so the frame inherits the same segment
  transform that applyTransform bakes into the cut brush (skylight is
  rendered under <group name="roof-elements"> at the roof level, not
  under the segment, so the renderer has to apply it explicitly).
- Skylight dirty propagation in RoofSystem: edits/moves dirty the host
  segment so the parent roof rebuilds.
- packages/viewer/src/index.ts — exposes getRoofOuterSurfaceFrameAtPoint,
  SurfaceFrame, getRoofSegmentBrushes, csg primitives so @pascal-app/nodes
  can compose roof-aware cuts without a layer violation.

Animations
- packages/editor/src/lib/skylight-interaction.ts — verbatim port of v1
  (toggleSkylightOpenState, closeSkylightOpenState, isOperableSkylightType,
  SKYLIGHT_TOGGLE_ANIMATION_MS = 520).
- packages/editor/src/hooks/use-keyboard.ts — R toggles, T closes
  operable skylights, mirroring door/window.
- packages/nodes/src/skylight/system.tsx — SkylightAnimationSystem
  ported as def.system; advances skylightAnimations and writes
  operationState back to useInteractive.skylights.
- Dropped the per-tick markSkylightDirty in the animation system. The
  renderer subscribes to useInteractive directly, so the glass swings/
  slides via Zustand re-renders without dirtying the scene — the cut
  geometry doesn't depend on operationState, so re-CSG'ing the merged
  roof on every animation frame was pure waste (caused visible lag).
- packages/core/src/index.ts — exports SkylightInteractiveState and
  SkylightAnimationState (interaction lib uses them).

Drag / duplicate ghost
- Floating action menu's setMovingNode → MoveTool → registry affordance
  now resolves to the kind-owned move tool. Duplicate already worked
  through structuredClone + def.schema.parse + setMovingNode; the new
  move-tool provides the ghost both flows use.

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

* Persist roof segment materials across refresh

* feat(solar-panel): full port from archive + placement/orientation fixes

Solar panel — ported from roof-system archive to registry shape:

**Inspector & presets**
- Custom panel (panel.tsx) with preset card grid (Residential / Residential
  Large / Compact / Frameless); picking a preset now writes all four dim
  fields (panelWidth / panelHeight / frameThickness / frameDepth) so the
  inspector immediately reflects the selection.
- Auto-fit to roof, Flip orientation, Custom label when no preset matches.
- Live preview: renderer subscribes to useLiveNodeOverrides so slider drags
  update the mesh before the value is committed to the Zustand store.
- Registered via parametrics.customPanel (same pattern as chimney).

**Texture / materials**
- Procedural cell texture (createSolarPanelTexture): chamfered cell shape,
  dark blue gradient, finger-line and busbar detail drawn on a 256×256
  canvas, tiled per cell via stretched UVs in buildSolarPanelGeometry.
- getDefaultPanelMaterial singleton uses MeshStandardNodeMaterial (WebGPU-
  native) so the material integrates with the MRT pass without triggering
  "Color target has no corresponding fragment stage output / writeMask not
  zero" GPU validation errors on segment reparent.
- defaultFrameMaterial and move-tool previewMaterial also switched to
  WebGPU-safe types (MeshStandardNodeMaterial / MeshBasicMaterial).

**Default grid size**
- Schema defaults changed from 4 rows × 5 columns → 2 rows × 3 columns.
- Placement ghost and move-tool ghost use a compact 2×3 footprint;
  committed panels also default to 2×3.

**Placement tool**
- Commit position uses raycast hit Y (hit.localY from segObj.worldToLocal)
  instead of analytical getSurfaceY so the panel lands exactly where the
  ghost was shown rather than sinking into the deck/shingle layers.
- Ghost orientation uses the same analytical-normal + explicit-yaw pattern
  as the placement tool for correctness on rotated segments.

**Move tool**
- Rewrote ghost to use resolveRoofSegmentHit + getAnalyticalNormal
  (segment-local) + explicit rotation-y group, matching the placement
  tool's ghost layout exactly. Dropped unreliable event.normal / world-
  space quat path that gave wrong tilt on any segment with rotation ≠ 0.
- Committed surfaceNormal is now segment-local (not world-space) so the
  renderer's surfaceQuat + outer segment.rotation group compose correctly
  without double-rotating the panel.
- Uses shared resolveRoofSegmentHit (with surface-Y disambiguation) instead
  of the private copy, so segment hopping respects the correct face.
- Reparents children arrays on segment hop.

**Renderer**
- Applies segment.position + segment.rotation explicitly (roof accessories
  are mounted under roof-elements group which has no transform, not under
  segment subtree).
- Merges useLiveNodeOverrides so slider drags update the 3D mesh in real
  time (same pattern as elevator/skylight renderers).

**Scene graph**
- SolarPanelTreeNode added; registered in tree-node.tsx type map so panels
  appear under their parent roof-segment in the sidebar.

**Segment-hit disambiguation**
- resolveRoofSegmentHit now scores all bbox-passing candidates by
  |localY − analyticalSurfaceY(localX, localZ)| and picks the smallest,
  fixing the long-standing bug where hip/gable segments at the same roof
  origin all pass the axis-aligned bbox test and the first-match (always
  segments[0]) was returned regardless of which slope was clicked.
  Benefits all roof-accessory placement tools (chimney, box-vent, skylight,
  dormer, solar-panel).

**Hip-roof normal fix**
- getAnalyticalNormal for hip now uses slopeReach = min(w,d)/2 for the Y
  component on all four faces. The old code used depth/2 for front/back and
  width/2 for sides, which was only correct for square (w==d) hips; for any
  other aspect ratio the long-axis faces tilted the panel at the wrong angle.

**Dormer, dormer move-tool, window-frame, ridge-vent, box-vent, skylight**
- Assorted in-progress work: dormer window-frame geometry, move-tool port,
  panel refinements, ridge-vent / box-vent panel additions, skylight CSG
  frame refinements, roof-system geometry improvements, material-paint
  support, post-processing cleanup.

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

* perf(chimney): fix hook order + share materials + memoise segment brushes

- renderer.tsx: hoist `surfaceArray` useMemo above the `!segment || !geo`
  early return so hook call order stays stable across renders (the
  previous order would have crashed React the first time segment or geo
  flipped to null mid-session).
- renderer.tsx: replace the module-scoped `bodyMaterial` / `topMaterial`
  singletons with per-instance fallback materials so a paint-mode or
  debug mutation on one chimney can't bleed into every other unpainted
  chimney on the scene; dispose them on unmount.
- renderer.tsx: collapse the 36-field hand-maintained dep array on the
  `geo` useMemo (and the 10-field one on `trimmedBody`) down to the
  memoised `node` / `segment` references — adding a new schema field
  no longer risks stale geometry from a forgotten dep, and the
  `eslint-disable react-hooks/exhaustive-deps` lines are gone.
- renderer.tsx + roof-trim.ts: memoise `getRoofSegmentBrushes(segment)`
  per-segment-shape in the renderer instead of rebuilding the four
  CSG-ready brushes inside `trimChimneyBodyAgainstRoof` on every call.
  A chimney slider drag changes `node.*` but not the segment, so the
  brushes now survive the entire drag instead of being rebuilt and
  disposed every frame.

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

* test(scene): pin reparent behavior for roof-mounted kinds under repeated A→B→A→B

Investigating a reported crash where moving a vent across roof
segments three times in a row crashes the scene. The hypothesis was
duplicate IDs in the host segments' `children` arrays. These tests
prove that's NOT the cause: at the store level, the auto-reparent
inside `updateNodesAction` leaves children lists clean under repeated
hopping for every roof-mounted kind (box-vent, chimney, skylight,
dormer, solar-panel, ridge-vent), and even the redundant manual-then-
auto pattern the vent move-tools use converges to the same correct
state.

Crash root cause still under investigation, but these pins prevent
the obvious-and-tempting regression where someone "fixes" reparent
by hand and accidentally lets duplicates through.

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

* refactor(chimney): give each sub-mesh its own name

Body, cap, flues, cricket, and bands were all named `chimney-surface`,
so hover/selection couldn't distinguish them and panel breadcrumbs
couldn't say "Chimney cap" vs "Chimney body". Rename to
`chimney-{body,cap,flues,cricket,bands}`. No code looked up the old
literal, so this is a pure naming improvement.

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

* feat(chimney): smooth round shading, radial cap UVs, cap reveal, flue pots

Four visual upgrades to the chimney builder, all in `geometry.ts`:

- **Round chimneys now render smooth, not faceted.** The previous
  `pushCylinderFaces` emitted unindexed triangles, so
  `computeVertexNormals()` baked per-triangle face normals into every
  vertex — the 24 polygon segments of a round body / cap / band were
  visible as flats. Round paths now build per-tier
  `THREE.CylinderGeometry` (indexed, side vertices shared across
  radial segments) and merge via `mergeGeometries`. Crisp rim edges
  are preserved because CylinderGeometry uses separate cap vertices.

- **Radial cap UVs.** Old `pushCylinderFaces` pushed `(0,0)` for every
  vertex on the top/bottom fan, so any texture on a round chimney
  smeared to a point at the caps. CylinderGeometry gives proper
  radial UVs (0.5 ± 0.5·cos/sin) for free.

- **Cap reveal.** The cap used to sit flush on the body, reading as
  glued on. New `CAP_REVEAL = 0.003` (3 mm) air gap above the body
  catches a shadow line and sells the cap as a separate stone / metal
  piece. `capTopY` (used for flue placement) updates so flues still
  sit on the actual cap top.

- **Flue pots, not drainpipes.** Each flue was a single straight
  cylinder / box — visually a "drainpipe", not a chimney. New
  two-tier silhouette: a tall straight shaft topped by a short
  overhanging rim (12 % of height, capped at 4 cm; rim radius
  flares 12 %). Reads as a terracotta pot. Total height still
  equals `flueHeight`, so the bore cutter in `holes.ts` covers the
  whole envelope unchanged.

Removed the now-unused `pushCylinderFaces` helper. Slab path
unchanged — square chimneys keep their crisp 90° corners.

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

* feat(chimney): cornerBevel for square bodies / caps / bands

Square chimneys read as plastic boxes at any distance because every
vertical edge is a perfect 90° corner that catches no light. New
`cornerBevel` field (default 0 → existing scenes unchanged) replaces
each corner with a 45° chamfer face. Real masonry chimneys often
ship the same detail — a small bevel (~1-2 cm) breaks up the
silhouette and reads as stone or chamfered brick.

- Schema: add `cornerBevel: z.number().default(0)` to ChimneyNode.
- Geometry: extend `pushSlabFaces` with an optional `bevel` param.
  When > 0, dispatch to a new `pushOctagonalSlabFaces` that emits
  an 8-vertex ring per y-level (axis-aligned faces + 45° chamfer
  faces) plus fan-triangulated octagonal caps. UVs follow the same
  physical-meter convention as the unchamfered path so a brick
  texture tiles at a consistent rate with and without bevel.
- Thread `node.cornerBevel` through `buildBodyGeometry`,
  `buildCapGeometry`, and `buildBandsGeometry` (square paths only —
  round bodies have no corners to bevel).
- Parametrics: expose under the Body group with `visibleIf` gating
  on square body for the MCP / fallback inspector.
- Panel: add a "Corner Bevel" SliderControl in the Footprint section,
  same conditional visibility, clamped at `min(width, depth) / 2`.

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

* feat(chimney): style presets — Brick / Stone / Modern / Round

The chimney panel exposes 30+ sliders; landing on a coherent
silhouette (corbeled stone with a sloped cap and a cricket vs. a
straight brick with a double band) takes a dozen edits even when
you know what you want. New "Style" segmented control at the top
of the panel applies a curated bundle of fields in one click.

Presets only touch shape / silhouette / accessory fields:
`bodyShape`, `shoulderStyle*`, `cap*`, `band*`, `cricket*`,
`cornerBevel`, `panel*`, `flue*`. Dimensions (`width`, `depth`,
`heightAboveRidge`), placement (`position`, `rotation`,
`roofSegmentId`), and paint (`material*`, `topMaterial*`) are
deliberately left alone — applying a preset to an already-sized,
already-painted chimney resizes nothing and doesn't overwrite the
user's material choices.

- `presets.ts`: four preset bundles + `detectActiveChimneyPreset`
  helper for highlighting the matching preset in the segmented
  control.
- `panel.tsx`: new "Style" PanelSection above Footprint, segmented
  control wired to `commitProp(chimneyPresets[key])`. Renders with
  no segment highlighted ("custom") when the current node doesn't
  match any preset exactly.
- `__tests__/presets.test.ts`: round-trip each preset, confirm
  fresh-default chimneys are NOT detected as any preset, and
  confirm non-preset fields (dims / materials / placement) don't
  knock a chimney out of a preset.

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

* refactor(chimney): drop the Stone preset

Leaves Brick / Modern / Round. The parameterised round-trip test
auto-adjusts via `CHIMNEY_PRESET_KEYS`; no test code change needed.

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

* feat(dormer): bugs, perf, UX, refactor + bundled pitch WIP

Dormer review-backlog batch across five passes. Plus the previously
unstaged pitch/roofHeight migration on RoofSegmentNode bundled in per
session continuity.

Dormer — bugs / dead code:
- implement the windowSill (toggle was UI-only before)
- ghost preview reads wallSkirtHeight and branches on roofType=flat
- live-override slider drag swaps the heavy CSG for the fallback
- consolidate arch/rounded shape builders between viewer CSG and frame
- drop unused surfaceNormal field
- collapse getEffectiveDormerSurfaceMaterial fall-through
- confine the panel's updateWorldMatrix into a single useMemo
- preserve position Y on panel commits (was being zeroed)

Dormer — schema hygiene:
- DORMER_DEFAULTS named constants replace inline magic numbers
- collapse windowCornerRadius + windowRadiusMode + windowCornerRadii
  into the tuple alone; "All vs Individual" is derived UI state
- drop the `as never` id casts; rely on objectId default factory

Dormer — tactile UX:
- R / Shift+R rotates the placement ghost by ±15°
- auto-number new dormer names ("Dormer N", smallest free integer)
- DORMER_PLACEMENT_SNAP_M + ROTATION_STEP constants extracted

Dormer — code shape:
- new use-dormer-placement hook dedupes tool + move-tool (~90% shared)
- new <DormerWindowAssembly> isolates the frame/glass/sill JSX
- panel.tsx 788 -> 295 lines; Position / Window / Actions sections
  extracted into per-file components

Bundled pitch WIP (pre-existing, unrelated to dormer):
- RoofSegmentNode.roofHeight removed; pitch (degrees) added
- new helpers in roof-segment: getActiveRoofHeight,
  getPitchFromActiveRoofHeight, getSegmentSlopeFrame,
  ROOF_SHAPE_DEFAULTS
- migration in use-scene.ts converts legacy roofHeight to pitch
- consumers updated: chimney, box-vent, ridge-vent, solar-panel,
  roof-segment, roof, segment-hit, roof-tool, mcp construction-tools

Verification: 12/12 dormer tests pass; targeted tsc on dormer files
clean. Workspace bun build of nodes is also affected by the pitch WIP,
which is included here per request.

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

* feat(roof): per-segment top/edge/wall materials + restore legacy UV flow

- RoofSegmentNode gains optional topMaterial / edgeMaterial / wallMaterial
  fields mirroring the parent roof. getEffectiveSegmentSurfaceMaterial
  resolves through segment-role → segment-legacy → parent fallback.
- Segment renderer builds the 4-slot array per role with the parent's
  array as a fallback so paint at any level reaches the right surface.
- Painting a segment directly (segment edit mode hover) writes to the
  segment's role fields via buildRoofSegmentSurfaceMaterialPatch — the
  parent roof and other segments are untouched.
- Segments with any material override render as their own per-segment
  mesh inside a new always-visible 'painted-segments' group; the merged-
  roof CSG skips them (hasSegmentMaterialOverride) so we don't double-
  paint with the roof's default array.
- Paint preview now dispatches to a segment-aware path
  (applyRoofSegmentPaintPreview) so hover effects land on the visible
  per-segment mesh instead of the hidden merged-roof.
- Drop the createRoofUvGeometry post-CSG re-projection. UVs now flow
  through CSG (csgEvaluator.attributes includes 'uv') exactly as in the
  legacy roof-system branch.
- Drop the stray [skylight-cut] console.log left in the hot path.

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

* refactor(roof-system): registry capabilities for roof accessories, paint, and keyboard

Replace kind-name branches in framework code with registry-driven dispatch.
Three new NodeDefinition slots back the migration:

- capabilities.roofAccessory — host roof cascade + optional CSG cut.
  Lets viewer's RoofSystem iterate dirty children and call buildCut on
  any kind that declares it, instead of switching on node.type. Dormer
  + skylight cut builders moved into packages/nodes/<kind>/.
- capabilities.paint — resolveRole / buildPatch / applyPreview /
  getEffectiveMaterial. Chimney, dormer, and wall now route through it;
  per-kind arms deleted from selection-manager + material-paint.
- keyboardActions — R / T handlers contributed by the kind. Skylight's
  open/close logic moved from editor/lib to nodes/skylight/interaction.

Dormer + skylight kind code (geometry, fallback shape, exposed-face math,
window-dim resolver, CSG cut builders) now lives under packages/nodes/
src/<kind>/ instead of packages/viewer/src/systems/roof/roof-system.tsx.
The viewer keeps only roof-generic primitives (roof-segment brushes,
surface-frame query, CSG dummy mats, material-slot remap).

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

* review: remove PORT-CHEATSHEET, document roofAccessory/paint/keyboardActions, fix double registry lookup

- Remove .claude/PORT-CHEATSHEET.md (AI authoring aid, not for public repo)
- Document three new NodeDefinition capabilities in wiki/architecture/node-definitions.md:
  roofAccessory, paint, keyboardActions
- Fix double nodeRegistry.get() lookup in use-keyboard.ts: replace !.keyboardActions!.r!.run()
  with ?.keyboardActions?.r?.run() for both R and T arms

* fix(item-placement): memoize preview/dimension callbacks to stop placement loop

updatePreviewGeometry and updateDimensionGuides were declared as plain
functions in the component body, so they got a fresh identity every
render. Both sit in the placement setup effect's dependency array, which
made React tear the effect down and re-run it on every commit — its
teardown deletes the draft node while the setup re-creates it, producing
an infinite delete/recreate loop ("Maximum update depth exceeded") when
opening furnish mode.

Wrap both in useCallback with empty deps (they only close over stable
refs, module-level helpers, and the setDimensionBounds setter).

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

* fix(item-placement): correct preview box dimensions and floor rotation

Two issues surfaced after the placement-loop memoization removed the
accidental every-render recompute that was masking them:

1. Preview box used stale (asset-default) dimensions at draft creation
   because nothing recomputed it once the imperative draft was made.
   Recompute the box from the freshly-created draft in `ensureDraft` and
   the chained next-draft path in `onGridClick`.

2. The green/red box (and the live transform the 2D floorplan mirrors)
   ignored the floor item's rotation:
   - `floorStrategy.move` returned a hardcoded `cursorRotationY: 0`; now
     returns the draft's rotation (`rotY`).
   - `onGridMove` never applied `result.cursorRotationY` to the cursor
     group; now it does, so box + floorplan track the draft on every move.
   - the init seed used the mesh world quaternion, which double-counts
     building rotation for floor items; floor now seeds from the node's
     local Y rotation (wall/ceiling keep the world-quaternion path).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: open-pascal <open@pascal.app>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-05-22 12:14:22 -04:00
sudhirandopen-pascal 8b73d7986b feat: expand material library with new wood and flooring textures while cleaning up legacy assets 2026-05-20 00:52:08 +00:00
sudhirandopen-pascal a9d26239b7 Refactor material catalog for roofing and flooring 2026-05-20 00:52:01 +00:00
Wassim SAMADandClaude Opus 4.7 924567293a shelf: v2 — cubby default, withBottom, item hosting, paintable surface
Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle
and a `children: ItemNode[]` field for item hosting. Schema-level defaults
preserve the v1 wall-shelf visual so existing scenes load unchanged; the
placement tool spreads `shelfDefinition.defaults()` for fresh shelves
(cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on).

Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share
the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per
row, plus the bottom-board top when `withBottom` is on for cubby /
bookshelf.

Material is a single paintable surface (same shape walls / slabs / stairs
use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so
unpainted shelves read as the canonical off-white.

Preview clones each cached material before mutating `transparent / opacity`
on the ghost — without the clone the mutation leaked into the cached
`getShelfMaterial` instance every committed shelf was using, rendering
them all see-through after the first placement preview rendered.

Store hardening: `migrateNodes` patches missing `children: []` on v1
shelves, and `updateNodesAction` reparenting tolerates a missing children
array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint
mode picks up the kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:11:13 -04:00
Wassim SAMADandClaude Opus 4.7 36c48b7fc9 chore: biome auto-format pass (resolve persistent dirty-tree noise)
These files had been showing as modified in every dev session — biome's
canonical formatting (line-length collapses, import sort, type-modifier
placement) didn't match the committed state. No semantic changes.
Committing now so the working tree stays clean across sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:19:15 -04:00
Sudhir YadavandGitHub aa6885e4c4 Merge branch 'main' into feat/elevator-system 2026-05-13 22:29:09 +05:30
Wassim SAMAD 9de7a8ee77 perf improvements 2026-05-13 09:05:09 -04:00
sudhir b782ce9cf2 Merge branch 'main' into feat/elevator-system
# Conflicts:
#	packages/editor/src/components/tools/item/move-tool.tsx
#	packages/editor/src/components/tools/tool-manager.tsx
#	packages/editor/src/components/ui/panels/panel-manager.tsx
#	packages/editor/src/store/use-editor.tsx
#	packages/viewer/src/components/renderers/site/site-renderer.tsx
#	packages/viewer/src/components/viewer/ground-occluder.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/components/viewer/post-processing.tsx
2026-05-13 01:38:55 +05:30
sudhir 564bfa229e Merge remote-tracking branch 'origin/main' into fix/fri-8-may
# Conflicts:
#	packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
#	packages/editor/src/components/tools/select/box-select-tool.tsx
#	packages/editor/src/components/tools/wall/move-wall-tool.tsx
#	packages/editor/src/components/ui/panels/column-panel.tsx
#	packages/editor/src/components/ui/panels/door-panel.tsx
#	packages/editor/src/components/ui/panels/window-panel.tsx
#	packages/viewer/src/components/renderers/column/column-renderer.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/systems/slab/slab-system.tsx
2026-05-12 00:27:06 +05:30
sudhir be41511474 Fix wall move junction ownership 2026-05-11 23:46:57 +05:30
Wawa e618175bba Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
2026-05-09 20:52:26 +00:00
sudhir 2480be91b1 Optimize elevator editing performance with live previews 2026-05-09 19:09:48 +05:30
sudhir 23eec44714 Move render systems out of core 2026-05-01 15:07:47 +05:30
aa43bb6a43 feat: backport monorepo PRs #297, #302, #306 — floorplan thumbnails + mobile editor UI
Port floorplan item thumbnails, full mobile editor UI, and
mobile polish from the private monorepo into the public editor.

Monorepo PR #297 (feat/item-admin-advanced):
- Add optional floorPlanUrl to item asset schema (core)
- Render 2D floor-plan images inside item footprints on floorplan
- Improve slider drag with modifier key re-anchoring

Monorepo PR #302 (feat/mobile-ui):
- Mobile editor layout with draggable bottom sheet
- Mobile tab bar, selection bar, and panel sheet
- Mobile-aware panel manager and panel wrapper
- useIsMobile rewrite (useSyncExternalStore, SSR-safe)
- Camera actions hideOrbit prop
- GridSnapControl and SecondaryToggles exports
- Action menu hides on mobile contextual tabs
- SidebarTab extended with mobileDefaultSnap/mobileIcon

Monorepo PR #306 (feat/mobile-ui-polish):
- Touch gesture mapping for camera controls (one/two/three finger)
- Snap ratio constants for bottom sheet
- Thumbnail generator WebGL2 fallback (bottom-up row flip)
- ErrorBoundary scope logging, viewer scene wrap
- GPUDeviceWatcher enhanced logging and uncaptured error handler
- WebGPURenderer init error handling and diagnostics
- Post-processing log improvements
- MergedOutlineNode WebGL2 FBO corruption fix

Excluded: apps/community/*, apps/editor/*, packages/community-*,
.cursor/*, .env.example (monorepo-only files)

Co-authored-by: Pascal <open@pascal.app>
2026-04-28 06:36:08 -07:00
Huy HoangandGitHub 3d1005847b fix: prevent crash when duplicating elements (#239)
- Guard _buildCache in merged-outline-node against stale/disposed
  Object3D refs that cause TypeError on .id access during render
- Reset children array when duplicating roofs to prevent inconsistent
  parent-child relationships (matching existing stair behavior)
- Use obj?.parent check in EditorOutlinerSync to ensure objects are
  still in the scene graph before adding to outliner arrays
- Resume temporal state on parse failure to prevent undo/redo freeze

Closes #232
2026-04-15 17:25:03 -04:00
Sudhir YadavandGitHub 57df224948 feat: Add catalog-based material presets across structural nodes (#231)
* Add catalog-based material presets across structural nodes

* Fix material picker fallback and wall visible highlights

* Preserve wall materials in selection highlights
2026-04-15 12:16:35 -04:00
PascalandGitHub 0a46a9deb4 sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)
Squash merge of 3 commits:
1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes
2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels)
3. Missing files fix (materials.ts, merged-outline-node.ts, type fix)

75 files changed, ~6K additions.
2026-04-07 19:21:10 -04:00
PascalandGitHub e8ad92592d fix(viewer): suppress THREE.Clock deprecation warning from R3F 9.x (#215)
three.js r183 deprecated THREE.Clock, but @react-three/fiber 9.x still
uses it internally per <Canvas> mount. This floods the dev console with
noise on every page load and HMR reload.

Uses three's setConsoleFunction hook to surgically suppress only the
exact Clock deprecation message. All other three.js logs pass through
untouched, including TSL stack-trace warnings.

Safe to remove once R3F v10 stable ships (which replaces Clock with
Timer). Removal condition documented in file header.

Closes #213
2026-04-07 10:17:00 -04:00
PascalandGitHub 0bd0cab533 feat: stair system, scene graph utilities, read-only mode, viewer state improvements (#210)
Stair system (full stack):
- New StairNode + StairSegmentNode schemas with flights, landings, L/U-shapes
- StairSystem: geometry generation with throttled per-frame updates
- Stair tool, edit system, panels, tree node, and renderers
- Event bus types, scene registry, and command palette entries

Scene graph utilities:
- cloneLevelSubtree: deep-clone a level with remapped IDs
- forkSceneGraph: clone + strip scan/guide nodes for project forking

Core improvements:
- Read-only mode on scene store (blocks create/update/delete when locked)
- readOnly guards on node-actions and collection actions
- Upload store for scan/guide file upload handling

Viewer state:
- previewSelectedIds for box-select live preview
- hoverHighlightMode (default/delete) for delete-mode hover outline
2026-04-05 01:20:43 -04:00
Developer 0be6d84c98 refactor: 完善材质系统相关代码
- 修复 node-actions.ts 中 forEach 返回值 lint 警告
- 更新材质 schema 定义和渲染器
- wall-cutout.tsx 材质颜色保留修复
2026-03-30 20:29:29 +08:00
Developer 12b6292623 feat: add material system for all node types
- Add MaterialSchema with 10 presets (white, brick, concrete, wood, glass, metal, plaster, tile, marble, custom)
- Add material field to Wall, Slab, Door, Window, Ceiling, Roof, RoofSegment nodes
- Create MaterialPicker UI component with preset selection and custom properties
- Update all renderers to support material rendering with caching
- Add Material section to all node panels (WallPanel, SlabPanel, DoorPanel, WindowPanel, CeilingPanel, RoofPanel, RoofSegmentPanel)
- Update AGENTS.md with Material System documentation
2026-03-30 12:15:16 +08:00
f06f2fa1b1 feat: 2D editing — floorplan panel, measurements, command palette (v0.3.0)
* sync: port 2D editing features from monorepo (v0.3.0)

## New Features

### Floorplan Panel (7.5K LOC)
- Full 2D editing interface with wall drawing, measurement, and unit display
- Interactive floorplan view with pan/zoom, grid snapping
- Wall thickness visualization, door/window placement
- Zone and slab polygon editing
- Guide image overlay support
- Metric/imperial unit toggle

### Command Palette Overhaul
- Complete rewrite with new command registry system
- Keyboard shortcuts UI with ShortcutToken component
- Editor-specific commands (floorplan, measurements, camera focus)
- Improved search and action organization

### Wall Measurements
- Real-time wall length labels in 3D view
- Metric/imperial conversion
- Wall measurement UI component

### Enhanced Tools
- Wall drafting utilities (grid snapping, validation)
- Node action menu for quick operations
- Improved polygon editing for zones/slabs/sites
- Roof segment panel for granular roof control

## Package Changes

### @pascal-app/core@0.3.0
- New wall-footprint.ts: 2D wall footprint calculation
- Wall mitering exports for floorplan view
- camera-controls:focus event
- Space detection undo pause/resume
- Mark sibling nodes dirty on deletion (miter recalc)

### @pascal-app/viewer@0.3.0
- ErrorBoundary component for robust item rendering
- Broken item fallback UI
- Wall renderer: mark dirty on mount
- Ground occluder: only lowest level punches through
- Unit state (metric/imperial) in viewer store

### @pascal-app/editor@0.1.0
- 48 file changes (16 new, 32 modified)
- New stores: useCommandRegistry, usePaletteViewRegistry
- Tree node drag-and-drop system
- Level selection utilities
- Enhanced scene graph operations

## Apps/Editor
- GeistPixelSquare font for pixel-perfect UI
- Blueprint icon asset
- Updated layout and globals for font support

## Security
- Zero AI imports or internal package refs
- All monorepo-specific code excluded
- Clean audit: no API keys or secrets

## Testing
- Security audit passed 
- All AI/internal code excluded 
- Version bumps applied 

79 files modified, 16 files added
~2600 insertions, ~1400 deletions

* fix: lint cleanup — suppress intentional dep warnings, fix missing dep, remove stale ignores

- Add biome-ignore for 3 intentional useEffect reset patterns (levelId, selectedGuide, selectedId)
- Fix actual missing dependency: currentBuildingId in handleSiteEditShortcutSelect callback
- Remove 3 stale biome-ignore comments in r3f.d.ts (rule not active)

Build and lint pass clean.

* fix: type errors — polygon area guards, selection cast, readonly keywords, door guard

- ceiling-panel, slab-panel, ceiling-tree-node, slab-tree-node, zone-tree-node:
  guard polygon[i]/polygon[j] array access before arithmetic (TS2532)
- scene.ts: introduce toViewerSelection() helper to cast persisted string IDs
  to branded template literal types expected by useViewer.setSelection (TS2345)
- door-panel: guard early return when node is undefined in setSegmentHeightRatio (TS18048)
- editor-commands: remove 'as const' from inline command object, keywords is mutable string[] (TS2322)

All type checks pass. Build and lint clean.

---------

Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
2026-03-24 19:48:11 +00:00
wass08 f2501be2bf remove community 2026-03-13 09:07:46 +01:00
wass08 6719f34a89 clean zone handling code 2026-03-11 07:47:10 +01:00
wass08 927ecba054 schema and interactive store 2026-03-06 13:06:33 +01:00
wass08 0d78474617 packages 2026-02-05 08:22:19 +09:00
wass08 e776ce9849 cdn and packages building 2026-02-04 09:53:44 +09:00