When the 2D panel is hidden, align-north publishes a single '2d' pose that
the camera applies through the echo-suppressed pending-pose path, so no
'3d' poses ever reach the compass subscription and the needle stayed
frozen. Drive the needle with a local rAF exponential decay (same 90ms
time constant as the 2D view animation and the camera's effective
smoothTime); a live '3d' pose cancels it and takes back ownership.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two readiness fixes surfaced by the first successful headless bake of a
heavy scene (which exported with 33 items missing):
- The 180-FRAME give-up cap silently assumed display-rate frames. The
bake page's timer-driven loop (?disable=draw) runs those frames in
~3.6s — shorter than a cold item-model download — so the cap fired
and the export raced ahead of real content. New `sceneReadyMaxWaitMs`
Viewer prop replaces the frame cap with a wall-clock one on hosts
whose frame cadence is decoupled from time; default frame behavior
unchanged for interactive surfaces.
- hasPendingSceneBuildWork now skips UNREACHABLE dirty nodes: orphans
whose parent doesn't list them in `children` (or parentless non-roots)
never render — every renderer enumerates the parent's children array —
so no system ever builds them or clears their mark. Observed in prod
scene data (two dangling windows parented to a level that doesn't list
them): they held every bake of that scene to the full readiness cap.
Validated: the 200+-item office scene with ?disable=draw settles
organically in ~2s and exports the complete artifact; a one-shot 504'd
item is now WAITED for through its retry instead of racing the cap.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With no real frames submitted, Chromium's no-damage scheduler throttles
requestAnimationFrame to 1Hz on Linux — measured in the headless bake
worker: every useFrame system ticked once per second, so a heavy scene
needed 300+ seconds of wall time just to run ~300 frames of build work
(and exceeded the capture deadline on the worker's slower cores). The
empty-scene clear from #482 avoids this on macOS but not on Linux.
When `draw` is disabled, drive advance() with setInterval instead of
rAF — plain timers are never throttled on a visible page, so the loop
pacing is deterministic (fps prop) regardless of compositor heuristics.
Normal rendering paths are untouched.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The headless bake worker renders on SwiftShader (CPU), where per-frame
vertex/draw cost dominates the whole capture — but a bake only reads the
built scene graph; the pixels are thrown away. `?disable=draw` (same
diagnostic-param family as postFx) renders an EMPTY scene each frame
instead of the real one: useFrame systems and scene-ready keep ticking,
per-frame cost collapses to a 64x64 clear.
Rendering nothing at all does NOT work: with zero submitted frames
Chromium's no-damage scheduler throttles rAF to exactly 1Hz (measured),
starving the very systems the bake needs — hence the empty-scene draw.
Measured on the office scene (200+ items) with a real GPU: capture
45.0s -> 8.4s (settle 37.6s -> 4.5s); output equivalent to a normal
render (structural GLB diff clean; the ±13-accessor variance exists
between normal runs too). CPU-only (worker) numbers via the prod-image
container against a preview deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): group R/T — keyboard rotate for multi-selections
Extract the rigid rotate/translate math from the group gizmos into
group-transform-shared (rotateGroupPatches / translateGroupPatches) and
add a keyboard group-rotate path: R/T on a multi-selection steps the
whole group ±45° around its bbox center, welded junctions and
connected-component expansion included, committed as one updateNodes
batch (one undo step). Single-selection R/T arms (reference, door/window
flip, registry keyboardActions) are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): polygon participant kind — slabs/ceilings/zones join group transforms
classifyParticipant learns a 'polygon' kind ([x,z] vertex arrays + optional
hole rings): slab/ceiling/zone now ride along in group move and rotate —
the two 3D gizmos and the keyboard group R/T all flow through the shared
rotateGroupPatches/translateGroupPatches, so the wiring is the classifier
plus the rotate gizmo's spread sampling. Zone's 3D renderer merges its live
polygon override so it previews during the drag (slab/ceiling already
rebuild via getEffectiveNode in their systems). Classifier + patch unit
tests included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): 2D floorplan group drag — Photoshop-style multi-selection move
Plain pointer-down on a transformable member of a multi-selection now
slides the whole selection rigidly in the floor plan (the 2D parity fill
for the 3D GroupMoveHandle), instead of collapsing the selection to the
pressed node. A plain click (no drag) still collapses on release; modified
clicks, Cmd-drag direct move, and reshape affordances keep their paths.
The move-handle dot routes through the same group session when its node
is part of the multi-selection.
The session shares group-transform-shared verbatim: welded LinkedNeighbor
endpoints, connected-component expansion, live previews via
useLiveNodeOverrides.setMany, grid snapping on the delta plus Figma
alignment through applyFloorplanAlignment (guides in every mode except
off, magnetic pull in lines mode), interaction scope handle-drag with the
shared group-move label, and a single batched updateNodes (one undo).
A dashed group bbox overlay shows what rides along and tracks the live
delta. clientToPlan moves to lib/floorplan/plan-coords for reuse.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): group manipulation polish — helper text + shortcuts dialog
Multi-selection select-mode HUD now advertises the group gestures (drag
any member to move the whole selection, R/T to step-rotate), and the
keyboard-shortcuts dialog documents both under Selection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): restore zone renderer useLiveNodeOverrides import
Formatter stripped it between the import and usage edits; nodes'
tsc --build catches it where the cached turbo typecheck did not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): tag the 2D group selection box for drivability
data-group-selection-box on the overlay <g> so smoke drivers and devtools
can assert the multi-selection chrome.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): multi-selection shows highlight only — hide per-node edit chrome
While 2+ nodes are selected the group is manipulated as one rigid piece,
so per-member edit affordances hide in both views: the 2D overlay pass
strips handle/dimension chrome centrally (stripHandleChrome — body
highlight, hit targets, and zone name text stay), and in 3D the slab /
ceiling boundary editors and slab hole click-to-edit outlines mount only
for a sole selection (the arrow-handle rig, wall side arrows, selection
affordances, and floating menu were already single-gated). Transformable
members show a move cursor in the floor plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): 3D body-drag group move replaces the move gizmo cross
Pressing any transformable member of a multi-selection in 3D and dragging
past the threshold now slides the whole selection on the ground plane
(group-move-3d.ts, armed from the selection manager's pointer-down choke
point) — same participant snapshot, welded junctions, grid + alignment
snapping, live overrides, and single-undo commit as the 2D session; the
2D dashed bbox rides the same delta in split view. A plain click still
collapses the selection to the pressed node. The purple GroupMoveHandle
cross is deleted, and the hover cursor advertises the gesture: 'move'
over any transformable member in both views (3D selection-manager cursor
+ 2D entry cursor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): group action menu — move / duplicate / delete the whole selection
Multi-selections get a floating Move / Duplicate / Delete pill in BOTH
views (GroupFloatingActionMenu anchored above the 3D group bbox,
FloorplanGroupActionMenu anchored above the 2D dashed group box); the
single-node menus are now sole-selection only. All three actions target
the whole selection via shared group-actions:
- Move starts a group pick-up: the selection rides the cursor
(delta-relative, grid + alignment snapped) across BOTH surfaces —
floorplan via the scene CTM, 3D via a ground-plane raycast through a
three-context bridge published by the selection manager — until a
click places it as one undo step; Escape / right-click cancels.
- Duplicate clones the selection through the clipboard pipeline
(subtrees + id remap, without touching the user's clipboard — new
duplicateNodesToLevel), selects the clones, and picks them up;
cancelling deletes the clones again. The pick-up scopes to the
selection (no component expansion / welded links) because the clones
sit exactly on the originals, and falls back to participant-data
bounds because the clones' meshes haven't mounted yet.
- Delete removes everything selected with the keyboard arm's
bulk-confirm semantics.
Verified end-to-end with the scripted smoke (16/16): chrome hiding,
2D/3D/split group drags, R/T round-trip, one-step undo, menu move /
duplicate / delete, 3D move cursor. (Headless runs of the suite can
deadlock in SwiftShader's GL readback on the split-view resize —
environment-only; run headed.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): group transforms no longer re-create auto rooms
Every group commit (2D/3D body drags, pick-up, rotate gizmo, keyboard
R/T) now wraps its single updateNodes in pauseSpaceDetection /
resumeSpaceDetection: the wall-driven room auto-detection rolls its
baseline forward instead of treating rigidly-moved walls as a new room
and duplicating its floors/ceilings. Room creation stays where it
belongs — building and editing walls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): polygon hosts carry their attached items in group transforms
Ceiling-mounted items are children of the ceiling, but polygon hosts
(slab/ceiling) move by rewriting vertices — no group transform — so,
unlike wall children which ride the wall mesh, their children were left
behind by group moves/rotates. collectParticipants now snapshots a
polygon participant's positioned children (level-frame coords, since the
host group sits at the origin) so they ride every group path. Unit test
included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): click picks up the group; the 2D dashed box is the drag handle
Clicking a selected member of a multi-selection (no drag) now enters the
group pick-up — the same click-to-move the sole-selection has — instead
of collapsing the selection, in 2D and 3D alike; clicking outside still
deselects. The 2D dashed selection box is itself the group's handle:
move cursor across its whole area, press-drag anywhere inside slides the
group, a plain click picks it up, and holding Cmd/Ctrl/Shift lets clicks
pass through to the entries so membership toggling keeps working. HUD
hint + shortcuts dialog updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): 3D dashed group selection box doubles as the drag handle
The multi-selection now shows a dashed wireframe box in 3D (sibling of
the 2D dashed rect): move cursor across its whole volume, press-drag
anywhere on it slides the group, a plain click picks it up, and holding
a selection modifier passes the press through so members inside can
still be toggled. Rides the shared live-drag delta so it tracks the
group mid-gesture in both panes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): mid-move R/T, 2D corner rotate, realtime opening symbols, box cursor + Esc hint
- R/T while carrying a group (body drag or pick-up) now rotates the
carried snapshots around the rest pivot — identical to the idle
keyboard rotate — instead of leaking into the store mid-session; the
session keydown owns the keys in capture phase and the group-move HUD
advertises them.
- The 2D dashed box grows corner rotate handles: drag a corner to spin
the group in 15° steps, Shift for free rotation — the floor-plan
sibling of the 3D rotate gizmo, sharing its scope, HUD hints, and
single-undo + space-detection-paused commit.
- Door / window floor-plan symbols now track wall drags in realtime:
their defs merge the walls' live overrides (wallFloorplanSiblingOverrides)
so ctx.parent is the effective wall instead of the stale store one.
- The 3D selection box asserts the move cursor across its whole volume
on pointer move (the old ''-guard lost to app default cursors).
- Multi-select HUD + shortcuts dialog mention Esc / click-outside to
clear, and the mid-move rotate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): rotate cursor + spinning box on 2D corner rotate; Delete ends a carry
- The 2D corner handles show a proper curved-arrow rotate cursor (inline
SVG data URI, black glyph with white halo, grab fallback).
- The dashed selection box spins live with the group during a corner
rotate: the rotate session publishes pivot+angle through the shared
drag store and the box applies the SVG rotate transform.
- Delete / Backspace during any group session (drag, rotate, pick-up)
reverts the session first and then lets the global Delete arm remove
the selection — no more dangling carry after deleting mid-move; the
duplicate flow's cancel still discards the clones instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): per-node direct manipulation stands down for multi-selections
Cmd is both the selection-toggle key and the single-node direct-move /
direct-rotate trigger, so a Cmd+click that wobbled past the drag
threshold over a selected member (slab or any kind) armed the legacy
single-node move and yanked that one member out of the group. All four
per-node direct-manipulation entry points — the 3D Cmd-drag move and
Cmd+right-drag rotate in the selection manager, and their 2D siblings in
the registry layer — now require the pressed node to be the SOLE
selection; with a multi-selection the gesture is simply not registered
(the group sessions own plain drags, Cmd+click keeps toggling). The
shortcuts dialog notes the single-selection scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): group gestures own their ending pointerup — no synthesized click leak
use-node-events synthesizes a selection click on EVERY canvas pointerup
(deliberately more forgiving than R3F's onClick). Body drags were safe
only because inputDragging suppresses it, but the pick-up flows leaked
it as a race: the release that started a pick-up could collapse the
multi-selection to the pressed node first (then only that node was
carried), and the placement press re-selected whatever sat under the
cursor after commit.
The group sessions now claim their gesture-ending pointer events in the
CAPTURE phase and stop propagation, so the canvas never sees them and no
click is synthesized: the pick-up claims the placement press on
pointerdown and commits on its pointerup; the 3D and 2D drag/rotate
sessions stop their ending pointerup (split view: a 2D-started release
over the 3D canvas had the same hole).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): screen-rect marquee tests projected oriented bounds, not double-inflated AABBs
The 3D screen-rect box select intersected the marquee with each node's
world AABB re-boxed in screen space — two nested axis-aligned inflations.
Any oblique camera padded every node's claim, and rotated geometry (now
common: group rotation) made it flagrant — a diagonal wall's world AABB
spans a full square, so marquees selected objects visually far from the
cursor.
Membership now tests the marquee against the convex hull of the node's
ORIENTED (local-frame) bounding box projected to screen — tight under any
rotation and camera, uniform for every kind. Pure helpers
(convexHull2D / rectIntersectsHull) live in marquee-geometry.ts with unit
tests covering the diagonal-wall regression, containment both ways, and
edge-only crossings. The plane marquee variant was already precise and is
untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): keep box select alive after group gestures
The capture-phase stopPropagation the sessions used to silence the
canvas's synthesized selection click also killed the window bubble
listeners that clear the box-select pointer suppression — and the mouse
pointerId is constant, so one group gesture left the marquee dead until
blur. The sessions now use the designed suppression instead: they hold
inputDragging raised through the release event's dispatch (lowered on a
0ms timer) so use-node-events suppresses the click itself, and every
other pointerup listener runs normally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): marquee membership by plan footprint; 2D item highlight; site line steps back
- The 3D screen-rect marquee now projects itself onto the active level's
floor plane and tests the DATA kinds exactly there — walls/fences by
their segment, slabs/ceilings/zones by their polygon — matching the
plane-marquee tool's semantics. This kills the two remaining
imprecision sources the hull pass couldn't: vertex-baked rotation
(polygon/fence geometry lives in level coords, so after a group
rotation even the local bbox is an inflated axis-aligned square) and
ceiling parallax (an elevated plane projects to a screen quad offset
from its room, selecting 'from far'). Mesh-transform kinds (items,
columns) keep the projected oriented-bbox hull test; plan-space
segment/polygon intersection helpers join marquee-geometry.ts with
unit tests.
- Selected items finally read as selected in the floor plan: palette
stroke + heavier weight on the footprint, plus a selection ring drawn
above the thumbnail (the move dot used to be the only cue, and it
hides in multi-selections).
- The site's dashed property line drops to 20% opacity while a multi or
in-flight marquee selection exists so it stops fighting the dashed
group selection box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): 2D marquee plan-footprint membership; item marquee preview; overlays re-measure after undo
- The 2D screen marquee intersected each entry's getBoundingClientRect —
an axis-aligned DOM box, inflated for rotated polygons and diagonal
walls (the same double-inflation class as the 3D bug). It now maps the
marquee through the scene CTM into a plan quad (rotated views included)
and tests the data kinds exactly — walls/fences by segment,
slab/ceiling/zone by polygon — reusing the shared marquee-geometry
helpers; other kinds keep the DOM-rect fallback.
- Items now show the marquee preview tint in 2D (highlighted viewState =
the same palette stroke + thumbnail ring the selection shows, slightly
lighter), instead of giving zero feedback until the drop.
- Everything that measures the selection's meshes (2D/3D dashed boxes,
the 3D group menu anchor, the rotate gizmo pivot) re-measures once the
meshes settle after a scene change via useMeshSettleEpoch — an undo
after a move no longer leaves the box at the pre-undo position
(computeGroupBox reads mesh world bounds, which lag the store commit by
a frame or two).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): dim the actual site boundary line during multi-selections
The earlier dimming targeted registry-layer site entries, but the site
lives at the scene root — outside the level DFS and the building-scoped
sweep — so no entry ever matched and the dashed property line kept full
opacity next to the dashed group selection box. Move the dimming to
FloorplanSiteLayer (the component that actually draws the line) and
drop the dead registry plumbing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(items): retry failed model loads, settle skipped items, keep exports clean
A transient storage failure (observed: Supabase 504s under the bake page's
~200-concurrent-request burst) permanently broke an item for the whole
session: drei's useGLTF caches the rejected load by URL, the per-item
ErrorBoundary swallowed it, and the red debug-box fallback was BAKED into
the exported GLB (observed in a prod artifact). Meanwhile ItemSystem
cleared the dirty mark at group registration — before the model resolved —
so scene-ready could fire while GLBs were still loading, risking exported
placeholder geometry.
- ModelWithRetry: bounded retries (1s/3s) that clear the useGLTF cache
entry and re-mount via the boundary's new resetKey. The timer is owned
by an effect keyed on the failure count, so StrictMode's synthetic
unmount/remount re-arms it instead of silently discarding it (the
naive onError-scheduled timer died exactly that way in dev).
- Exhausted retries settle the item as SKIPPED: the debug box renders
nothing during exports, and the failure lands in
useViewer.itemLoadFailures (nodeId -> url) so a bake host can persist
which items are missing from the artifact.
- ItemSystem holds the dirty mark until the item settles (model mounted,
terminally failed, or never expected) — scene-ready now genuinely waits
for item content; loading placeholders also hide during exports.
- ErrorBoundary: onError + resetKey props.
Verified against a 200+-item prod scene locally: permanent 504 -> exactly
3 fetch attempts, bake completes without the item and without debug
boxes; 504-once -> retry heals, artifact byte-equivalent to the intact
run; no-failure runs unchanged (demo_1 byte-identical).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(items): reset retry budget + settled flag when the asset URL changes
Bugbot: after a terminal load failure, swapping the item's model kept the
stale failures/epoch state and the settled flag — the new URL never even
attempted to load. ModelWithRetry is now keyed by asset src (clean retry
budget per URL) and un-settles the item on mount so the replacement load
is awaited by ItemSystem/scene-ready too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
While editing a numeric field, the unit suffix now visually responds to
the draft content:
- Plain number / empty: suffix dims to 40% opacity (signals "default unit
if you commit a bare number")
- Explicit unit typed (hint showing): suffix fades to opacity-0 over
150ms (the conversion hint provides unit feedback instead)
- Unparseable text: suffix stays dimmed (field will revert on commit)
- At rest: unchanged full-opacity suffix
Uses opacity-0 rather than unmounting so the flex layout doesn't jump.
The predicate is simply `hint !== null` — it aligns exactly with
measurementHint's internal PLAIN_NUMBER guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instanced plant kinds (trees/grass/flowers) never cleared their dirty
marks: FloorElevationSystem deliberately leaves the mark for kinds with
a def.system, expecting that system to clear it after its own work, but
InstancedKindSystem never participated in the dirty protocol. The marks
lived forever, hasPendingSceneBuildWork() never went false, and the
Viewer's scene-ready signal stalled at SCENE_READY_MAX_WAIT_FRAMES on
every plant-containing scene — measured on the headless bake worker as
~190s (180 frames x ~1s SwiftShader frames) of pure cap-wait per bake.
Clear the marks in a priority-2 useFrame pass: after the priority-1
floor-elevation lift in the same frame, and only for nodes whose proxy
is registered (instances rebuild synchronously from the store, so a
rendered node is already built). Validated by baking Wawa House
locally: settle 22.5s -> 11.6s (the remainder is genuine asset
loading), exported GLB byte-identical with and without the fix.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Route all pending-pose cancellations through clearPendingFloorplanNavigationPose()
so savedSmoothTime is always restored. Add a controlstart listener to catch
right-click orbit and other pointer drags that weren't handled. Guard the
rotation-degree ref mirror to prevent React re-renders from clobbering the
imperative 3D-owned value when the panel is hidden, and add a useLayoutEffect
to keep the needle DOM in sync. Make the catch-up effect re-run directly on
panel reopen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove CSS transition on compass needle SVG so rotation tracks
exactly without rubber-band lag (was transition-transform 150ms)
- Drive compass needle imperatively via ref when floorplan panel is
hidden (3D-only mode), avoiding per-frame React re-renders of the
full SVG while keeping the needle live
- Align-to-north in 3D-only mode derives from latestNavigationSyncPose
instead of stale viewport, publishes directly to the 3D camera
- Temporarily lower camera-controls smoothTime from 0.25s to 0.18s
during 2D-origin transitions (SmoothDamp τ≈90ms matches the 2D
exponential decay), restoring on completion or cancellation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.2.0 is API-compatible with our usage (parseQuantity, quantity, Kind);
it adds i18n/locale entrypoints and does not change the symbols we import.
Verified: @pascal-app/editor and @pascal-app/mcp typecheck, and all 31
measurement tests pass, including the AMBIGUOUS_NUMBER escalate guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The metric/imperial preference was already honored by every length input,
but two area surfaces were still hardcoded to m²:
- the "Load build" import dialog's Floor area stat
- the auto-generated default names for zones, slabs, and ceilings
(e.g. "Zone (12.3m²)"), which are live display fallbacks
Both now convert value + label to the active unit. Adds shared
squareMetersToAreaUnit / getAreaUnitLabel / formatAreaLabel helpers to
lib/measurements (with tests) and routes the site-panel Area readout
through them, replacing an inline 10.7639 magic constant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix pass over 2D reference-image (guide) handling plus two adjacent repairs:
- North is now world −Z (FLOORPLAN_VIEW_ROTATION_DEG 90 → 0, mirrored in
world-grid-snap + mcp README; floorplan-panel de-dupes its local copy).
A rotation-0 north-up scan reads upright when the compass says aligned,
and "align north" maps to camera azimuth 0 instead of a 90° jump.
- Guide resize cursor: custom double-arrow cursor aimed at the dragged
corner in screen space — accounts for image aspect (was atan2(±1,±1),
square-only), the guide's own rotation, and the floorplan view rotation
the overlay group renders inside (was ignored entirely).
- R/T rotate a selected reference (guide/scan) in ±45° steps; references
live in selectedReferenceId, not the viewer selection, so both arms get
the reference-first branch the Delete arm already had. Locked guides skip.
- 2D-only mode hides the Top View button (drives the display:none 3D
camera); orbit stays — it spins the synced floorplan view.
- Set Scale UX: locked guides stay clickable (calibration auto-lock made
them unselectable until reload); starting the flow from 3D switches to
2D; the length input pre-fills the drawn length in the pre-selected unit
(imperial pre-filled meters labeled feet); Set Scale flips into Cancel
while the flow runs (mirrored via referenceScaleActiveGuideId); Hide/
Clear Scale only render once calibrated; Escape cancels (global arm +
dialog); Clear Scale and Replace Image drop the calibration auto-lock;
discoverability: corner-hint row + panel nudge for uncalibrated guides.
- healSceneNodes: strip child refs whose child's parentId names another
parent (stale reparent leftovers rendered a window twice — duplicate
React keys in 2D, doubled hosted geometry in 3D) and same-array dupes.
- Editor accepts onLoaderChange so hosts can measure open-to-interactive
time (community wires it to a PostHog timing event).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The property-line vertex X/Z inputs and the Area/Perimeter readout were
hardcoded to meters. Convert them to feet / ft² when the viewer preference is
imperial, matching every other length input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SliderControl (which drives the auto-inspector's ~144 fields and most custom
panels) previously ignored the viewer unit preference and always rendered raw
meters with a static "m" label, while MetricControl converted to feet — so
toggling imperial changed some length inputs and not others. Extract the
conversion into a shared `useLinearDisplay(unit, precision)` hook and route
BOTH controls through it, so every `unit="m"` length field displays and edits
in feet when imperial and meters when metric — consistently.
Values are always stored in meters; only display, the text field, and drag/
wheel/arrow deltas move to the display unit. For metric and non-length units
(°, %, in, …) the conversions are the identity, so those paths are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core): apply window schema defaults on scene load
Windows saved before a schema field existed (columnRatios/rowRatios/
frameThickness/…) loaded with those fields missing; the window mesh
builder reads them unconditionally and threw every frame, crashing the
viewer on legacy scenes. Zod-parse windows on load like doors so the
schema defaults land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): mount plant geometry during client GLB/STL/OBJ export
The client export never set isExporting, so instanced kinds (trees/
flowers/grass) kept their colorWrite:false raycast collider mounted and
exported it as an opaque white box, while the real geometry (which only
mounts while exporting) was never captured. Reuse BakeExporter's
flag + frame-wait dance in ExportManager, and harden isRenderableMesh
to drop colorWrite:false materials from exports entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): route zone drawing through the shared surface snap
The zone tool quantized later vertices by distance along a free ray
instead of snapping to the grid, and never joined the magnetic
wall-corner/midpoint/crossing + alignment-guide pipeline that walls,
slabs and ceilings use. Give zone the slab treatment in the 3D tool and
both 2D floorplan branches (move + click + double-click commit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(viewer): self-host the scene environment HDR
drei's preset="sunset" fetches venice_sunset_1k.hdr from
raw.githack.com, which intermittently fails ("Could not load
venice_sunset_1k.hdr: Failed to fetch"). Point Environment at
/hdri/venice_sunset_1k.hdr and ship the file in the app's public/ —
same mirroring convention as /audios/sfx; consuming apps must carry
the file too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): unmount zones entirely outside the zones layer
Zone labels showed in every editing mode (visual noise), and each zone
kept a drei <Html> mounted at opacity 0 — an <Html> costs per-frame
matrix work and live DOM even when invisible. A new viewer presentation
flag (showZones, default true) lets the editor unmount zone meshes and
labels whenever the structure layer isn't 'zones' (and during snapshot
capture); the registered group stays so zones keep their scene identity
for selection and GLB export. Preview / first-person / viewer surfaces
are untouched. Raycast-disable moved from a one-shot group flag to
per-frame on the meshes, which now remount on layer toggles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Records @pascal-app/lingo@^0.1.0 in bun.lock (added to @pascal-app/editor and
@pascal-app/mcp), so `bun install --frozen-lockfile` in CI resolves it. Also
documents that measurement.ts is intentionally duplicated with the AI-chat copy
across the submodule boundary and must be kept in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial-review follow-ups to the lingo measurement() field:
- Add a `positive` option (strict > 0) and use it for the non-zero dimension
params. Swapping z.number().positive() → measurement(..,{min:0}) had started
admitting 0 (the core node schemas have no positivity backstop), so a zero-size
wall/opening/roof could be created. Inclusive-0 fields (overhang, sill height,
knee-wall height, opening offset, roof pitch) keep min:0.
- Escalate AMBIGUOUS_NUMBER to error so "1,234" fails instead of silently reading
as 1234 — a 1000x hazard for European decimals. Matches lingo's own /ai fields.
- roofLevelElevation reverted to z.number() (it's a level ordinal, not meters);
radians fields now advertise radian-appropriate examples.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measurement/angle parameters on the scene tools now accept a bare number OR a
natural-language string ("6 in", "180cm", "45°", "1.57rad"), canonicalized to
the unit the handler already expects (meters for length, radians/degrees for
angles) with min/max bounds and model-readable errors. Makes LLM tool calls
safer — "6 in" no longer has to be pre-converted to 0.1524, and a bad value is
rejected with a message the model can self-correct from.
A shared `measurement(kind, unit)` zod field wraps lingo's parseQuantity as a
`number | string` union+transform; numbers pass through unchanged (backward
compatible), so no handler changes were needed. Covers create_wall, cut_opening,
place_item, create_level, create_story_shell, create_roof,
create_stair_between_levels, create_room, add_door, add_window, photo_to_scene.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type any unit into SliderControl / MetricControl fields — "6ft", "180cm",
"1m80", "5'11\"", "72in", "45°", "1.57rad" — and it canonicalizes to the
unit the field already stores (meters / degrees / radians / inches),
independent of the metric/imperial display toggle. A live "= 1.83 m" hint
previews the parsed value while typing.
Only the typed-text commit path changes: drag-scrub, wheel, and arrow-key
editing are untouched, and any unit the adapter doesn't recognise (%, unitless,
rad/s) falls back to Number.parseFloat, so nothing regresses. The shared
lib/measurement-parser.ts maps each field's `unit` prop to a lingo kind +
canonical unit, covering the auto-inspector and every custom panel at once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): keep snapshot pitch out of preset thumbnail capture
The capture overlay's snapshot hint and 'Take snapshot' shutter label
rendered in every capture variant, clashing with the save-as-preset
flow's own 'Frame your item — click Capture' banner. Gate them on the
existing isPreset discriminator and label the shutter 'Capture' there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(viewer): renderPaused flag suspends the frame loop
Hosts that fully cover the canvas (e.g. the studio gallery overlay) can
set renderPaused to stop FrameLimiter's RAF loop — a heavy animated
scene otherwise keeps starving the GPU behind the overlay and makes it
stutter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): drop the crop/format HUD in preset capture
The pills crowded the save-as-preset banner and repeat what the fixed
square frame already shows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): no contextual shortcut hints in the studio workspace
Holding Shift surfaced select-mode/snapping hints over the studio
compose panel and gallery, where there is no scene selection or tool to
act on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
- roof-system: replace BoxGeometry placeholders (initial swap + empty-roof
merged shell) with a group-less degenerate geometry — a Box's 6 groups
against the roof's 4-material array crashed GLTFExporter on every scene
with a segment-less roof node (prod: 'reading isShaderMaterial'), and
count-0 groups crash MeshBVH's packed-tree build.
- glb-export: sanitizeMaterialGroups pass repairs any mesh whose geometry
groups don't line up with its material array before GLTFExporter runs.
- export-manager: give neutralised (attribute-less) meshes an empty position
attribute before STL/OBJ export — both exporters read position.count
unconditionally.
- bake-exporter: log the full error stack so the bake worker's console relay
captures it in the job's error trail.
- viewer: host-controlled disablePostFx prop (Viewer → PostProcessing) that
skips building the SSGI/denoise pipeline entirely; the ?disable=postFx URL
flag previously only bypassed it per-frame while still allocating it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): stage overlay slot, preselectable snapshot crop, pro capture overlay
- Editor/EditorLayoutV2 gain a stageOverlay slot rendered over the canvas
but under the viewer toolbar (z-10 < z-20), so hosts can swap in a
full-stage surface (e.g. studio gallery) without unmounting the WebGL
canvas; FloatingLevelSelector hides while a stage overlay is active
- CaptureMode 'standard' accepts an optional crop preselection
(SnapshotCropMode, exported) so hosts can enter capture with
standard/viewport/area already chosen
- SnapshotCaptureOverlay restyle: letterboxed 16:9 frame with corner
accents + rule-of-thirds for standard mode, thirds grid in viewport
and area modes, top-center crop/format HUD chips, shutter button with
capture/saved states — all existing crop, drag, resize and preset
behaviors preserved
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): standard-capture aspect presets, capture scrim, caption fix
- Standard crop gains aspect presets (16:9, 9:16, 4:3, 3:4, 1:1) — clicking
the active Standard chip opens the picker; the letterbox frame, HUD and
output resolution follow the choice
- ThumbnailGenerateEvent carries standardSize; ThumbnailGenerator
center-crops to it (default stays 1920×1080)
- Subtle bottom scrim keeps shutter/caption readable on bright scenes
- Fix missing space in the snapshot caption
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): standard-capture aspect preselect via CaptureMode
Hosts (the studio capbar) can now pass standardAspect alongside crop when
entering capture mode; the overlay initializes its aspect picker from it.
SnapshotStandardAspect exported.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default
- Alignment guides display in every snapping mode (grid/lines/angles/off);
magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate
decouples guide display from the snap delta across all placement/move/draw
producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/
window, MEP, 2D floorplan drafting, surface snap).
- Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG -
building rotation), matching the on-screen aligned-to-north view.
- Item placement rotate hint collapsed to a single 'R / T Rotate' row.
- Default item snapping mode changed lines -> grid.
* feat(walls): detect rooms across shared walls + tight connect-snap in all modes
Room detection: planarize the wall graph before face-finding — split straight
walls at T-junctions where another wall ends mid-span, so a room closed against
the middle of an existing wall is detected, not just isolated 4-wall rooms.
Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph
via wallClosesRoom(), so drafting stops when a segment seals a room against the
existing structure — not only when the chain returns to its own start.
Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05)
that also runs in grid/off/angles — within range of a wall (body or corner,
uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close
in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in
non-magnetic modes to the same connect distance so a corner dot no longer
magnetises the cursor from far.
Tests: cover T-junction detection + wallClosesRoom; update the item-default and
wall-split tests for the mode-driven behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(surfaces): gate alignment guides to connect distance in non-magnetic modes
Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap
(resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the
connect distance are fed to the alignment resolver, so guides form to nearby
points and far corner dots stop lighting up from across the plan. Filtering the
candidates (local-frame, like the cursor) rather than the resolved guides avoids
the floor-plan view rotation baked into the guide coords. Lines mode keeps the
full-range guides; the surface wall connect-snap is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slabs): deterministic wall-slab overlap via clipped overlap length
Wall elevation used ray-cast point samples that landed exactly on the
slab boundary for perpendicular walls, so whether a butting wall
followed the slab's elevation depended on which side of the slab it
touched. Clip the wall centerline and face lines against the polygon
and require >=5cm of on/inside length instead: walls along the slab
edge follow it on every side, point contact never does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start
The horizontal snap lattice sat exactly at the followed surface Y, so it
z-fought elevated slab tops while moving items. The visual mesh now rides
1mm above; the grid event plane keeps the true height.
Moving a wall-hosted item showed a horizontal grid until the first
pointer move published a wall surface — the mesh fallback assumed UP.
Derive the host wall normal from the item mesh's world orientation
(local +Z faces out of the wall) so the lattice is wall-aligned from
the first frame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): unify selection shortcuts + truthful select-mode hints
Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and
appends on box-select release), matching the 2D floorplan. The HUD rows
describe what actually works in both views: move is plain left-drag (the
grip/dot), the two vague selection rows collapse into one Cmd/Shift
or-group, and the modifier-held variants (freely / with guides / bypass
snaps) are gone — guides follow the snapping mode now. Key pills join
with + for combos and / for alternatives; Shift renders as the shift
icon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration
The group-rotate gizmo never received pointer events through the shared
invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled
context; its handlers now live on the visible arrow plus a plain
default-layer invisible torus, so hover and drag work with a fat target.
Group drags begin a handle-drag scope: each gizmo hides while its
sibling drags (the frozen corner goes stale), idle hints leave the HUD,
and the drag gets contextual hints — Shift free-rotation for rotate, the
snapping chips for move.
Group move joins the snapping-mode system via the 'item' context: Shift
cycles the mode, Ctrl the grid step (both read live mid-drag), and
'lines' runs the same Figma-style alignment as single-node moves against
the group's bbox anchors. Chip clicks now tick like the keyboard cycles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): group-move ticks in every snapping mode
Mirror the single-node move's sfx: emit per delta change rather than
only on grid crossings, so lines/off get the same rate-limited texture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(items): keep host transfers glued to the cursor
Moving between hosts used to diverge from the pointer and mangle state:
- Grab offsets: every surface anchor (wall / ceiling / shelf / item
surface / floor) preserved the grab offset by re-seeding from the
item's carried-over position on each new host, landing it far from
the cursor. The grab offset now survives only on the original host
and only until the item anchors anywhere else — after that every
host (the original included) centers the item under the cursor.
Applied uniformly to the placement coordinator and the window/door
move tools.
- Rotation: detaching from a rotated shelf/table back to the floor kept
the HOST-local yaw as the level yaw, visibly spinning the item. The
detach now re-expresses the item's world yaw in the level frame.
- Elevation: the same detach wrote the level parentId to the store but
not the draft ref, so the floor-elevation resolver bailed on its
parent-must-be-a-level guard — the snap grid and the item stopped
following slab elevations for the rest of the drag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(items): latch grab-offset forgetting across every host kind
Review follow-up (PR #458): unify the per-surface grab-forget rule into
one grabForgotten latch. A wall/ceiling item could not actually reach a
shelf or the floor (item-surface enter rejects attachTo assets), but a
wall item CAN anchor on a roof face — and returning to its original
wall then restored the stale grab offset. Roof-wall transitions (and
floor landings after a host visit) now trip the latch in the
coordinator and the window/door move tools alike.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): trees tracer-bullet plugin + minimal host panel surface
Ship one real first-party plugin and only the host surface it forces
into existence, proving the plugin contribution paths every future
plugin (mint.gg generator, Home Assistant, environments, room volume)
will reuse.
Host surface (core + editor):
- core: Plugin.panels + observable panelRegistry; loadPlugin routes
panels (namespaced by plugin id, dup-throws like nodes).
- editor: AppSidebar merges panelRegistry into the icon rail via
useSyncExternalStore; each plugin panel lazy-loaded behind an error
boundary; host extraPanels keep precedence.
- editor: widen Tool to `KnownTool | (string & {})` so plugin tool ids
(e.g. 'trees:tree') typecheck; dispatch already registry-first.
Trees plugin (packages/plugin-trees), structurally a third-party pack
(peer-deps on @pascal-app/*):
- trees:tree node — procedural low-poly geometry (oak/pine/birch/palm),
free parametric inspector (preset/height/seed + Randomize), placement
tool + ghost preview built from public primitives only.
- presets rail panel — panel -> plugin store -> def.tool -> SceneApi ->
scene -> reactive read-back ("N planted").
Loading + docs:
- apps/editor: setPluginDiscovery([treesPlugin]) in bootstrap;
transpilePackages + dep.
- wiki/architecture/plugin-authoring.md: panels field, error-boundary
contract, styling, tool-from-panel note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): merge plugin panels into the v2 sidebar layout too
The community shell renders <Editor layoutVersion="v2" sidebarTabs={...}>,
a separate rail from AppSidebar. Merge registry panels into the v2
tabMap + tab bar (and thus mobile) so plugin panels show up there as
well, not only in the v1 AppSidebar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): ez-tree geometry + instanced rendering + snapping
Replace the placeholder low-poly geometry with dgreenheck/ez-tree
(self-contained — bark/leaf textures inlined as base64, no assets to
host) and render the forest with true GPU instancing.
Rendering:
- def.system (system.tsx): one collective renderer that groups every
trees:tree node by (preset, seed) variant and draws each variant as
one InstancedMesh per ez-tree sub-mesh, composing the parent level's
world matrix into per-instance matrices. ~1 draw call per variant.
- def.renderer (proxy-renderer.tsx): an invisible, raycastable per-node
proxy so the host's existing selection / outline / zone machinery
keeps working — no instanceId bookkeeping. (Outline highlights the
proxy bbox; documented.)
- geometry per variant generated once by ez-tree and cached; seeds drawn
from a bounded pool so trees actually share variants.
Presets remapped to ez-tree built-ins (oak/pine/aspen/ash/bush).
Placement now respects the active snap mode — isGridSnapActive() +
gridSnapStep + snapPointToGrid, like the built-in item/shelf tools.
transpilePackages += @dgreenheck/ez-tree in the editor app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): true-silhouette hover/select + richer presets panel
Selection/hover now outlines the real tree shape, not the bbox. The
proxy splits into an outer group (stable invisible box collider +
pointer handlers, the raycast target) and an inner registered group that
mounts the real ez-tree geometry (invisible, non-raycasting) only while
the node is hovered or selected. The host outline pass reads the
registered inner group, so it traces the true silhouette; picking stays
on the steady box. Geometry for the highlight reuses the cached variant.
Panel: redesigned cards with gradient swatches + selected state, a
"planted" count chip, and a height slider that seeds the next tree's
height (a per-instance scale — never touches placed trees or instancing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): curated tree params + procedural flowers (sibling kind)
Tree params: expose foliageDensity, trunkThickness, and a leafless toggle
in the inspector and the panel brush. Each is folded into the instancing
variant key and mapped onto ez-tree options (radius scale, leaf count),
so editing one tree only re-buckets that tree — instancing degrades
gracefully, never worse than per-node.
Flowers: add a `trees:flower` sibling kind — simple procedural geometry
(stem + petals + center, merged per material), presets daisy / tulip /
lavender, instanced + selectable exactly like trees.
Refactor: extract the instanced renderer + selection proxy into a generic
`instanced.tsx` (InstancedKindSystem + KindProxy) and the snap/level/grid
placement wiring into `placement.tsx` (usePlacement). Trees and flowers
are now thin bindings — the template for future plant kinds.
Panel: a Trees / Flowers toggle, gradient preset cards, per-kind planted
count, and brush sliders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): native inspector controls in panel + preset thumbnails + ez-tree credit
- swap the panel's custom slider/checkbox/toggle for the host's exported
SliderControl/ToggleControl/SegmentedControl so the brush matches the
right-hand inspector pixel-for-pixel
- replaceable preset thumbnails (inline SVG data URIs, no asset hosting) render
as <img> cards instead of gradient swatches
- credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind
- tree presets now cover all of ez-tree's built-ins via species × size
(Small/Medium/Large + Bush 1/2/3 + Trellis), plus a Deciduous/Evergreen type
- edit-only leaf & branch colour tints (leaves.tint / bark.tint), all folded
into the instancing variant key
- flowers gain a per-flower petal colour (baked from preset at placement)
- new trees:grass instanced kind — procedural blade tufts (meadow/fescue/reed)
with a per-tuft blade colour, reusing the generic instanced + placement core
- panel: Trees/Flowers/Grass segmented switch, size + type controls, native
host controls throughout; credit links Daniel Greenheck's X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tolerate nodes persisted before new fields existed
treeSpecOf/flowerPetalColor now default every geometry field, and hexToInt
guards undefined — trees/flowers placed before size/type/colour fields were
added load with neutral defaults instead of crashing the instanced renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): shared always-on wind + pin credits to panel bottom
- wind.ts injects a sway into each material's begin_vertex driven by one shared
uTime uniform (advanced per frame in the instanced system); USE_INSTANCING
guards keep the same material valid for the non-instanced placement ghost.
Applied to tree, flower, and grass materials — a whole scene sways like ez-tree.
- credits footer is now sticky to the bottom of the panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename panel header Plant -> Nature to match the rail
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: bake-exportable instanced plants + renderer-agnostic wind
Bake (GLB export): add a transient useViewer.isExporting flag. BakeExporter
flips it, waits for the commit, then exports. KindProxy watches it and, during
export, emits each plant's REAL visible geometry under scene-renderer (which the
exporter clones) instead of the invisible collider — so instanced kinds
(def.system) that live outside that subtree are captured. The collider box is
dropped during export so it doesn't bake as a phantom solid.
Wind: the previous onBeforeCompile wind was a no-op under the editor's WebGPU
renderer (WebGL-only hook). Replace it with a renderer-agnostic per-instance
base-pivot tilt animated in useFrame (which FrameLimiter drives continuously).
Reads as wind under any renderer; removes the dead wind.ts + applyWind calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt
wind-node.ts adds a TSL positionNode that bends each plant proportional to
height above its base, phased per-instance (instanceIndex) and per-vertex, so
tips sway and roots stay planted — animated on the GPU via the renderer's time
node. ez-tree is untouched: its materials are copied into MeshStandardNodeMaterial
(toWindMaterial); flowers/grass build node materials directly (windStandardMaterial).
Instance matrices go back to static.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree materials render black under wind (Phong not Standard)
ez-tree's bark/leaves are MeshPhongMaterial; copying them into a
MeshStandardNodeMaterial swapped the shading model (specular/shininess ->
roughness/metalness) and rendered black. Convert into the matching node
material type (Phong->Phong, Lambert->Lambert, else Standard) so map/color/
alphaTest/side are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): trees still black — build node material from map/color explicitly
Material.copy() doesn't transfer a classic material's map/color onto a node
material, so textured trees rendered black (flowers/grass were fine — they carry
no map). Re-create the ez-tree materials as MeshStandardNodeMaterial with map,
alphaMap, color, side, alphaTest passed explicitly in the constructor — the same
proven path the flower/grass materials use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), static trunk
The whole-tree height-based bend was a rigid rotation about the base ('rotating
in place'). Replicate ez-tree's actual approach: scale sway by the leaf card's
uv.y and apply it ONLY to the 'leaves' material (multi-frequency wave, phased per
instance + per leaf) so leaves flutter from their attachment while bark/branches
stay static. Flowers/grass keep the gentle whole-plant stem bend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wind via it
Adds a broad, plugin-declared post-load scene hook so effects survive GLB export:
- core: Plugin.onSceneLoad (lazy, three-free) + observable sceneHookRegistry + loadPlugin routing.
- viewer: applyPluginSceneHooks util; PluginSceneHooksSystem re-runs it on the live
scene as nodes change; GlbScene runs it on each loaded baked GLB.
- plugin-trees: declare onSceneLoad; wind is no longer welded into the materials —
the hook re-attaches LEAF_FLUTTER/STEM_BEND by material name (leaves / flower-* /
grass-*), so it now animates in the baked /viewer too, not just live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked GLB matches editor
The bake's quantiser renormalises each plant mesh to [-1,1] and moves real size
into a large node scale (~3 on trees). LEAF_FLUTTER added a constant local offset,
so in the baked GLB it was multiplied by that scale → ~3x over-swing. Divide the
sway by modelScale.x to keep a constant world-space amplitude; editor (scale 1)
is unchanged, baked now matches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert: onSceneLoad wind approach → back to wind-in-materials
Reverts the Plugin.onSceneLoad hook + viewer application + modelScale compensation
(commits 893ca839, 6c9ee003). Superseded by the bake-policy 'replace' model
(plans/editor-plugin-trees-example.md Part D): trees render live in the viewer via
their own path, so wind is a plain positionNode on the material again — no hook,
no baked-geometry decoration, no coordinate-space fights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): per-kind bake policy (static/strip/replace)
Adds NodeDefinition.bake ('static' default | 'strip' | 'replace') + registry
helpers bakePolicyOf/kindsWithBakePolicy in core. Generalises the previously
hardcoded scan/guide handling:
- glb-export strips kinds with bake==='strip' from the artifact (was a
scan/guide type check); 'replace' kinds stay baked (portable static snapshot).
- glb-reference-nodes selects rebuild candidates by policy instead of type.
- scan/guide declare bake:'strip'; trees/flower/grass declare bake:'replace'.
Viewer-side 'replace' swap (strip baked meshes + live-rebuild) is the next slice.
Part of plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): viewer 'replace' swap — hide baked meshes, re-render live
Adds NodeDefinition.bakeReplaceRenderer (falls back to renderer). The baked
/viewer now, for kinds with bake:'replace' and a loaded plugin:
- hides the static baked meshes (bakePolicyOf(kind)==='replace') in GlbScene's
identity pass — capability-gated, so with no plugin the meshes stay;
- re-renders each node live via buildGlbReplaceNodes + the shared portal-into-
baked-level path (GlbReferenceNode now prefers bakeReplaceRenderer), so trees
ride level stacking automatically and wind runs in real coordinate space.
plugin-trees ships a hookless KindStatic (real geometry + wind materials, no
collider/selection) and per-kind static-renderer binds; tree/flower/grass point
bakeReplaceRenderer at them. Completes plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on camera move
GlbScene re-renders per frame during camera movement (hover raycast / walkthrough
HUD). The rebuilt nodes reconciled every frame — fine for 1-2 scans/guides, but a
bake:'replace' forest puts dozens of nodes here (profiler: KindStatic x56 6ms,
GlbReferenceNode x57 4ms per frame). memo at the node boundary skips the whole
subtree (incl. the plugin renderer) when (node, anchor) are unchanged; both props
are stable refs, so it short-circuits cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest
The baked scene's <primitive> has pointer handlers, so R3F recursively raycasts
every descendant on each pointer move (incl. orbit drags). The live replace trees
had fully-raycastable dense ez-tree geometry, so hover cost ~400ms/frame over a
forest (profiler: 'JavaScript' 413ms during camera move). They carry no pascalId
(a hit resolves to the level, not the tree), so they're scenery, not pick targets
— mirror KindProxy, which already NO_RAYCASTs its real geometry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(bake): viewer 'replace' renders instanced per level (was per-node)
Replaces the per-node KindStatic viewer path with a collective instanced one,
mirroring the editor's system instead of diverging from it:
- extract InstancedNodes from InstancedKindSystem (shared by both) with a
localSpace flag: editor folds parent world matrix (root instances); viewer uses
level-local matrices + NO_RAYCAST, portaled into the baked level.
- bakeReplaceRenderer is now a collective renderer (BakeReplaceRenderer<N>,
receives {nodes}); GlbReplaceInstances groups replace nodes by (level, kind) and
portals each kind's instanced renderer into that baked level. GlbReferenceNode
reverts to per-node renderer (strip kinds only).
Fixes: forest hover cost (100+ tree meshes → a few instanced draws, R3F walks far
fewer objects) and per-tree wind phase (instanceIndex varies again; per-node
KindStatic gave every tree phase 0 → unison sway). Trees still ride level stacking
(portaled) and stay static in a plain glTF viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): BVH the baked scene — accelerate hover/pick raycasts
The baked /viewer runs with useBvh={false}, so GlbScene's hover/pick raycasts
against the baked building were brute-force triangle intersection (profiler:
_computeIntersections + intersectTriangle ~30%). The parametric viewer wraps its
scene in <SceneBvh>; the baked one never did. SceneBvh's effect is one-shot on
mount and can't catch the async-loaded GLB, so compute a per-mesh BVH inside
GlbScene keyed on gltf.scene instead. 'replace' instances are NO_RAYCAST, so the
raycast===Mesh.prototype.raycast guard skips them (mirrors SceneBvh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)
Our node defaults (seed:1, treeType:deciduous, colors:#ffffff) were applied as
*overrides* on top of loadPreset, discarding each preset's tuned seed, growth
model, and tints — so our trees looked nothing like eztree.dev (pine grew
deciduous, foliage washed white, every silhouette off).
Mirror the flower petalColor pattern: seed/treeType/leafColor/branchColor are now
optional; generateTree only overrides an ez-tree option when the node set it,
otherwise loadPreset's value stands. Placement stores none of them (fresh tree =
pure preset; all same-preset trees share one instancing variant); the deciduous/
evergreen brush toggle is gone (growth model comes from the preset). The inspector
keeps them as per-tree overrides, and Randomize still varies the seed.
Verified against ez-tree: fresh Oak Medium → seed 35729 + preset tints; Pine
Medium → evergreen + seed 13977; overrides apply when set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): bundle real preset artwork (webp, in-package)
Replaces the placeholder SVG thumbnails + lucide panel icon with the real nature
art. 13 webp (~126KB total, 256px cards / 128px icon) live in src/assets and are
imported via art.ts — both consumers are Next, so transpilePackages runs them
through the image pipeline (hashed, cached /_next/static/media URLs). No CDN, no
per-app public/ mirroring; the assets travel with the package.
- art.ts: central webp imports → TREE_ART / FLOWER_ART / GRASS_ART / NATURE_ICON
- presets/flower-presets/grass-presets: thumbnail ← bundled art (was *Thumbnail())
- index.ts: Nature panel icon ← NATURE_ICON (was lucide:leaf)
- assets.d.ts: ambient *.webp decl (no next type dep)
- remove thumbnails.ts (placeholder generators, now dead)
Verified on the running dev server: webp emitted + served (HTTP 200, image/webp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): square preset cards on light-gray flattened thumbnails
Regenerate the 12 thumbnails with the transparency flattened onto gray-100
(#f3f4f6) and render the card square (aspect-square, was h-16 crop). The
nature-icon keeps its transparency for the icon rail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): correct move-drag + outline behavior for instanced plants
Two selection defects, one root cause each:
1. Outline moved mirror-wise during a move drag. The host move tool drives the
registered Object3D imperatively with absolute level-local positions (and
mirrors them via useLiveTransforms) — the contract ParametricNodeRenderer
satisfies by putting position + registration on the same group. KindProxy
registered a child nested inside the transform-carrying group, so drag
positions landed in the node's rotated frame (placement gives every plant a
random Y rotation → deltas rotated up to 180°). Restructure: the registered
group now carries position/rotation (live-transform aware); the box collider
is a positioned sibling, staying out of the outline mask.
2. Outline froze while the mesh swayed. The outline mask pass renders with a
shared override material, so it can never follow the material positionNode
wind. Instead, while hovered/selected the collective system skips the node
and the proxy mounts the real geometry with static twins of the wind
materials (toStaticMaterial — explicit property transfer; node-material
clone() drops map/color). Outlined mesh == visible mesh, both still.
Bonus from the same restructure: a move drag now animates the actual plant in
realtime (the proxy is what the tool drives), not just the drag box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugin-trees): 2D plan symbols + trunk-sized footprint
- def.floorplan for the three kinds — the registry floor-plan layer renders any
kind that provides one, so this is all plugin nodes need to appear in 2D.
Tree: dashed canopy ring (dashed = overhead element) in the preset swatch +
solid trunk dot; ring is pointer-events:stroke so the large disc doesn't
steal clicks from what's under the canopy. Flower/grass: small colour dots.
Selected → palette stroke + move-handle; hovered → hover stroke.
- Tree floorPlaced.footprint is trunk-sized (treeTrunkRadius) instead of
canopy-sized, so the move/placement drag box hugs where the tree actually
plants instead of spanning the crown. The invisible hover collider keeps its
larger radius — only the displayed box shrinks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate gizmo
- The move drag box ignored floorPlaced.footprint: with collides:false the tool
auto-measures the rendered mesh — which, since the selection swap-out, is the
whole canopy. Declare capabilities.dragBounds (trunk-sized, node-local) so the
box hugs the trunk and rotates with the node instead of wrapping the crown as
a world AABB.
- Placement wrote a fully random Y rotation, so the box/gizmo never sat on a 45°
step. Snap the random rotation to 45° increments (variety preserved, alignment
restored) and widen rotatable.snapAngles to 8×45° on all three kinds.
- Add the standard rotate gizmo (def.handles, shelf-style arc handle): ring
around the trunk near the ground — not the canopy, which would put the handle
meters out on a large oak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): live rotate preview + ground-level rotate ring
- KindProxy now folds useLiveNodeOverrides into its transform (mirrors
ParametricNodeRenderer) — the rotate/resize gizmos publish mid-drag patches
there, so the plant turns in realtime instead of snapping on commit. The
snap-on-commit (with the arc delta wrapping to ±180°) is also what made long
drags land 'the wrong way'; with live feedback the direction reads correctly
and matches the item gizmo pipeline exactly.
- Rotate ring drops from mid-trunk to 0.25m — a floor affordance like the item
gizmo, not a waist-height hoop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): panelForKind — 'find in catalog' opens a plugin kind's panel
loadPlugin now associates every node kind with its plugin's first (namespaced)
panel id; panelRegistry.panelForKind(kind) exposes it. A host's find-node
handler can open the right panel for any plugin kind with zero per-plugin
knowledge.
plugin-trees uses it end-to-end: a module-level selection:find-node listener
(find-sync.ts, imported by the manifest so it's live from plugin load) points
the panel store at the found node's section + preset — panel section state
moved from panel-local useState into the store to make it addressable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): plugin panels declare their workspaces (default edit-only)
Plugin panels rode into the studio rail: the host swaps its own sidebar tabs by
workspaceMode, but usePluginPanels appended registry panels unconditionally.
PluginPanel gains workspaces?: readonly ('edit'|'studio')[] — manifest metadata,
default ['edit'] (an authoring panel has no business in the clean render
workspace; a plugin shipping studio tooling opts in explicitly). usePluginPanels
filters by the current workspaceMode, covering both the v1 AppSidebar and v2 tab
bar paths. Nature declares nothing and disappears from studio via the default;
the v2 layout's existing active-tab fallback handles a panel vanishing mid-use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): keep ez-tree off the eager SSR path
ez-tree loads its inlined textures at module scope (needs document), so any
eager import chain reaching geometry.ts crashed Next prerender
(ReferenceError: document is not defined on /_not-found). Move the pure
helpers (mulberry32, naturalHeight) to variant-utils.ts so the
flower/grass builders and floorplan no longer pull ez-tree, and drop the
generateTree re-export from the package barrel (no external consumers).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
Walk 4→2 m/s, run 5.5→5 m/s, jump 6→5 (~1.27 m peak). Applied to both
the GLB walkthrough controller (viewer) and the editor first-person
controls so the two surfaces stay consistent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polish pass on the placement/interaction overhaul. Five fixes:
- Item-on-item rotation box: the cursor box held the host-local yaw instead of
world yaw, so it diverged from the item by the host's rotation when stacked on
another item (fine on the floor). The box now derives world yaw from the
mesh/host quaternion in both the R/T handler and the move-start sync.
- 2D floorplan move now respects the snapping mode (parity with 3D): grid
quantization only in grid mode, alignment guides only in lines/magnetic mode;
Shift/Alt no longer hard-bypass. Item move also plays the move "tick" SFX on
any resolved-position change, like the 3D move.
- Wall-side item footprint side: the 2D footprint depth offset extended toward the
wall (centerLocalZ -depth/2) instead of into the room, mirroring the item across
the wall; flipped to +depth/2. Aligned the undefined-side anchor to the 3D
convention (front +1 / else -1).
- 3D placement preview side: the wall-side preview bounds + base plane used a -Z
(into-wall) convention; flipped to +Z (into-room) to match the body and the
fixed 2D footprint. The 2D live preview during a 3D wall placement now publishes
the plan rotation (wall angle + item yaw) instead of the world cursor yaw, which
was pi off on a wall face and flipped the footprint to the far side.
- Cmd/Ctrl+R no longer rotates/flips the selected node (it reaches the browser
reload); guard added to the global selected-node handler and the 2D move overlay.
Verified: core/editor/nodes tsc, biome, editor 162/0 + nodes 292/0 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Floorplan group to the settings Export panel alongside the 3D
model exports, with "Full floorplan" and "Structure only" buttons.
Export re-runs the live registry-driven floorplan pipeline
(def.floorplan -> FloorplanGeometryRenderer) headlessly with a neutral
viewState, fits each level to its own page, and titles each page with
the level label. Every level of the active building becomes a page in
one landscape A4 PDF. "Structure only" keeps category === 'structure'
nodes; "Full" keeps every visible node with a floorplan builder.
- new lib/floorplan/floorplan-export.tsx; jsPDF + svg2pdf dynamically
imported so they only load on export
- export five pure helpers from floorplan-registry-layer for reuse
(buildContext, getFloorplanLevelData, floorplanLayerRank,
splitFloorplanOverlay, isFloorplanNodeVisible) — no behaviour change
- bake vector-effect:non-scaling-stroke widths into real units before
svg2pdf (which ignores the hint and would otherwise draw door/window/
stair linework as metre-wide strokes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the lone biome organizeImports error so the quality gate passes on the
post-#448 base. Type-check (9/9), biome, and all suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two zone fixes.
helper-manager: the contextual HUD only rendered for tools with `def.toolHints`,
so zone (none) showed no HUD and no snapping chip even though it resolves a snap
context. Render the generic RegisteredToolHelper whenever the tool has hints OR a
snap/continuation context; hoist the legacy `roof` RoofHelper above it so the new
fallback doesn't capture it. Any snappable hint-less draft tool now advertises
Shift = cycle.
zone-tool: a not-yet-migrated legacy tool — it used Shift as a snap bypass and
applied `gridSnapStep` unconditionally, so Off mode still snapped to grid.
Migrated to the mode-driven exclusive-modes convention (zone resolves to the
'wall' context): grid quantize gated on isGridSnapActive(), 15° ray gated on
isAngleSnapActive(), Off/Lines leave the raw cursor. Dropped the Shift-bypass and
its key listeners — Shift now cycles the mode globally. Recorded as migrated in
the review skill's known-legacy list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the durable patterns from the placement-interaction overhaul so reviews
and new devs don't regress them:
- tools.md: new "Data-driven live drag" section — kinds whose geometry is
recomputed from fields (wall/opening/endpoint) preview via useLiveNodeOverrides
(merged by getEffectiveWall/getEffectiveNode), store written once on commit;
per-tick useScene.updateNodes is the documented anti-pattern (churns the nodes
ref → app-wide re-render flood). Plus "Floorplan registry: per-node
subscriptions" — each entry subscribes to its own live slice, memo'd with
stable props, sibling-epoch invalidation; widening to the whole Map / dropping
memo is a regression. Plus a note that the HUD snapping chip renders for any
snap-context tool, not only those with def.toolHints.
- review SKILL.md: matching blockers in §C (data-driven drag / no per-tick store
write) and §D (per-node list subscriptions).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>