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
+271 -221
View File
@@ -11,6 +11,7 @@ import {
spatialGridManager,
useScene,
type WallEvent,
type WallNode,
} from '@pascal-app/core'
import {
calculateCursorRotation,
@@ -22,7 +23,7 @@ import {
useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -33,6 +34,7 @@ import {
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
@@ -45,31 +47,61 @@ const FALLBACK_WIDTH = 0.9
const FALLBACK_HEIGHT = 2.1
const roofFallbackPoint = new Vector3()
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
// the cursor is over open floor (the grid handler then free-follows).
type HostKind = 'wall' | 'roof' | null
/**
* Door tool — places DoorNodes on walls and on roof-segment wall faces
* (the generated base walls under a roof, including coplanar gable ends).
* Doors always sit at floor level (clampedY = height/2 — segment base for
* roof-hosted doors).
*
* The ghost follows the cursor everywhere (like moving an item): over open
* floor it floats as an invalid (unplaceable) ghost; the moment the cursor
* ray hovers a wall (or roof-segment face) the real draft snaps onto it.
* Snapping engages only on an actual mesh hover — no proximity magnet — since
* the wall side faces are big raycast targets.
*/
const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real door geometry follows the cursor over
// the grid (tinted invalid). Mutually exclusive with the on-host draft —
// when a draft + wireframe is shown this is null and vice-versa.
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
} | null>(null)
const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), [])
useEffect(() => {
useScene.temporal.getState().pause()
let hostKind: HostKind = null
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
// the grid raycast from the SAME pointermove share the source DOM event's
// timeStamp, so the grid handler can detect "a mesh handler already owns
// this frame" without depending on event order or on a leave firing (node
// events are suppressed during a camera drag, so a sticky boolean would
// strand the draft after an orbit; a per-frame timestamp self-heals).
let lastMeshEventTime = -1
// R flips the door's facing side mid-placement (front ↔ back). On a wall
// the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`;
// re-applied to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const getSlabElevationForWall = (wall: WallNode) =>
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
@@ -86,17 +118,22 @@ const DoorTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
setFallbackPose(null)
}
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A door aligns by the plan position of its centre.
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// On-host cursor: the green/red wireframe outline tracks a live draft.
// Showing it always clears the off-host floating ghost (they never
// coexist — a draft means the cursor is on a valid host).
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
setFallbackPose(null)
const group = cursorGroupRef.current
if (!group) return
group.visible = true
@@ -105,237 +142,134 @@ const DoorTool: React.FC = () => {
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const showFallbackCursor = (event: GridEvent) => {
if (draftRef.current) return
const [x, y, z] = event.localPosition
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
// Off-host fallback: hide the wireframe outline and float the real door
// geometry (tinted invalid) at the cursor so the armed tool is visible.
const showGhostAt = (position: [number, number, number]) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
useAlignmentGuides.getState().clear()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
useAlignmentGuides.getState().clear()
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
useAlignmentGuides.getState().clear()
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
const levelId = getLevelId()
if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const width = 0.9
const height = 2.1
// Settle a wall target: alignment snap → clamp → overlap check. Pure read.
const resolveWallPlacement = (
wall: WallNode,
rawLocalX: number,
width: number,
height: number,
bypass: boolean,
bypassSnap: boolean,
ignoreId?: string,
) => {
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
bypass,
bypassSnap,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(
event.node,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
),
cursorRotation,
valid,
)
event.stopPropagation()
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
return { clampedX, clampedY, valid }
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return
}
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
// Shared create/update path for the wall draft — used by the direct
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
// (reparenting only on an actual wall change to avoid churning the host's
// children array, which flashes 0-vertex wall geometry in WebGPU).
const applyWallTarget = (args: {
wall: WallNode
rawLocalX: number
side: 'front' | 'back'
itemRotation: number
cursorRotationY: number
bypass: boolean
bypassSnap: boolean
}) => {
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
// Draft may be null after a successful placement (the click handler
// deletes it and relies on the wall rebuild → pointer-enter cascade to
// recreate it). Recreate it here on the first subsequent move so the
// preview is ready for the next click without requiring a leave/enter.
if (!draftRef.current) {
const levelId = getLevelId()
if (levelId && event.node.parentId === levelId) {
const node = DoorNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
}
const node = DoorNode.parse({
position: [0, height / 2, 0],
rotation: [0, itemRotation, 0],
side,
wallId: wall.id,
parentId: wall.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, wall.id as AnyNodeId)
draftRef.current = node
}
if (draftRef.current) {
// Update the scene store on every move so the 2D floor plan
// stays in sync (it re-renders from `node.position`). Only
// forward `parentId` / `wallId` when the wall actually changed
// — otherwise the reparent path churns the host wall's
// `children` array every tick, which re-renders the wall and
// briefly draws its 0-vertex placeholder geometry (WebGPU then
// flags "Vertex buffer slot 0 ... was not set").
const isSameWall = event.node.id === draftRef.current.parentId
if (isSameWall) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markHostDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
const { clampedX, clampedY, valid } = resolveWallPlacement(
wall,
rawLocalX,
width,
height,
draftRef.current?.id,
bypass,
bypassSnap,
draftRef.current.id,
)
if (wall.id === draftRef.current.parentId) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markHostDirty(wall.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: wall.id,
wallId: wall.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
updateCursor(
wallLocalToWorld(
event.node,
wall,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
getSlabElevationForWall(wall),
),
cursorRotation,
cursorRotationY,
valid,
)
event.stopPropagation()
return { clampedX, clampedY, valid }
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: draftRef.current.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
draftRef.current.width,
draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
draftRef.current.width,
draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
// Promote the draft into a permanent door. Shared by the wall-mesh click
// and the floor proximity click.
const commitDoorAtWall = (
wall: WallNode,
clampedX: number,
clampedY: number,
side: 'front' | 'back',
itemRotation: number,
) => {
const draft = draftRef.current
if (!draft) return
draftRef.current = null
hostKind = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
@@ -344,18 +278,17 @@ const DoorTool: React.FC = () => {
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'door') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return w?.parentId === levelId
}).length
const name = `Door ${doorCount + 1}`
const node = DoorNode.parse({
name,
name: `Door ${doorCount + 1}`,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
wallId: wall.id,
parentId: wall.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
@@ -380,19 +313,107 @@ const DoorTool: React.FC = () => {
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
}
// ── Direct wall-mesh hover ──────────────────────────────────────
const onWallHover = (event: WallEvent) => {
hostKind = 'wall'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
destroyDraft()
showWallFallbackCursor(event)
return
}
lastWallEvent = event
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const flipOffset = sideFlip ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + flipOffset
const cursorRotation =
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypass = event.nativeEvent?.altKey === true || bypassSnap
applyWallTarget({
wall: event.node,
rawLocalX: event.localPosition[0],
side,
itemRotation,
cursorRotationY: cursorRotation,
bypass,
bypassSnap,
})
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
return
}
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0)
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypass = event.nativeEvent?.altKey === true || bypassSnap
const { clampedX, clampedY, valid } = resolveWallPlacement(
event.node,
event.localPosition[0],
draftRef.current.width,
draftRef.current.height,
bypass,
bypassSnap,
draftRef.current.id,
)
if (!valid) return
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
}
const onWallLeave = () => {
if (hostKind !== 'wall') return
lastWallEvent = null
destroyDraft()
hideCursor()
hostKind = null
}
// ── Floor free-follow ───────────────────────────────────────────
// Over open floor the ghost follows the cursor like a moving item. It does
// NOT snap from proximity — snapping engages only when the cursor ray
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
const onGridFreeFollow = (event: GridEvent) => {
if (useViewer.getState().cameraDragging) return
// A wall/roof mesh handler processed this exact pointermove (R3F + the
// grid raycast share the source DOM event's timeStamp) — it owns the
// frame and has snapped the draft, so skip the floor follow this tick.
const ts = event.nativeEvent?.timeStamp ?? -1
if (ts === lastMeshEventTime) return
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
// and free-follow the cursor with the invalid (unplaceable) ghost.
hostKind = null
lastWallEvent = null
const [x, y, z] = event.localPosition
destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2, z])
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -414,6 +435,8 @@ const DoorTool: React.FC = () => {
}
const onRoofHover = (event: RoofEvent) => {
hostKind = 'roof'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
@@ -456,6 +479,7 @@ const DoorTool: React.FC = () => {
const draft = draftRef.current
draftRef.current = null
hostKind = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
@@ -508,42 +532,61 @@ const DoorTool: React.FC = () => {
}
const onRoofLeave = () => {
if (!draftRef.current?.roofSegmentId) return
if (hostKind !== 'roof') return
destroyDraft()
hideCursor()
hostKind = null
}
const onCancel = () => {
destroyDraft()
hideCursor()
hostKind = null
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
// R flips the door's facing side mid-placement (front ↔ back), like the
// committed-selected R flip. Only meaningful while snapped to a wall (the
// off-wall ghost has no orientation), so it acts only then — re-applying
// the last wall hover so the snapped preview flips live.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault()
sideFlip = !sideFlip
triggerSFX('sfx:item-rotate')
onWallHover(lastWallEvent)
}
emitter.on('wall:enter', onWallHover)
emitter.on('wall:move', onWallHover)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('grid:move', onGridFreeFollow)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('grid:move', onGridFreeFollow)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [])
@@ -553,14 +596,21 @@ const DoorTool: React.FC = () => {
boxGeo.dispose()
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
{fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
<DoorPreview invalid node={ghostStub} />
</group>
)}
</>
)
}