Two fixes for the split-view FPS cliff when moving a wall or opening:
- wall/move-tool: publish the live preview to useLiveNodeOverrides instead of
writing useScene.updateNodes every frame. The store's `nodes` ref no longer
churns per frame (which had re-rendered every useScene(s => s.nodes) subscriber
app-wide). Matches the existing 2D wall drag + 3D wall-system override pattern;
the final plan still commits atomically as one undoable change.
- floorplan-registry-layer: replace the single global siblingEpoch with a
per-node epoch bumped only for the nodes affected by the live drag (dragged
wall -> walls at its old + new junctions + child openings; door/window -> host
wall; gutter -> roof-peer gutters), unioned with the previous frame's live set
so a cancelled drag reverts. Dragging one wall/opening now rebuilds a handful
of geometries instead of all the level's walls + openings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate `curvingWall`, `curvingFence`, `movingWallEndpoint`, `movingFenceEndpoint`
(and their setters) off `useEditor` onto the authoritative interaction scope.
The `reshaping` scope variant gains an `endpoint` discriminator; existence
checks read `useIsCurveReshape()` / `useEndpointReshape()`, and the few sites
that need the node (affordance-tool mounts, wall-vs-fence type checks) read it
from `useReshapingNode()` — a frozen drag-start snapshot, mirroring the old
flags so the tools' own per-frame writes don't feed back. `MovingWallEndpoint`
/ `MovingFenceEndpoint` move to the kind-owned tools that consume them.
`editor-api` is simpler: endpoint engagement is kind-agnostic, and the
`engageMove` reshape clears are gone (the scope is single-owner).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the two pure-mirror interaction flags off `useEditor` onto the
authoritative `useInteractionScope`. Both carried payloads byte-identical to
the scope union, so this is a zero-behaviour-change refactor: readers use the
reference-stable `useEditingHole()` / `useActiveHandleDrag()` hooks (or the
`getEditingHole()` imperative read), and producers drive the scope directly
with guarded `endIf` so clearing one interaction never stomps an unrelated
scope. Adds the `holeEditScope` builder + a no-leaked-flag invariant test.
Closes the first slice of the legacy-flag deletion; the rich-payload flags
(movingNode, curving*, *Endpoint, placementDragMode) remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.
- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
driven from central useEditor setters; overlay scoping (zone labels,
context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
rig hides during a whole-node move; rotate gizmo advertises Shift=free
rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
geometry cache so only changed nodes rebuild on a drag; hoist wall miters
to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).
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>
Duplicating an item drops to the catalog placement flow, which rebuilds the
draft from the asset + transform and never carried node.slots — so the copy
lost every painted slot override. Thread slots through the draft create path:
useDraftNode.create seeds it onto the draft and commit() forwards it to the
final node, the placement coordinator passes it to its lazy wall/ceiling
draft creates, and the item move tool supplies node.slots for both the floor
(direct create) and wall/ceiling (coordinator) duplicate paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slabs now expose two paintable faces instead of one: `surface` (top, keeps
the wood floor default and the prior slot id so painted slabs are
unaffected) and `side` (vertical walls + underside, default light grey).
The merged slab buffer is split by per-triangle face normal into a top mesh
and a side mesh, each tagged with its slot id for paint resolve/preview;
legacy whole-slab material maps onto the top only. Textures-off still
collapses to the floor role.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Custom-create now pre-creates a scene material and opens its inline editor
in the build pane (no separate right-side PaintPanel, which is removed);
the brush + "Paint with" use a scene: ref so painting stores the ref and
edits propagate everywhere. Slot preview (shared + item) resolves scene
refs so hover shows the real material.
Material properties editor uses the shared SliderControl for roughness/
metalness/opacity; row action buttons use Tooltip instead of title; the
color input renders as a clean filled swatch.
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>
- 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>
Elevator exposes four finish slots — cab / doors / shaft / glass (glass only
when shaft or door style is glass) — on node.slots. The finish materials now
resolve through a per-node material set (context provider) keyed on node.slots
+ scene materials: textures-off joinery role -> node.slots ref -> declared
default (cab preset-softwhite, doors metal-steel, shaft preset-lightgrey,
glass preset-glass), with glass transparency flags re-applied. Cab/door/shaft/
glass meshes tag userData.slotId; buttons, indicators, control panels, and
queue strips stay untagged (functional UI, not paintable). Interaction system
untouched. Monochrome unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Column exposes shaft / base / capital / frame slots on node.slots (base when
baseStyle!=none, capital when capitalStyle!=none, frame only for non-vertical
support styles). The single material context is widened to a per-slot map plus
a ColumnSlotContext; each part subtree is wrapped in <ColumnSlot> so every
mesh primitive resolves its material AND stamps userData.slotId from the same
context. Decorative shaft parts inherit shaft, base/capital carvings inherit
their parent slot. Resolution: textures-off wall role -> node.slots ref ->
legacy material/preset -> declared default (shaft/base/capital concrete-plaster,
frame metal-steel). Monochrome unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stair exposes three paintable slots — treads / body / railing (railing only
when railingMode != none) — on node.slots. The 2-material-index body mesh
maps materialIndex 0->treads, 1->body via userData.slotIds; railing meshes
tag userData.slotId='railing'. Per-slot resolution layers over the viewer's
base body/railing materials only in textures-on mode: node.slots ref ->
legacy per-part field (preserved) -> declared default (treads wood-woodplank48,
body wood-woodfine2, railing metal-steel). A custom preview swaps the targeted
body material-array index and whole-mesh railing materials. Monochrome
unchanged. Stops using DEFAULT_STAIR_MATERIAL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fence joins the node.slots paint model with one paintable 'surface' slot
(merged post/base/rail geometry is a single mesh). Resolution order matches
slab: textures-off joinery role -> node.slots.surface ref -> legacy inline
material/preset -> declared default (library:wood-finewood27). Tags the mesh
userData.slotId='surface' and wires capabilities.slots + paint. Stops
borrowing DEFAULT_STAIR_MATERIAL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the flat #e5e5e5 placeholder on the roof shingle surface (material
index 3) with the catalog roof-weatheredshingles finish via
resolveSlotDefaultMaterial. Wall/trim, deck, and interior surfaces and the
textures-off role array are unchanged. Roof keeps its existing per-segment
role material system (no node.slots migration).
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>
The editor's /icons assets were ~10MB of oversized PNGs (a single
toolbar icon up to 1.4MB). Convert every non-PWA icon to WebP (quality
92) and repoint all /icons/*.png references to .webp across
packages/editor, packages/nodes, and apps/editor. PWA/platform icons
(apple-touch-icon, icon-192, icon-512) stay PNG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* fix(editor): preserve grab offset when moving items (no teleport-to-cursor)
Grabbing a placed item to move it (move-cross handle or the Move
affordance) snapped the item's origin to the cursor instead of keeping
the offset between the grab point and the origin.
- Floor (regression): the grab-offset rewrite patched event.localPosition,
but floorStrategy.move reads event.position (the world cursor) for its
world-grid snap on the default non-Shift path -- a read added after the
offset fix landed. Now also correct event.position, derived from the
corrected building-local point via buildingLocalToWorld.
- Wall / ceiling / item-surface / shelf: the shared surface strategies
snapped the origin straight to the cursor with no grab offset. Added
per-surface grab anchors (start + (raw - anchor)) seeded on the first
move per host and reset on leave/detach; host-resting items start in
their surface, and the level-reparent effect skips intentionally-hosted
drafts so the dragged mesh stays on its host.
Rename preserveFloorDragOffset -> preserveDragOffset (it now governs all
surfaces). 2D move paths already preserved the grab offset, so no sibling
change is needed -- this brings 3D into parity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(editor): tidy grab-offset floor/wall paths (quality pass)
Behaviour-preserving readability pass before shipping:
- Extract the floor grab-offset IIFE into a named `applyFloorGrabOffset`
helper (parity with `resolveHostSurfaceWorld`); drop redundant tuple
casts via the return annotation and tighten the comment.
- Wall move: always set `position` (raw hit when the wall mesh is absent)
instead of a conditional object spread.
A reviewed dedup of the three per-surface anchors was deliberately NOT
taken — the wall (X/Y) / ceiling (X/Z) / host-surface frames are
heterogeneous enough that unifying them adds more indirection than it
removes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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
- Cubby without a bottom board: the lowest cell opens onto the floor, but its
column divider still started at the (missing) bottom board's top, leaving it
floating ~thickness above the floor. Anchor it at y=0 when there's no bottom.
- Bookshelf full-height divider: it crosses the continuous shelves, so it can't
share their depth plane (proud at the front, coplanar with the back panel —
z-fighting down the centre back). Recess its depth to sit fully INSIDE the
boards' depth: embedded at each shelf crossing (board occludes it) and tucked
inside the back panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cubby column divider was full-depth while the boards are depth-recessed,
so it (a) sat proud of the shelf fronts — the earlier Y-tuck then made it poke
past the top/bottom boards — and (b) shared the back panel's rear plane,
z-fighting down the centre of the back.
Give the divider the same depth recess as the boards (via boardGeometry) and
drop the Y-tuck: it now sits flush with the shelf fronts and its back tucks
inside the back panel. Flush top/bottom is fine — those board faces are
back-to-back, not co-facing, so they don't fight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cubby per-cell dividers landed exactly flush on the board below and under the
board above — coplanar faces that shimmer and read as merged into the shelf.
Extend each divider 1mm into both boards (centre unchanged) so it tucks under
the top board and onto the bottom board with a solid, seam-free join.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the plate-recess fix:
- Top surface: full-height frame members that pass UNDER the top board
(back panel, bookshelf column dividers, corner posts) had their top face at
unitHeight — coplanar with the top board's top — so they z-fought through it
(the seam on the top). Drop their top 1mm (cappedFrameY); bottoms stay on the
floor so nothing floats.
- Plate width: the previous pass recessed every board's width, which opened a
1mm gap where boards ABUT the side panels (cubby / bookshelf-with-sides).
Width is now recessed only where boards span OVER posts (open-rack /
no-sides bookshelf); abutting boards keep full width and meet the sides
flush. Depth stays recessed everywhere (gap-free, fixes the back-panel fight).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open-rack / no-sides shelves span their boards across the full footprint, so a
board's end / front / back faces land exactly on the corner posts' outer faces
(and, with a back panel, on its rear face). Coplanar surfaces the depth buffer
can't order flicker as z-fighting (visible as seams where plates meet posts).
Recess every board 1mm on width + depth via a `boardGeometry` helper so each
plate sits just inside the frame — the meshes still overlap (no visible gap),
but no two faces are coplanar. Frame parts (posts/sides/back/dividers/brackets)
keep their full size as the silhouette.
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>
Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.
UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
(footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
warning) + free-place (lands at the raw cursor but keeps the alignment guides
visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
commit places (cursorRotation was π off for the asymmetric ghost). R ownership
follows the current pointer pane (capture-phase + stopImmediatePropagation in
the 2D overlay) so 3D and 2D never double-flip or go dead.
Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
shared/wall-attach-target.ts — the single source of truth the ghost tint AND
the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
(door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.
Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In colored mode (textures on), every item now renders its authored material
— textures, vertex colours, and default colours — instead of being stripped
to the uniform off-white baseMaterial. AI-generated and vertex-coloured items
show their real appearance; slot resolution (override -> curated -> authored)
is unchanged. Monochrome (textures off) still collapses to the furnishing
clay role colour (the escape hatch). Drops the now-unused isAuthored marker.
Co-Authored-By: Claude Fable 5 <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>
Reuse one THREE.Line + preallocated position buffer per guide slot, mutating
endpoints in place each drag tick instead of rebuilding the geometry, line, and
two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable
semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that
persists keeps its React element and drei <Html> pill mounted as the guide set
churns, rather than remounting under shifting index keys.
Also: make useOpeningGuides.clear() a no-op when already empty so the common
no-guide hover frame doesn't push a fresh [] and re-render to the same nothing;
dispose the move-tool cursor EdgesGeometry on unmount; and memoize the
placement-tool cursor EdgesGeometry (static fallback dims) so it isn't
reallocated and orphaned on every render during placement.
Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak
regressions.
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>
Insert the plan's middle resolution step for item slots: a curated
default baked into the GLB as a per-material 'pascal_material' extra
(three's GLTFLoader copies material extras to userData). Resolution per
slot is now: node.slots[slotId] override -> pascal_material curated
default -> authored GLB material. Bare ids resolve as library:<id>;
unresolved refs fall through. Runtime reads only the GLB (no DB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three related editor fixes surfaced while debugging a captured house
project that rendered an infinite wall and failed to load.
Infinite wall (core/systems/wall/wall-mitering.ts):
Junction miters are line-line intersections, so the joint point sits
~halfThickness/sin(theta) from the junction. The only guard was an
exact-parallel check (det < 1e-9), so two walls meeting at a shallow
angle (a room-preset preview dragged onto an existing wall, or a wall
drawn nearly collinear to its neighbour) produced a joint point far
away — an infinite spike. Add a miter limit: reject joints farther
than 10x half-thickness from the junction and fall back to a square
joint, exactly like the parallel case.
Scene load failure (core/utils/heal-scene-graph.ts + validate-build-json
+ use-scene migrateNodes):
Capture wall-merge could leave a `children: [null]` entry (see the
matching merge-walls.ts fix in private-editor) and zero-length walls.
`null` children fail wall schema validation, so the whole scene fails
to load. Add a shared heal step — strip non-string child refs, drop
childless zero-length walls — run on every load path: import
validation now repairs instead of hard-failing (with a warning), and
setScene heals on the prod project-load path too.
Grid snap (nodes slab/ceiling/spawn tools):
These tools hardcoded a 0.5 m snap (Math.round(x*2)/2) and ignored the
editor's grid-snap setting, so the cursor jumped by 0.5 while later
vertices already followed the configured step. Route them through
snapPointToGrid / snapScalar with gridSnapStep.
Adds unit tests for the miter limit and the heal step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the opening proximity guides to two more interactions:
- PLACEMENT: the door/window placement tools publish the same 3D guides
(sill/head, edge proximity, sill alignment, equal-spacing) while a NEW
opening is being dropped; window placement also snaps its sill to a
neighbour's sill/centre/top (Shift bypass) — so "two windows aligned" reads
during placement, not just move.
- RESIZE: a new `onDrag` hook on the linear-resize handle descriptor lets the
door/window width/height arrows publish live guides for the edge being
resized — proximity to neighbours as the width grows, and the live sill/head
as a window's height changes. The generic LinearArrow stays kind-agnostic;
only door/window declare the hook.
Refactor (Codex review follow-up): one `publishOpeningGuidesForWallEvent`
wrapper now backs all four wall-event publish sites (door/window move +
placement) over a shared `makeWallToWorld`; window placement's repeated
sill-snap is a single `resolvePlacementY` helper. Opening guides clear on
commit / leave / cancel / roof-hover / unmount (mirroring the alignment-guide
lifecycle) and on resize end.
Codex-reviewed — no blockers; lifecycle/leaks, coordinate frame, sill-snap
precedence, and resize disposal confirmed. Typecheck + biome + 23 core + 170
nodes tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the opening-guides service into the 3D door/window move tools and render the
wall-plane guides as the spatial twin of the 2D plan guides:
- sill / head height (floor → bottom edge, top edge → wall top) — windows only
- edge-to-edge proximity dimensions to the nearest neighbour each side
- a sill-alignment line + SNAP when a window shares a neighbour's sill / centre
/ top (competes with the 0.5m grid, Shift bypasses) — the chosen
"snap + guide" behaviour
- Figma-style equal-spacing "=" badges across a run of openings
Adds `useOpeningGuides` (editor store) + `OpeningGuides3DLayer` (raw THREE.Line
overlays + Html pills, mounted beside Alignment3DGuideLayer) and a thin
`opening-guides-runtime` helper (collect siblings / sill snap / publish / clear)
called from the door + window move-tools at their per-tick `applyPreview` hook;
guides clear on commit / cancel / leave / roof-hover / unmount.
Guides render in the move cursor's building-local frame (reuses `wallLocalToWorld`)
so they track the dragged opening exactly. Codex-reviewed (roof-hover stale-guide
clear, collapsed-dimension suppression). Placement-time guides reuse the same
helper and are the next step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the door/window floor-plan placement dimensions through the new
opening-guides service:
- edge-to-edge clearance to the nearest neighbour (or wall end) on each
side, now with overlap suppression (previously nearest-only, ad-hoc).
- Figma-style equal-spacing — a "=" badge per gap on the wall centreline
whenever the moving opening is part of a run of 3+ (near-)equally-spaced
openings.
Adds the `equal-spacing-badge` FloorplanGeometry primitive, its 2D renderer
(distinct pink accent), and overlay registration. Shown while placing/moving.
Sill height + vertical alignment are 3D-only (a top-down plan has no vertical
axis) and land in the next phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>