Commit Graph
3 Commits
Author SHA1 Message Date
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
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