* Add roof surface placement support for items
Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.
- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fixed conflict
* feat(duct): ceiling-snap drawing + connected-joint endpoint move
Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.
Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): detach + vertical modifiers for duct/pipe joint editing
Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): full DWV pipe parity for joint editing
Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): wall-style arrow handles for duct fittings + segments
Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.
Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): click-to-latch cube handles for duct + fitting editing
Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).
- Duct segment: per-vertex + run-center cubes reveal axis-locked move
chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
per-axis rotation arcs (oriented in place), replacing the old
height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.
* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag
Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): run-aligned duct handles, swing snapping, elbow flatten
Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): per-segment linesets/liquid-lines with joint-follow editing
Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.
Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag
Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Add roof accessory placement guides
Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.
* Improve duct and placement routing
* Fix duct vertical movement routing
* Fix duct vertical offsets and roof accessory movement
* Add DWV movement parity and line endpoint controls
* Fix MEP handle review issues
* Fix chimney placement and duct offset cleanup
* Use snapped targets for roof accessory commits
* fix(nodes): repair MEP movement review issues
- auto-fitting: tee branch now follows the drawn lateral angle; update the
stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default
* fix: address mep movement review issues
* fix: address follow-up mep review comments
* fix: address additional mep review comments
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
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>
* 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>