A catalog GLB ships its own animation clips (a ceiling fan's spin), but those
clips aren't in the editor scene graph, so the bake couldn't see them. Add an
`itemClipRegistry` (core, type-only three) that the item renderer fills with the
resolved clip per node while the scene is live; the GLB export reads it and
re-emits each item's clip onto the baked subtree, rebinding tracks to the cloned
spinning node's uuid. Catalog node names repeat across instances and the glTF
roundtrip rebinds by name, so the targeted node is uniquified per item
(`<id>__lamp_018`) — every fan animates independently.
The baked viewer plays these as looping ambient motion: `<id>: loop` clips are
set to LoopRepeat (not the door/window LoopOnce) and GlbItemAnimation drives
them off each item's toggle (lit/spinning by default).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a GLB interactive layer (`GlbInteractive`) that re-creates the item
interactivity the parametric viewer has — point lights and the controls
overlay — on top of a baked artifact. Effects + controls come from the
DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world
transforms come from the baked Object3Ds. Lights are portaled into their
item node so they ride level stacking, and intensity tracks the shared
`useInteractive` store so overlay dimming works. Baked scenes load "lit"
(toggles default on) for a showcase viewer feel.
Extract `ControlWidget` into its own module so the parametric
`InteractiveSystem` and the GLB overlay render identical controls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Items mounted on a ceiling (lamps, fans, recessed lights) are child nodes of
the ceiling, so hiding the whole occluder node hid them too. Hide only the
ceiling/roof's own meshes (stop descending at nested identity nodes) so hosted
items stay visible when a floor is opened up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl +
GlbWalkthroughController) and round out the GLB-consuming viewer:
- Walkthrough: fallback ground only on level 0 (upper floors rely on baked
slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via
pointerlockchange, force perspective on enter / restore on exit.
- GlbScene: monochrome strips baked textures and recolours meshes by surface
role using the active theme's clay tints; spawn node hidden from render.
- glb-export: camera/label/spawn identity extras for the viewer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A door/window only carries extras.openable + extras.clips when an open
animation actually bakes. Cased openings (no leaf) and fixed windows (no
operable sash) are no longer mislabelled openable, so the GLB never claims
a part opens when nothing moves. /viewer is unaffected (it already required
openable && clips). Adds a regression test for the no-clip case.
(Export barrels reordered by the formatter.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverts the LOD switching (0469b256). Rendering lod1/lod2 on zoom-out produced
WebGPU validation errors (invalid bindGroup_object) on real-GPU browsers — not
caught earlier because headless Chromium falls back to the WebGL2 backend, so
the WebGPU path was never exercised. The viewer returns to loading the baked
KTX2 lod0 (still the 22MB->12MB win). LOD switching to revisit with a real-GPU
test loop and on-demand loading instead of preload-all.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlbScene accepts an optional lodUrls list and swaps rendered detail by camera
distance to the building's world-space center: lod0 (interactive — identity,
selection, zones, clips all bind here) for normal + building-view distances,
simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are
unchanged. useGLTFKTX2 now accepts a URL array.
Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance
to its own origin (0,0,0), which mis-selected levels (blanking the scene) when
the baked geometry is offset from the origin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `GlbScene` — the viewer that renders a baked GLB artifact with no
parametric scene graph, and finish the phase-1 export contract it consumes.
Viewer (packages/viewer):
- GlbScene: loads the artifact, drives a building → level → zone → node drill
hierarchy on useViewer.selection. Room/zone picking resolves from the floor
plane (ray ∩ floor + point-in-polygon), node picking uses a footprint test,
so walls/ceilings/zone-helpers never skew or block selection. Level modes
(stacked/exploded/solo), dollhouse (hide a focused floor's ceilings + roof so
rooms are visible and pickable), reconstructed zone floor fills + gradient
edge borders + room labels (faded, hover-brightened), baked door/window
open clips, click-outside / empty-space deselect, and the shared outline
post-FX. Exported as GlbScene/GlbLevel/GlbIdentity/GlbHover.
- post-processing: composite the zone-pass tint into the base scene so rooms
show whether or not SSGI is enabled (previously only added in the SSGI branch).
Export contract (packages/editor/src/lib/glb-export.ts):
- Convert WebGPU NodeMaterials to classic glTF-standard materials; decompress
KTX2 maps via WebGPUTextureUtils so GLTFExporter can embed them.
- Stamp name + extras identity (pascalId/kind/label/openable/clips), bake
door/window open clips (loop:false), strip editor overlays (off-layer +
invisible-material hitboxes) so cutouts aren't plugged.
- Fix opaque-but-flagged-transparent materials (no spurious alphaMode=BLEND)
and BackSide → FrontSide + winding flip (glTF has no back-face-only).
- Force levels + zones visible and stamp level display names + zone polygons so
every floor bakes and /viewer can reconstruct rooms and label the breadcrumb.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the client GLB export into the baked-artifact format from
plans/editor-baked-glb-export.md (phases 0 and 1).
- NodeMaterial -> classic MeshStandardMaterial conversion at export. The
viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not
isMeshStandardMaterial, so GLTFExporter would otherwise drop every
surface to a blank default. KTX2 (compressed) maps are decompressed via
WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2
re-encode is deferred to the phase-3 bake worker).
- Identity stamping from sceneRegistry: node.name = pascalId and
extras = { pascalId, kind, label?, openable?, clips? }; all other
userData stripped so editor/runtime ephemera never reach glTF extras.
- Door/window open clips baked from the build-once + pose-at-t primitives
(door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named
by label ("Door 1: open"), carry extras.loop = false (consumers play
once and hold; dumb glTF players still loop).
- Cutout fix: door/window selection hitboxes hide via material.visible,
which onlyVisible misses, so the hitbox box plugged the wall opening.
Non-renderable container meshes now keep their node but lose geometry;
childless ones are removed.
- Editor-overlay stripping mirrors the thumbnail capture: emit
thumbnail:before/after-capture so scene-layer affordances (handles,
ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER
(gizmos, grid, zone fills).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a textured wall/slab/item replaced its surface with a flat
purple wash. Root cause: the selection highlight clones the material and
tints it, but `NodeMaterial.clone()` on the WebGPU backend drops the
texture-map node assignments, so the clone rendered flat — and a strong
albedo blend + emissive washed whatever was left.
Fix: re-attach the maps from the source material after cloning (shared by
reference) and drop the albedo tint, keeping only a gentle indigo emissive
so the real material/texture stays readable with a soft "selected" glow.
Applied to both highlight paths:
- generic editor highlight (slabs/items) in selection-manager
- wall path (walls are excluded from the generic one), built lazily +
cached/self-healing so it survives the wall finish's async texture load
Removes the now-dead eager wall `highlightedVisible`/`selection` profile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `horizontal` fence style that builds composite-cladding panels —
horizontal boards stacked between square intermediate posts — instead of
the vertical pickets that slat/rail/privacy all produce today.
- New `style: 'horizontal'` (core schema + inspector Style control).
- Posts march along the whole span at `postSpacing`, each with a cap.
- New `postCap` control (none/flat/pyramid, default pyramid); pyramid
caps render as a 4-sided cone fence part.
- New `slatGap` control for the board reveal — 0 collapses to one flush
panel so the stacked-board seams don't read as lines.
- `postCap` + `slatGap` are horizontal-only (visibleIf); postCap uses a
labelled dropdown so its options aren't a context-free switch.
- Lower the `postSpacing` inspector minimum from 0.2 to 0.05 m.
- 2D floor plan reuses the solid-panel marker for horizontal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire the inline material fields on every slot-model kind, moving them
onto the unified node.slots model on load so painting, edit-propagation,
and the picker behave uniformly.
Walls: slots {interior,exterior}; slot-first viewer resolution threading
sceneMaterials (content folded into the wall material hash); WallRenderer
subscribes to the scene-material palette so a scene-material edit
re-renders live; wallPaint rebuilt on createSlotPaintCapability.
Load migration generalizes legacy -> slots across slab/ceiling (surface),
fence (posts/infill/base/rail), column (shaft/base/capital/frame), shelf
(shelves/frame/back), and stair (per-role tread/side/railing). Library/
scene refs pass through; inline customs mint a deduped scene material;
legacy fields cleared. No visual change (renderers already fell back to
the legacy fields). Roof/chimney/dormer/vents intentionally stay on their
role system.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier gable fix world-referenced only V, leaving U in segment-local
space — so gable and wall lined up vertically but slid horizontally. Replace the
V-only scalar offset with the segment's full world matrix (roof group composed
with segment transform) and project vertical faces exactly like the wall kind:
U = ±worldX/Z, V = 1 - worldY. Gable now tiles continuously into the walls in
both axes. Sloped shingle UVs are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Walls used THREE's ExtrudeGeometry UVs, which restart at each wall's local
start/end — so finishes seamed at wall joins, showed a mid-face axis-switch
stripe, and didn't line up with the roof gable. Re-project the render mesh's
UVs in WORLD space (1 unit = 1 m), matching roof-system's pushRoofUv exactly:
vertical faces U = ±worldX/Z, V = 1 - worldY. De-indexes so each triangle uses
its own face normal (no edge seams). Applied only to the render mesh; collision
and floorplan geometry keep the original UVs. Now walls tile continuously
across segments and meet the gable seamlessly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gable wall band tiled its V in segment-local space, so its texture phase
didn't line up with the wall below (whose ExtrudeGeometry UVs map V = 1 -
height-from-base). Make vertical roof faces tile V in world space
(V = 1 - worldY) via the segment's resolved world Y (parent roof + segment
position), so the band matches a ground-floor wall's vertical tiling at the
eave seam. U is unchanged (stays in the face's local run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The posts/infill and the accent rail shared the same panelDepth*0.35 depth, so
their faces were coplanar where they cross -> z-fighting. Make the verticals
0.001m shy so the rail wins the depth test cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mergeGeometries throws on an empty array, so an absent slot group (infill with
showInfill off, base on a floating fence) crashed the geometry build. Return an
empty BufferGeometry for an empty group; the renderer already skips meshes with
no position attribute.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add 'concrete-drywall' (Prepared Drywall) catalog finish + 512 KTX2/webp
runtime assets; default walls (WALL_SLOT_DEFAULT interior+exterior) and the
roof wall/trim band to it so roofs read continuous with walls.
- fence: replace the 2-slot panel/rail split with 4 slots that match the panel's
build options — posts / infill (showInfill) / base (grounded only) / rail —
each its own mesh with userData.slotId; conditional slots track the build
state. Defaults: posts/infill/base charcoal, rail wood-finewood27.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fence: split into two paint slots — panel (posts/base/infill, default charcoal)
and rail (cap, default wood-finewood27) — as separate meshes with userData.slotId.
Fix applyFenceUVs to continuous world-space 1 UV unit = 1 m (drop the per-part
min origin that broke tiling across parts). New generateFenceSlotGeometries.
- roof: real catalog defaults for the segment surfaces via getRoofMaterialArray
(the actual default path): wall/trim concrete-plate (matches walls), deck +
soffit soft-white, shingle terracotta; textures-off role escape hatch kept.
Align nodes getRoofMaterials no-parent fallback to match.
- stair: body slot default -> preset-lightgrey.
- (biome formatting normalization of the round-1 merged renderer files rides along.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the editor-only EditorEnvironment wrapper with a SceneEnvironment
component exported from @pascal-app/viewer, mounted as an opt-in <Viewer>
child (still not baked into the Viewer component). One source of truth the
editor and the community public viewer both inject; embed/thumbnail
surfaces simply don't mount it. Sunset preset at environmentIntensity 0.6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):
- Per-part painting: door = panel/frame/glass/hardware, window = frame/glass,
each independently paintable. The recessed door/window body sits behind the
wall, so the proud invisible cutout wins the scene raycast over the wall and
the shared resolveSlotByReRaycast() re-raycasts the kind's own subtree to pick
the exact part under the cursor (panel↔frame↔glass↔hardware). Hover tracks the
cursor via a re-eval (idempotent, no flicker).
- Door frame is its own slot (separate frameMaterial); hardware = new flat
'metal-chrome'.
- Library defaults (generic): panel/frame -> library:preset-softwhite, glass ->
library:preset-glass (flipped preset-glass to FrontSide — DoubleSide poisons
the WebGPU MRT pass; it's the only glass we use).
- Catalog: add flat (non-PBR) 'metal-chrome' + 'metal-brass'; drop metal
metalness 1 -> 0.6 so metals are lit by existing lights (no env needed).
- World-scale UVs (1 unit = 1m) on door/window box meshes via shared box-uv.ts,
so finishes tile at real-world scale instead of stretching.
- PaintResolveArgs gains an optional for subtree re-raycasting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a host mount the editor's real editing experience on a bare <Viewer>
without the full <Editor> shell.
editor: export Grid, NodeArrowHandles, MoveTool, ToolManager — the selection
handles, the kind-owned mover, the build-tool host, and the drafting grid that
feeds tools their grid:* pointer events. See the doc comment in index.tsx for
how they cooperate with host camera controls and selection.
viewer: two opt-in, non-persisted presentation flags (both default off, so the
editor and every other consumer are unchanged):
- transparentBackground / <Viewer transparent>: emit premultiplied RGBA masked
by geometry + outline alpha (outputColorTransform off on that path) so the
scene can float on any page background. ACES tone-mapping makes a true-white
opaque background impossible, hence transparency.
- inkOpacity: override the per-mode ink-edge opacity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real interceptor was the 'cutout' mesh, not the hitbox: it's a 1m-deep CSG
helper whose front face sits 0.5m proud of the glass, and current three.js
raycasts invisible meshes — so every paint/hover ray resolved to 'cutout'
(no slotId → role null). Disable its raycast; combined with the hitbox noop,
paint/hover rays now land on the tagged frame/glass (panel/glass) children.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- post-processing: hoverHighlightMode was a dependency of the pipeline-build
effect, so every hover rebuilt the entire pipeline. The hover style is already
pushed to uniforms in a separate effect, so the rebuild was pure waste —
removed it from the deps (and the build log).
- selection-manager: temporary [paint-debug] logs for window/door hover to trace
why their paint dispatch drops (to be removed once diagnosed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The window/door root is an invisible hitbox the system gives full-depth
BoxGeometry; its front face intercepted every paint/hover ray, so the hit
resolved to the hitbox (no slotId) → role null → paint silently disabled.
Disable the hitbox's own raycast in the visual path so R3F's recursive
intersect returns the tagged frame/glass (panel/glass) children instead;
selection still works because those child hits bubble to the root's event
handlers. Restored to the default raycast each build, and kept for 'opening'
windows/doors (no visuals to paint, still need a selectable hitbox). The
'cutout' child is visible=false so the raycaster already skips it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows and doors build all visuals in their viewer systems from module-global
materials, so this threads per-node slot materials + userData.slotId tags
through those builders without restructuring them:
- window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) +
'glass'; the opening reveal keeps its own material.
- Each system captures per-frame viewer state, then updateWindow/DoorMesh points
the builder-facing base/glass materials at the node's resolved slot override
(recomputed per node, so the next node resets without a restore). Meshes are
auto-tagged in the shared addBox/addShape helpers by which material they got.
- Textures-off still collapses to the role material (escape hatch); a slot
override only applies in colored mode.
- Editing a referenced scene material re-dirties the window/door (these systems
aren't covered by GeometrySystem's scene-material re-dirty).
- New paint capabilities (resolve role from userData.slotId, preview by
userData.slotId) + capabilities.slots; window/door dropped from the paint
disabled list. Shared previewSlotByUserData helper.
Defaults unchanged: unpainted windows/doors render exactly as before (the slot
fallback is the existing frame/glass material), so no visual regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings slab, ceiling, and wall onto the unified slot contract the shelf
established, so each declares its paintable slots with a declarative default and
(slab/ceiling) is painted through the registry capabilities.paint dispatch.
- Shared helper packages/nodes/src/shared/slot-paint.ts: a node.slots-based
PaintCapability factory (commit/resolve/effective generic; preview injected).
Distinct from surface-paint.ts, which writes the legacy inline node.material.
- slab: schema slots; def.geometry resolves node.slots.surface -> legacy
material -> declared default, tags the mesh userData.slotId; slabPaint +
capabilities.slots. Retires DEFAULT_SLAB_MATERIAL in the slab path.
- ceiling: schema slots; material builders extracted to ceiling/materials.ts
(shared by renderer + paint preview, built BackSide so the hover preview is
visible from below); renderer resolves the slot; ceilingPaint + slots.
- wall: WALL_SLOT_DEFAULT in core; the viewer's getMaterialsForWall renders an
unpainted face with its declared default instead of the themed wall role;
capabilities.slots (interior/exterior). wallPaint's inline interior/exterior
fields are unchanged (node.slots migration is a later step).
- selection-manager + material-paint: drop slab/ceiling from the legacy
single-surface arms (now registry-driven).
Behavior change (intended, matches the shelf precedent + the phase-5 plan):
colored-mode UNPAINTED slab/ceiling/wall surfaces now render their fixed slot
default (#e5e5e5 / #f5f5dc / #ffffff) instead of the theme role colour. The
textures-off (monochrome) role collapse is unchanged — the escape hatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- core: decouple drag-follow from distributionRole — add
portConnectivityFollow flag to NodeDefinition; pipe-trap opts out
(portConnectivityFollow: false) so dragging a connected pipe endpoint
stretches the trap arm instead of yanking the anchored trap fixture
- core: remove the module-level getLevelHeight cache entirely — it was
keyed only by nodes-object identity, which could return stale heights
for in-place mutations by pure/headless callers. The function is now
fully pure and deterministic; viewer hot path recomputes per frame as
before (the cache only ever skipped the resolver-free branch)
- test: harden port-connectivity-pipe.test.ts — real DuctSegmentNode
cross-family isolation case (was waste-vs-vent), new pipe-trap anchor
case (run drag doesn't move trap; trap drag still stretches run), and
beforeEach/afterEach registry reset instead of leaky beforeAll
- nit: biome format/import-order on all touched files
- core: fix layer violation in level-height.ts — extract sceneRegistry
import, replace with optional WallBaseYResolver callback so core stays
pure (no Three.js mesh state); viewer callers pass resolver, headless
callers (MCP/tests) get deterministic node-data-only result
- core: generalise port-connectivity service from duct-only to all
distribution families — match partners by distributionRole ('run' →
endpoint stretch, 'fitting' → rigid follow) instead of hard-coded
duct-segment/duct-fitting type names; add system-compat guard so
cross-system ports (e.g. supply duct vs waste pipe) don't fuse
- editor: fix port-snap rotation bug in move tool — pass preview node
at live rotation into resolvePortSnap so own-port positions reflect
any mid-drag R/T rotation before computing the snap delta
- editor: wire pipe-trap into UI — add to StructureTool union,
MepToolKind, MEP_ITEMS Build-tab tile, and structure-tools action menu
- test: add port-connectivity-pipe.test.ts — 2 tests covering
pipe-fitting → pipe-segment endpoint drag and cross-system isolation
- test: fix stale pipe-auto-fitting.test.ts wye expectation — author
deliberately chose square sanitary-tee for DWV side-taps (documented
in PR description and PipeFittingNode schema); update the one test
that still expected wye to match the implemented behaviour
- nit: fix optional-chain biome warning in validate-dwv.ts
Wire KTX2 into the catalog finish path and add 15 textured finishes across four
new families, all as GPU-compressed KTX2 (512px) + a webp picker thumbnail.
- Shared `ktx2-loader.ts`: one KTX2Loader (Basis transcoder) used by both the
GLB loader and catalog textures; `ensureKtx2Support(renderer)` runs once at
viewer init (GPUDeviceWatcher) so `.ktx2` finishes load even with no GLB in
the scene. `use-gltf-ktx2` now reuses the shared instance.
- `materials.ts`: texture loaders pick the KTX2 loader for `.ktx2` urls, the
image loader otherwise (all three load paths).
- `material-library.ts`: 15 entries (fabric ×6, leather ×2, concrete ×4,
metal ×3). Neutral albedo for tinting, `flipY: false` (compressed textures
can't flip), `repeat` per real-world tile size, metals `metalness: 1`.
Normals encoded UASTC, data maps ETC1S/linear.
Assets generated from raw sources via the new community
`scripts/build-material-textures.ts`. 1024/256 tiers + raws kept out of git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proves the unified (nodeId, slotId) slot contract on a procedural generator,
beyond items and walls. A shelf now exposes three paintable slots — shelves /
frame / back — painted through the same PaintCapability dispatch and the same
node.slots: Record<slotId, MaterialRef> shape items use.
Foundation (shared, reusable by future procedural kinds):
- core: SlotDeclaration type + capabilities.slots(node) registry declaration;
GeometryContext gains `materials` so a pure builder can resolve scene:<id>
slot refs without importing useScene.
- viewer GeometrySystem: threads the scene material library into every builder
ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that
references a scene material when that material changes — so editing a custom
colour propagates to every shelf using it, matching items.
Shelf:
- schema: slots: Record<string, MaterialRef> (mirrors ItemNode).
- geometry: per-slot material resolution (slot override -> legacy whole-shelf
-> declared default colour); every mesh stamped with userData.slotId;
DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white).
- paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material
commit for one-off colours, preview restricted to __fromGeometry meshes so
hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback).
- definition: paint + slots capabilities; slots folded into geometryKey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pool slabs (generatePoolGeometry) were the one procedural surface emitting
normalized [0..1] floor UVs, so a textured finish stretched to fit the pool
instead of tiling at real-world scale like every other surface. Switch the
floor to shape-space metres (x, -z) — the same mapping generatePositiveSlab-
Geometry already uses for its caps. Pool walls were already in metres.
Also document the contract in wiki/architecture/materials-and-themes.md: every
procedural surface generates UVs in metres (1 UV unit = 1 m), GLB slots follow
the same ~1 unit/m authoring convention, and a catalog material's `repeat` is
therefore a per-material world-scale setting (tiles per metre), identical for
every surface that uses it — never per-item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#407 ("Always-visible placement ghosts + true-nearest 2D opening snap")
restructured the door/window placement tools: it split the old
create-in-resolve into a pure resolveWallPlacement() + side-effecting
applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt),
unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall.
Conflict resolution (door/tool.tsx, window/tool.tsx):
- Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget
(after the draft update + updateCursor), using that scope (wall,
getSlabElevationForWall(wall)); door includeVertical:false, window true.
- Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback
path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown.
- Made the window sill snap (resolvePlacementY) event-free and call it from the
pure resolveWallPlacement, so hover + click both get sill/centre/top snapping;
Shift bypasses, the moving draft is excluded via ignoreId.
- Dropped the branch's inline onWallClick in favour of #407's onWallClick +
commitWindowAtWall (no behavior lost).
- Reconstructed both files' import blocks, which the auto-merge had truncated to
stubs (only tsc caught it).
All other conflicts auto-merged (registry types, floorplan-registry-layer,
both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594
tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex
(adversarial): no semantic regressions; all #407 behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): always-visible translucent placement ghost for openings + roof accessories
When a host-surface placement tool is armed, the node's real geometry now
follows the cursor everywhere as a translucent ghost: tinted invalid (red)
and unconfirmable off-host, snapping onto its host surface (wall/roof) with
the existing valid/invalid affordances when near. This replaces the old red
wireframe box (door/window) and red DragBoundingBox (roof accessories), so
the armed tool is visible before the cursor reaches a placeable surface.
- New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts):
clones materials, disables raycast (avoids cursor-ray starvation),
tints invalid; cleanup disposes only the clones.
- New door/window preview components built from the real geometry via new
`buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float
the ghost via a `fallbackPose` that is mutually exclusive with the on-host
draft + wireframe outline.
- `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory
tools pass their real preview (invalid-tinted) instead of a box `size`.
Snapping behavior is unchanged (no proximity snap yet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): magnetic proximity wall-snap for door/window placement
The door/window ghost now follows the cursor over the floor like a moving
item and magnetically snaps onto the nearest wall within range (1.5 m), then
releases back to free-follow when the cursor moves away — instead of only
attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it
snaps onto a new spot, so it reads as moving a physical object that can only
land on walls.
- Plan-space proximity via the existing `findClosestWallInPlan` (the same
helper the 2D floor-plan move uses): level-scoped, skips curved walls,
returns wall + along-wall localX + side + wall-local rotation.
- `grid:move` drives the snap and `grid:click` commits when proximity-snapped;
a direct wall-mesh hover (wall:enter/move) still owns the precise face side.
Both paths share `applyWallTarget` (create the draft once, reparent only on
an actual wall change) and a shared commit that refreshes alignment anchors.
- Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate
(R3F + the grid raycast share the source DOM event) plus a `cameraDragging`
guard and stale-`hostKind` reset, so a missed wall:leave during a camera
orbit can't strand the draft.
- Window keeps its sill height on the floor path (the floor cursor carries no
wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move.
- Shift bypasses the along-wall grid/alignment snap but still attaches to the
nearest wall, matching the 3D-hover and 2D-move conventions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): door/window preset placement follows the cursor over open floor
The community preset/catalog flow places doors and windows through the
isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow:
the fresh clone was parented to the level at the origin and only became
visible once the cursor reached a wall, so over empty floor nothing tracked
the cursor. Now the move tools mirror the def.tool placement behaviour:
- Off-wall, the real node rides the cursor like an item (reparented to the
level, positioned at the building-local cursor) so it's obvious what's
being placed before it attaches.
- Within range of a wall it magnetically snaps on via findClosestWallInPlan
(the same plan-space helper the 2D move uses), releasing back to free-follow
when the cursor moves away, and plays the grid-snap sound on each new snap.
- grid:click commits only when snapped (open floor is a no-op — a door/window
needs a wall); the wall/roof mesh-hover paths are unchanged and still own
their own click. A per-pointermove timeStamp gate + cameraDragging guard
keep the floor handler from fighting a wall/roof hover.
- Windows default to a ~0.9m sill while off-wall (fresh preset clones carry
position [0,0,0], which buried half the window below the floor).
The wall/roof commit body is extracted into a shared commitToWall so the
mesh-click and proximity-click paths stay identical. Existing-node moves are
fully restored on cancel/unmount (the node stays isTransient through
free-follow). Standalone-editor def.tool placement already had this in a
prior commit; this brings the community move path to parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): 2D floorplan ghost follows the cursor for door/window placement
Mirrors the 3D free-follow in the top-down floor plan: while placing a door
or window, a loose footprint rectangle now follows the cursor over open floor
so it's obvious what's being placed before it snaps to a wall. The instant the
cursor nears a wall, the existing synthesized wall:enter/move path takes over
and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost.
- The opening-placement pointer-move handler in floorplan-panel sets a new
`openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and
clears it on a wall hit; a loose width × 0.1m rectangle renders at that point
inside the floor-plan scene group (same world→SVG transform as every glyph).
- Width comes from the moving node or the kind default (door 0.9 / window 1.5).
- The ghost clears when opening placement ends (tool/mode change, cancel,
commit) and on level change, so no stale rectangle lingers.
Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's
no host to orient the swing to. The shared door/window def.floorplan builders
are untouched — overloading them with a wall-less fallback would make
roof-hosted doors (parent is a roof segment, builder returns null today) draw
stray rectangles in plan. Keeping the preview in the editor layer avoids that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover
Three placement fixes plus a snapping revision, for both the standalone
def.tool path and the community isNew move path (door + window):
- 2D faithful ghost: the off-wall placement ghost now renders the real
blueprint symbol (door swing arc / window panes) following the cursor, not a
bare rectangle. Done by publishing a transient opening on a synthetic wall to
usePlacementPreview (extended with a `parentNode` fed as the builder's
ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit,
on commit, on placement-inactive, and on level change.
- 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms
for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide
updated the 3D mesh but left the 2D symbol frozen. It now merges the
wall-local live position/rotation onto the node (keeping parentId) so the 2D
symbol slides with the cursor.
- R-flip during placement: pressing R now flips a door/window's facing
(front ↔ back, rotation += π) before commit — the placement tools own R while
placing (the global selection-based R/T handler stands down via
isPlacingOpening so it can't double-fire). No-op on roof faces (front-only).
- 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the
opening free-follows the cursor over open floor and snaps only when the cursor
ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its
0.5 m findClosestWallPoint padding since plan walls are thin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement
Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths
running at once: the floorplan-panel synthesized wall:*/grid:* events (driving
the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget.
They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while
3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner
(faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo
commit), so it now owns 2D opening placement when movingNode is set:
- floorplan-panel: the opening pointer-move branch + the registry grid catch-all
+ the background-click catch-all all now exclude the door/window MOVE case
(`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they
still drive pure raw-build placement, which has no movingNode). Without the
catch-all exclusions the move case fell through to grid:move/grid:click, which
re-drove the 3D tool's free-follow and consumed the commit click.
- R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`;
door/window floorplan-move implement it (XOR the wall-derived side + π rotation,
re-running the last apply). The overlay's keydown calls `session.flipSide()` on
R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the
active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double
flip / double cue on one R press).
- Commit in 2D now flows solely through the overlay's pointerup (no competing
synthesized wall:click), so click-to-place commits.
The global use-keyboard R/T already stands down during opening placement
(isPlacingOpening), so a selected node can't also flip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall
Moving an existing door/window in the 2D floor plan: the move target's `apply`
early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on
its old wall instead of following the cursor between walls (3D free-follows),
and an off-wall confirm click committed the stale last-wall position — looking
like the placement failed.
Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move:
- Off-wall, `apply` free-follows the cursor — hides the real node and floats the
faithful door/window symbol at the cursor via a synthetic wall published to
`usePlacementPreview` (the same preview layer fresh placement uses). The real
node is `visible:false` so the registry layer skips it (no double symbol).
- Back on a wall, it clears the ghost, reveals the real node, and snaps as before.
- `canCommit` returns false while off-wall, so an open-floor click reverts to the
pre-move snapshot (door returns to its wall) instead of committing in mid-air —
matching the 3D move, where clicking open floor commits nothing. On a wall the
commit lands normally.
The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s
`visible:true` restore both guarantee the node is never left hidden after a move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius
The 2D door/window snap felt too aggressive and could grab a wall further away
than the one the cursor was actually nearest. Root cause in
`findClosestWallInPlan`: it compared a candidate's true segment distance against
the previous best's `perpDistance` (signed offset to the wall's infinite line,
not the clamped segment distance). Near a wall end those diverge, so a closer
wall could be rejected / a farther one kept.
- Track the best segment distance and keep the strict minimum — the wall chosen
is now always the single closest segment to the cursor (true nearest), which
resolves correctly when many walls sit close together.
- Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old
radius snapped from far away. The opening now free-follows the cursor until
it's genuinely near a wall.
Only the 2D move/placement targets use this helper (3D snaps on raycast hover);
wall-attached items share the same improved nearest-wall behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(editor): true-nearest 2D opening snap with dev hit-area overlay
Round 7 of the placement-ghosts work. Make the 2D door/window wall snap
always pick the wall nearest the cursor, fix fresh-placement snapping/slide
in 2D, and add a dev-only overlay that visualises each wall's snap region.
- Extract the plan-space nearest-wall-segment math to core
(`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment /
nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan`
delegates to it, so the snap and the debug overlay share one source of
truth. WallHit contract unchanged.
- door/window 2D move now resolves the host level via the shared
`getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement
parented straight to the level — the last case previously resolved to the
building, so a new opening never snapped in 2D).
- Cursor resolver switched to absolute mode: query the snap with the true
cursor, not the original-wall position + grab delta, so it picks the
cursor-nearest wall (matching the 3D move) instead of a far wall across a
thin gap.
- 2D move clears any stale `useLiveTransforms` entry for the node each apply:
the registry layer renders door/window from the live transform in
preference to the scene node, so a leftover entry from the 3D tool froze
the 2D slide for fresh / re-armed openings.
- Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor
at y=0), matching the 3D MoveWindowTool.
- New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws
each wall's snap hit area as an analytic capsule (no grid sampling), gated
on a developer-menu toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Doors and windows could only be moved via the floating action menu — their
3D handle rig declared width/height resize arrows but no move grip, and
Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make
direct-drag work for every bespoke-mover kind.
- door/window: add a `tap-action` `move-cross` handle (plane node-normal,
portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It
routes through the existing per-kind move tool (3D `affordanceTools.move`,
2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall —
so the grip, the floating Move button, and the 2D plan's move dot share one
pipeline. Grab-drag-release commits without a second click.
- canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move`
(the 3D-mountable move paths) instead of `movable` only, so doors/windows/
walls/slabs/stairs/… are draggable in 3D as they already are in 2D.
Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The
floating helper auto-syncs (it reads canDirectMoveNode).
- TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross
[π/2,0,0] into the wall face — previously ignored, so the item wall grip
rendered flat too. Now door/window/wall-item crosses lie in the wall.
- use-node-events: split the drag-suppression gate. `inputDragging` still
suppresses SELECTION events (the synthesized release-click would re-select),
but no longer suppresses SPATIAL events (enter/move/leave) — a
surface-following move tool runs with `inputDragging` set and needs
wall:move to track the cursor. General consumers that must ignore drags
(viewer hover, box-select) already self-gate on `inputDragging`; the
editor's select-hover and paint-preview enter handlers now gate on it too.
- handle-arrow: make handle hit areas inert while `placementDragMode` is set,
so a move grip riding the dragged node can't intercept the ray and starve
the move tool's surface raycast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: sync bun.lock with 0.9.1 workspace versions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(viewer): stop interactive overlays from starving frames and flickering the UI
Every interactive item mounted a drei <Html occlude> overlay
unconditionally — invisible (opacity 0) when no zone was selected, but
still alive. With `occlude` as a bare boolean, drei raycasts the entire
scene per overlay on every camera-move frame, and rewrites each
element's z-index while toggling display when the occlusion flips. On
scenes with hundreds of interactive items (recessed lights, ceiling
fans) this starved the frame budget and made the whole DOM UI blink
during camera moves while the WebGPU canvas stayed healthy.
Overlays now mount only while a zone is selected and the item sits
inside its polygon, fade in/out over 300ms (the child components stay
rendered so the exit transition can play before the <Html> unmounts),
and drop `occlude` entirely. eps=-1 works around a drei mount bug: its
mount path writes the element transform without the distanceFactor
scale, and with a static camera the eps guard never re-applies it, so
freshly mounted overlays stayed mis-scaled until the camera moved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(viewer): break down dirty nodes by kind in the perf overlay
DIRTY now reads e.g. "29 (12 wall, 9 ceiling, 8 item)" — sorted by
count, only non-zero kinds, with a "missing" bucket for dirty ids whose
node no longer exists. Makes dirty-mark leaks attributable at a glance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(core): skip dirty marks for kinds with no dirty consumer
dirtyNodes is consumed by GeometrySystem (def.geometry),
FloorElevationSystem (capabilities.floorPlaced), and the legacy
per-kind viewer systems. Site, building, level, zone, and guide match
none of those, so their marks were never cleared: they accumulated for
the whole session (every child create/delete dirties its parent),
permanently defeated every consumer's empty-set early exit each frame,
and polluted the perf overlay's DIRTY readout.
NodeDefinition gains an explicit dirtyTracking?: boolean opt-out
(default tracked — no derivable predicate exists since wall's dirty
consumption lives in the viewer while zone/guide/level declare
def.system for unrelated per-frame work). markDirty consults the
registry; the five structural kinds opt out.
Also fixes a second leak: deleteNodesAction never removed deleted ids
from the dirty set, and every consumer skips missing nodes without
clearing them, so marks on deleted nodes lived forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: replace concise-arrow forEach with for...of in deleteNodesAction
biome's useIterableCallbackReturn rejects forEach callbacks that
implicitly return a value (Set.add / clearDirty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:
- fix: clone-scene-graph remaps roofSegmentId like wallId in both
clone paths — duplicated scenes/levels kept pointing roof-hosted
children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
four door/window tools into shared/roof-wall-opening-placement.ts
(resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
worldToSelectedBuildingLocal); tools keep the stateful lifecycle
(drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
markWallDirty→markHostDirty (they hold segment ids too); capability
cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
was the standalone Build tab, which now enumerates the registry
inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
"segment-local" comment fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.
- Children store roofFace + position [u, v, z-from-mid-plane] with
rotation 0 in-frame — the exact wall-child conventions (the wall
volume's mid-plane lands on the nominal footprint). A shared
<RoofFaceHostFrame> derives segment pose + face frame from the
live-override-merged segment: children follow resize handle drags in
real time and never jump on commit. No re-anchor cascade needed —
position is authoritative, the frame is derived. migrateNodes
converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
coordinator (surface 'roof-wall'), Shift free-place normalized with
walls, ItemSystem wall-side push extended to segment hosts, correct
2D plan glyphs via face→segment→roof pose composition. The roof
hit resolver + overlap guard moved to @pascal-app/editor (the
coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
merged-shell and per-segment CSG paths (full edit mode / painted
segments used to lose every hole), built from the CURRENT host
geometry and live-effective children so holes follow segment and
opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
into the portal frame instead of composing parent+node registry
poses — correct for any nesting (the face-frame group broke the
old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
re-anchor clears and every revert restores them.
Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.
- core: roof-segment-walls.ts models the four vertical faces as 2D
frames (u along face, v height) with convex profile polygons that
mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
subtracts from the wall brush only. cascadesViaHostSegment keeps the
roof-merge loop from consuming door/window dirty marks (their own
systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
preset path), with roofSegmentId cleared/restored across every
roof<->wall re-anchor and revert; shared hit resolver normalizes
normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
mode — the uncut rebuild used to draw over the merged shell's fresh
opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
visibility; stale uncut segment CSG inside the hidden segments-wrapper
blocked the player at openings the merged shell had cut through.
Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v0.9.0 GitHub release declared the trio at 0.9.0 but the npm publish
never ran (npm still serves 0.8.0). Aligning manifests so the release
workflow's bump=patch lands everything at the announced-next 0.9.1
(mcp 0.3.0 -> 0.3.1, ifc-converter 0.1.0 -> 0.1.1 keep their own lines,
matching the per-package version table style of previous releases).
Inter-package peer/dev refs move to ^0.9.0 so the workspace stays
self-linked during the release run; the workflow re-syncs them to the
final versions after bumping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Allow template forks to preserve scans
* Add room preset save trigger
* Show room preset action for selected zones
* Use standard inspector for selected zones
* Support room preset editor workflows
* Preview room clear-underneath items