Always-visible placement ghosts + true-nearest 2D opening snap (#407)

* 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>
This commit is contained in:
Aymeric Rabot
2026-06-15 12:24:27 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent aafb238545
commit 1628b728fa
49 changed files with 1907 additions and 748 deletions
@@ -283,6 +283,27 @@ export function FloorplanRegistryMoveOverlay() {
}
const onKey = (event: KeyboardEvent) => {
// R flips a directional kind's facing mid-placement (door / window:
// front ↔ back). The session records the flip and re-runs its last
// apply so the 2D symbol updates immediately; kinds without a facing
// leave `flipSide` unset and R falls through to the global handler.
//
// Only when THIS overlay is the active mover — i.e. the user has moved
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool
// also listens for R while a door/window `movingNode` is placed (split
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from
// both flipping (or double-playing the cue) on a single R press.
if (event.key === 'r' || event.key === 'R') {
if (!(session.flipSide && hasMovedSinceStart)) return
const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
event.preventDefault()
session.flipSide()
sfxEmitter.emit('sfx:item-rotate')
return
}
if (event.key !== 'Escape') return
// Claim teardown ownership so the 3D move tool's cleanup skips
// its own restore — without this, both sides would race to
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
*/
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
const node = usePlacementPreview((s) => s.node)
const parentNode = usePlacementPreview((s) => s.parentNode)
if (!node) return null
const builder = nodeRegistry.get(node.type)?.floorplan
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
// and `parent: null` short-circuits the elevator's level walk) so the layer
// never subscribes to / bulk-reads the nodes map during render.
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: null,
parent: parentNode ?? null,
viewState: undefined,
} as unknown as GeometryContext
@@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (live) {
const floorPlaced = def?.capabilities?.floorPlaced
const hasPosition = Array.isArray((node as { position?: unknown }).position)
if (floorPlaced && hasPosition) {
if (node.type === 'door' || node.type === 'window') {
// Door / window movers publish WALL-LOCAL live transforms
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
// wiki/architecture/tools.md. The mover only writes
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
// updates the 3D mesh imperatively but never the scene node —
// without applying the live transform here the 2D symbol stays
// frozen while the cursor slides. Merge the wall-local position +
// rotation onto the node but KEEP `parentId` (the wall) so
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
// real swing-arc / pane symbol at the live spot.
const r = (node as { rotation?: unknown }).rotation
effectiveNode = {
...node,
position: live.position,
rotation: Array.isArray(r)
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
: r,
} as AnyNode
} else if (floorPlaced && hasPosition) {
effectiveNode = applyPositionLiveTransform(node, live)
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0]
@@ -0,0 +1,128 @@
'use client'
import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import useEditor from '../../../store/use-editor'
/**
* Dev-only 2D debug overlay for the opening (door / window) wall snap.
*
* The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is
* within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the
* nearest such wall. The set of points within that radius of a segment is a
* **capsule** (stadium): a band of half-width = the snap radius along the
* wall, with semicircular caps at each end. That capsule IS the wall's
* (normally invisible) hit target — so this layer draws it directly, one
* analytic `<path>` per wall, instead of sampling a grid (which produced the
* stair-stepped boundary the previous version showed). No per-point
* classification, so it's cheap regardless of plan size.
*
* Where two walls sit closer than 2× the radius their capsules overlap; the
* snap resolves the overlap to the nearer wall (the translucent fills just
* blend there — a darker patch reads as "either wall is in reach, nearest
* wins"). Drawing the true bisector-clipped cells would need the expensive
* per-point pass this rewrite removes, and the hit-area view is what the
* user asked for.
*
* Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the
* floor-plan scene `<g>`, so it shares the plan→SVG transform and the scene
* rotation with every other floor-plan layer.
*/
/** Stable hue per wall id so a wall keeps its colour across re-renders. */
function wallHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
}
// Spread hues around the wheel with an offset that avoids a muddy
// red-orange clump for short, similar ids.
return (hash * 47) % 360
}
export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() {
const show2dVoronoi = useEditor((s) => s.show2dVoronoi)
const selectedLevelId = useViewer((s) => s.selection.levelId)
// Recompute only when the wall geometry on this level changes — not on
// every scene write. Dragging a door updates the door node every frame;
// keying the build on this string means the capsule paths don't rebuild
// for openings, only for wall edits.
const nodes = useScene((s) => s.nodes)
const wallsKey = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId) return ''
const segments = collectLevelWallSegments(nodes, selectedLevelId)
return segments
.map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`)
.join('|')
}, [show2dVoronoi, selectedLevelId, nodes])
const walls = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null
// Read nodes imperatively: `wallsKey` already encodes every wall change,
// so this memo is keyed on it rather than on the per-frame `nodes` ref.
const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId)
if (segments.length === 0) return null
const R = WALL_SNAP_DISTANCE_M
const r = R.toFixed(3)
return segments.map((s) => {
// Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the
// segment endpoints ±R along the normal for the long sides, then a
// semicircular cap (radius R, sweep-flag 0 bulges outward past each end)
// joins them. Verified winding holds for every orientation because the
// whole construction is a rigid transform of the axis-aligned case.
const nx = -s.dirY
const ny = s.dirX
const ax = (s.start[0] + nx * R).toFixed(3)
const ay = (s.start[1] + ny * R).toFixed(3)
const bx = (s.end[0] + nx * R).toFixed(3)
const by = (s.end[1] + ny * R).toFixed(3)
const cx = (s.end[0] - nx * R).toFixed(3)
const cy = (s.end[1] - ny * R).toFixed(3)
const dx = (s.start[0] - nx * R).toFixed(3)
const dy = (s.start[1] - ny * R).toFixed(3)
const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z`
return {
wallId: s.wall.id,
d,
hue: wallHue(s.wall.id),
x1: s.start[0],
y1: s.start[1],
x2: s.end[0],
y2: s.end[1],
}
})
}, [show2dVoronoi, selectedLevelId, wallsKey])
if (!walls) return null
return (
<g className="floorplan-voronoi-debug" pointerEvents="none">
{walls.map(({ wallId, d, hue }) => (
<path
d={d}
fill={`hsla(${hue}, 80%, 55%, 0.22)`}
key={`hit-${wallId}`}
stroke={`hsl(${hue}, 85%, 50%)`}
strokeOpacity={0.5}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{walls.map(({ wallId, hue, x1, y1, x2, y2 }) => (
<line
key={`line-${wallId}`}
stroke={`hsl(${hue}, 85%, 42%)`}
strokeLinecap="round"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
x1={x1}
x2={x2}
y1={y1}
y2={y2}
/>
))}
</g>
)
})
@@ -10,6 +10,7 @@ import {
calculateLevelMiters,
DEFAULT_ANGLE_STEP,
type DoorNode,
DoorNode as DoorNodeSchema,
type ElevatorNode,
emitter,
type FenceNode,
@@ -45,7 +46,9 @@ import {
useLiveTransforms,
useScene,
type WallNode,
WallNode as WallNodeSchema,
type WindowNode,
WindowNode as WindowNodeSchema,
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
@@ -85,6 +88,7 @@ import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
@@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { snapToHalf } from '../tools/item/placement-math'
@@ -5501,6 +5506,70 @@ export function FloorplanPanel({
return 0
}, [isWindowBuildActive, movingNode, shiftPressed])
// Float the faithful door/window symbol at the cursor while it isn't over a
// wall (the off-wall placement ghost), by publishing a transient opening on a
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
// renders it through the real `def.floorplan` builder (swing arc / panes), so
// it reads as a real door/window, not a bare rectangle. Off any wall there's
// no orientation to inherit, so the synthetic wall runs along plan-X.
const showOpeningGhost = useCallback(
(planPoint: WallPlanPoint) => {
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
const half =
(isDoor
? movingNode?.type === 'door'
? movingNode.width
: 0.9
: movingNode?.type === 'window'
? movingNode.width
: 1.5) /
2 +
0.5
const wall = WallNodeSchema.parse({
start: [planPoint[0] - half, planPoint[1]],
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Clone the moving opening (carries width / type / hinge / swing) onto the
// synthetic wall, or parse a default for build mode. position[0] = the
// along-wall midpoint so the symbol centres on the cursor.
const base =
movingNode?.type === 'door' || movingNode?.type === 'window'
? { ...movingNode }
: isDoor
? DoorNodeSchema.parse({})
: WindowNodeSchema.parse({})
const ghost = {
...base,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
} as AnyNode
usePlacementPreview.getState().set(ghost, wall)
},
[
DoorNodeSchema,
WallNodeSchema,
WindowNodeSchema,
floorplanOpeningLocalY,
isDoorBuildActive,
movingNode,
movingOpeningType,
],
)
// Drop the floating opening ghost whenever opening placement ends (commit,
// tool change, mode switch, cancel) or the active level changes, so a stale
// ghost never lingers on the wrong level.
useEffect(() => {
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
}, [isOpeningPlacementActive])
useEffect(() => {
usePlacementPreview.getState().clear()
}, [levelId])
const isMarqueeSelectionToolActive =
mode === 'select' &&
floorplanSelectionTool === 'marquee' &&
@@ -8568,7 +8637,16 @@ export function FloorplanPanel({
// `wall:move` events the door / window placement tools listen for.
// Same reason `handleBackgroundPlacementClick` runs its opening
// branch before its grid catch-all.
if (isOpeningPlacementActive) {
//
// Only the pure BUILD case (a door/window tool armed with no
// `movingNode`) drives placement through these synthesized `wall:*`
// events. When a door/window `movingNode` is set — the community
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
// plan-space snap, single-undo commit, R-flip). Running both at once
// made them fight (R-flip overwritten on the next move, click-commit
// dropped), so the move case is excluded here.
if (isOpeningBuildActive && !isOpeningMoveActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
@@ -8595,9 +8673,23 @@ export function FloorplanPanel({
} else {
emitter.emit('wall:move', wallEvent as any)
}
} else if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
// Snapped to a wall — the real on-wall draft is the preview; drop
// the loose free-follow ghost.
usePlacementPreview.getState().clear()
} else {
if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
}
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
// panes) following the cursor, not a bare rectangle. The glyph
// builder needs a wall for `ctx.parent`, so we publish the opening on
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
// through the real `def.floorplan` builder. Shift bypasses grid snap.
const snappedPoint =
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
showOpeningGhost(snappedPoint)
}
return
}
@@ -8622,7 +8714,13 @@ export function FloorplanPanel({
// window are also registered kinds, but need wall events — see
// comment there). Wall build skips this so its own branch below
// updates local `draftEnd` state alongside the registry tool.
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
//
// A door/window MOVE (community preset) is owned by
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
// for it (build mode + a registered `door`/`window` tool), so without
// this exclusion the catch-all would emit `grid:move` and re-drive the
// 3D MoveDoorTool's free-follow, fighting the overlay again.
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8969,8 +9067,15 @@ export function FloorplanPanel({
isCeilingBuildActive,
isCeilingItemPlacementActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
// grid catch-all true for it, but the overlay owns its commit (its own
// pointerup). Letting the catch-all emit `grid:click` here would consume
// the commit click and fight the overlay.
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
// Only the pure-build opening case (tool armed, no movingNode) commits via
// the synthesized `wall:click`; the move case (community preset) is owned
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
@@ -10412,6 +10517,13 @@ export function FloorplanPanel({
showGrid={showGrid}
/>
{/* Dev-only: draw each wall's opening-snap hit area (the
capsule of points within the snap radius of its centerline).
Gated on the developer-menu toggle. Painted right after the
grid so the translucent capsules sit under the wall / opening
glyphs. */}
<FloorplanVoronoiLayer />
<FloorplanReferenceFloorLayer
data={referenceFloorData}
opacity={referenceFloorOpacity}
@@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
@@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({
stopPropagation: () => {},
} as any)
}
// Drop the off-wall ghost on commit so it doesn't linger at the
// just-placed spot before the next pointer move re-evaluates.
usePlacementPreview.getState().clear()
return true
}
+17 -2
View File
@@ -30,6 +30,16 @@ export const useKeyboard = ({
return
}
// True while a door/window is being placed: either a fresh clone is moving
// (preset / duplicate path) or a door/window build tool is armed. The
// placement tool owns R/T then (flip the draft before commit), so the
// global selection-based R/T handler must stand down to avoid double-firing.
const isPlacingOpening = () => {
const ed = useEditor.getState()
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
}
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -171,11 +181,16 @@ export const useKeyboard = ({
}
}
}
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
// Doors use R to flip side (front ↔ back, rotation += π); their
// open/close toggle lives on E. Windows still use R to toggle
// their open/closed state.
//
// Skipped entirely while a door/window placement is active
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
// before commit), and the user can have a node selected at the same
// time — without this guard both would fire (double flip + sfx).
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
@@ -225,7 +240,7 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
+7
View File
@@ -369,6 +369,11 @@ type EditorState = {
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// Development-only debug overlay: draw each wall's opening-snap hit area
// (the capsule of points within the snap radius of its centerline). Lets us
// see why a door/window snaps where it does.
show2dVoronoi: boolean
setShow2dVoronoi: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
@@ -955,6 +960,8 @@ const useEditor = create<EditorState>()(
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
show2dVoronoi: false,
setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (enabled) => {
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
/** Transient preview node, already positioned + rotated at the (snapped,
* aligned) cursor. `null` when no placement is active. */
node: AnyNode | null
set(node: AnyNode | null): void
/** Optional synthetic parent for the preview's `def.floorplan` context.
* Door / window glyph builders need `ctx.parent` to be a wall to draw their
* real symbol (swing arc / panes); off any real wall we hand them a
* synthetic wall segment centred at the cursor so the floating ghost shows
* the faithful blueprint symbol instead of a bare rectangle. `null` for
* self-contained kinds (column / elevator). */
parentNode: AnyNode | null
set(node: AnyNode | null, parentNode?: AnyNode | null): void
clear(): void
}
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
node: null,
set: (node) => set({ node }),
clear: () => set({ node: null }),
parentNode: null,
set: (node, parentNode = null) => set({ node, parentNode }),
clear: () => set({ node: null, parentNode: null }),
}))
export default usePlacementPreview