Always-visible placement ghosts + true-nearest 2D opening snap#407
Merged
Conversation
… + 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>
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>
…n 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>
…lacement 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>
…ring 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>
…oor/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>
… 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>
…ter 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>
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>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
jelharou
pushed a commit
to jelharou/editor
that referenced
this pull request
Jun 15, 2026
…proximity-guides pascalorg#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 pascalorg#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 pascalorg#407 behavior preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Reworks door/window (and roof-accessory) placement UX so the real node geometry follows the cursor as a ghost, snaps faithfully onto host surfaces, and behaves identically in 2D and 3D. Built incrementally over several rounds (8 commits); the final round adds a true-nearest 2D wall snap and a dev-only hit-area overlay.
Highlights
applyGhosthelper; door/window get narrow viewer preview-mesh exports.FloorplanRegistryMoveOverlayowns 2D opening placement end-to-end (the legacy synthesized-wall:*path is gated off for the move case to stop the two fighting).@pascal-app/core(lib/wall-distance.ts) so the snap and the debug overlay share one source of truth.getOpeningHostLevelId); a fresh window defaults to a realistic sill instead of sitting on the floor.FloorplanVoronoiLayer, gated on ashow2dVoronoieditor flag (wired to a developer-menu toggle in the consuming app), draws each wall's snap region as an analytic capsule.Layers / safety
wall-distance.tsimports only schema +wall-curve).Verification
tsc --buildgreen across core / nodes / editor; repobun run buildsucceeds (6/6).biome checkclean on all touched files.getOpeningHostLevelIdis a strict superset of the prior per-file derivation).🤖 Generated with Claude Code