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:
co-authored by
Claude Opus 4.8
parent
aafb238545
commit
1628b728fa
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user