* 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>
225 lines
8.7 KiB
TypeScript
225 lines
8.7 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
type FloorplanMoveTarget,
|
|
type FloorplanMoveTargetSession,
|
|
useLiveTransforms,
|
|
useScene,
|
|
type WallNode,
|
|
WallNode as WallNodeSchema,
|
|
type WindowNode,
|
|
} from '@pascal-app/core'
|
|
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
|
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
|
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
|
import {
|
|
findClosestWallInPlan,
|
|
projectWallLocalPointToPlan,
|
|
snapLocalXToNeighbors,
|
|
} from '../shared/wall-attach-target'
|
|
import { clampToWall, hasWallChildOverlap } from './window-math'
|
|
|
|
/**
|
|
* 2D floor-plan move handler for window. Same shape as door (see
|
|
* `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap
|
|
* to nearest wall → project onto wall axis → snap local-X to 0.5m →
|
|
* clamp inside wall bounds → commit.
|
|
*
|
|
* Window-specific: local Y (vertical position on the wall) is preserved
|
|
* from the source node — we don't try to reposition the sill from a 2D
|
|
* pointer (there's no Y signal in plan view). The 3D move tool handles
|
|
* vertical motion; the 2D move is a horizontal-only re-anchor.
|
|
*/
|
|
|
|
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
|
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
|
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
|
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
|
const originalWall = node.parentId
|
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
|
: undefined
|
|
const resolveCursor = createFloorplanCursorResolver({
|
|
original:
|
|
originalWall?.type === 'wall'
|
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
|
metadata: node.metadata,
|
|
// Absolute: query the wall snap with the TRUE cursor (see the matching
|
|
// comment in `doorFloorplanMoveTarget`). Relative mode anchored the search
|
|
// to the original wall, which let the window snap to a farther wall across
|
|
// a thin gap instead of the one under the cursor.
|
|
mode: 'absolute',
|
|
})
|
|
|
|
// Preserve the source window's local Y — 2D move doesn't have a way
|
|
// to express vertical motion, so we keep whatever vertical position
|
|
// the window had when the move started. A fresh preset/catalog clone is
|
|
// created at y=0, which would sit the window's centre on the floor (half
|
|
// below ground); default those to a realistic sill so it floats above
|
|
// the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`).
|
|
const DEFAULT_SILL = 0.9
|
|
const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2
|
|
|
|
// Track the last successful placement so `commit()` can write it
|
|
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
|
let lastValid: {
|
|
position: [number, number, number]
|
|
rotation: [number, number, number]
|
|
side: WindowNode['side']
|
|
parentId: string
|
|
wallId: string
|
|
roofSegmentId: undefined
|
|
roofFace: undefined
|
|
} | null = null
|
|
|
|
// R flips the window's facing (front ↔ back) mid-placement — see
|
|
// `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the
|
|
// flip is a persistent XOR plus a π rotation offset.
|
|
let flipped = false
|
|
let lastApply: {
|
|
planPoint: readonly [number, number]
|
|
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
|
|
} | null = null
|
|
// See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
|
|
// as a ghost and isn't committable (it needs a wall). Starts true.
|
|
let onWall = true
|
|
|
|
const freeFollow = (planPoint: readonly [number, number]) => {
|
|
onWall = false
|
|
lastValid = null
|
|
if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
|
|
}
|
|
const half = node.width / 2 + 0.5
|
|
const wall = WallNodeSchema.parse({
|
|
start: [planPoint[0] - half, planPoint[1]],
|
|
end: [planPoint[0] + half, planPoint[1]],
|
|
thickness: 0.1,
|
|
})
|
|
const ghost = {
|
|
...node,
|
|
parentId: wall.id,
|
|
wallId: wall.id,
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
position: [half, startLocalY, 0] as [number, number, number],
|
|
rotation: [0, 0, 0] as [number, number, number],
|
|
visible: true,
|
|
} as WindowNode
|
|
usePlacementPreview.getState().set(ghost, wall)
|
|
}
|
|
|
|
const session: FloorplanMoveTargetSession = {
|
|
affectedIds: [node.id as AnyNodeId],
|
|
flipSide() {
|
|
flipped = !flipped
|
|
if (lastApply) this.apply(lastApply)
|
|
},
|
|
apply({ planPoint, modifiers }) {
|
|
lastApply = { planPoint, modifiers }
|
|
// Drop any stale live transform left by the 3D `MoveWindowTool` — see
|
|
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
|
|
// keeps rendering the window at the 3D tool's last hover (it prefers
|
|
// `useLiveTransforms` over the scene node for door/window), so the 2D
|
|
// slide — which writes the scene node — wouldn't show. Guarded on
|
|
// existence: `clear` allocates a new Map + re-renders.
|
|
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
|
|
useLiveTransforms.getState().clear(node.id as AnyNodeId)
|
|
}
|
|
const nodes = useScene.getState().nodes
|
|
const resolvedPlanPoint = resolveCursor(planPoint)
|
|
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
|
if (!hit) {
|
|
freeFollow(resolvedPlanPoint)
|
|
return
|
|
}
|
|
onWall = true
|
|
usePlacementPreview.getState().clear()
|
|
if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) {
|
|
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
|
}
|
|
|
|
// Figma-style along-wall alignment first (edge-to-edge with other
|
|
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
|
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
|
|
const neighborX =
|
|
modifiers.altKey || modifiers.shiftKey
|
|
? null
|
|
: snapLocalXToNeighbors({
|
|
wall: hit.wall,
|
|
localX: hit.localX,
|
|
width: node.width,
|
|
selfId: node.id as AnyNodeId,
|
|
nodes,
|
|
})
|
|
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
|
const { clampedX, clampedY } = clampToWall(
|
|
hit.wall,
|
|
snappedLocalX,
|
|
startLocalY,
|
|
node.width,
|
|
node.height,
|
|
)
|
|
|
|
const side: WindowNode['side'] = flipped
|
|
? hit.side === 'front'
|
|
? 'back'
|
|
: 'front'
|
|
: hit.side
|
|
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
|
|
|
lastValid = {
|
|
position: [clampedX, clampedY, 0],
|
|
rotation: [0, itemRotation, 0],
|
|
side,
|
|
parentId: hit.wall.id,
|
|
wallId: hit.wall.id,
|
|
// Re-anchoring to a wall ends any roof-segment hosting; the
|
|
// overlay's snapshot restores it if the move is reverted.
|
|
roofSegmentId: undefined,
|
|
roofFace: undefined,
|
|
}
|
|
|
|
useScene.getState().updateNodes([
|
|
{
|
|
id: node.id as AnyNodeId,
|
|
data: lastValid,
|
|
},
|
|
])
|
|
},
|
|
canCommit() {
|
|
// Off-wall the window is free-following — not placeable; the overlay
|
|
// reverts to the pre-move snapshot. Matches the 3D move.
|
|
if (!onWall) return false
|
|
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
|
|
if (!live || live.type !== 'window') return false
|
|
const overlapping = hasWallChildOverlap(
|
|
live.parentId as string,
|
|
live.position[0],
|
|
live.position[1],
|
|
live.width,
|
|
live.height,
|
|
live.id,
|
|
)
|
|
return !overlapping
|
|
},
|
|
commit() {
|
|
// Own the atomic write so the overlay takes the deterministic
|
|
// commit-path (revert → resume → session.commit()). The dispatcher's
|
|
// diff path would otherwise re-derive the final state by comparing
|
|
// the post-apply scene to the snapshot — that works most of the
|
|
// time but produces an empty diff (and silent revert) when the
|
|
// committed move lands on the same `parentId` with identical data.
|
|
// See `doorFloorplanMoveTarget.commit` for the original fix.
|
|
if (!lastValid) return
|
|
useScene.getState().updateNodes([
|
|
{
|
|
id: node.id as AnyNodeId,
|
|
data: lastValid,
|
|
},
|
|
])
|
|
},
|
|
}
|
|
|
|
return session
|
|
}
|