feat(editor): door/window placement-feel polish + placement-state refactor (#411)

Round 8 of the opening-placement UX work. Make door/window placement feel
physical and predictable, and unify the validity/placement logic behind one
shared decision.

UX:
- Window default sill 0.5 m (DEFAULT_WINDOW_SILL_M) so fresh windows float
  slightly above the floor; existing windows keep their own sill.
- Dev-only floor "shadow" projection for windows during placement/move
  (footprint + dashed drop-line) so an elevated window's plan spot is legible.
- Move SFX: one soft grid-snap click per grid step — identical free-following
  over floor or sliding on a wall (keyed on the raw cursor; per-frame + step
  dedup), no separate snap cue (that was a "double"). Mirrored into the 2D
  floorplan-move so 2D and 3D match.
- Shift = force-place over a collision (commit allowed; ghost stays a red
  warning) + free-place (lands at the raw cursor but keeps the alignment guides
  visible). Tint flips green/red live when Shift is pressed/released stationary.
- On-wall preview is now the tinted ghost (green placeable / red colliding),
  matching the free-follow ghost, instead of a pale solid mesh + thin wireframe.
- R-flip fixes: always toggles (no initial no-op needing a second press),
  e.repeat filtered, ghost rebuilds with the live `side`, and the ghost's
  on-wall world yaw uses `itemRotation - wallAngle` so it faces exactly what
  commit places (cursorRotation was π off for the asymmetric ghost). R ownership
  follows the current pointer pane (capture-phase + stopImmediatePropagation in
  the 2D overlay) so 3D and 2D never double-flip or go dead.

Refactor / quality:
- New `resolveOpeningPlacement({collides,forcePlace}) -> {placeable,tint}` in
  shared/wall-attach-target.ts — the single source of truth the ghost tint AND
  the commit gates both consume, so they can't disagree under Shift.
- Consolidated the byte-identical `hasWallChildOverlap` into one shared impl
  (door-math/window-math re-export it).
- applyGhost gained a green "valid" tint.
- Removed dead `cursorRotation` from the move-tool targets after the yaw fix.

Docs: "2D <-> 3D behavioral parity" principle in wiki/architecture/tools.md
(+ README + AGENTS.md) — applicable behaviors must exist in both views.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-16 10:20:44 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d053c4317
commit a0d3d9c701
18 changed files with 1027 additions and 277 deletions
+5 -69
View File
@@ -1,12 +1,4 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import type { WallNode } from '@pascal-app/core'
/**
* Keep the door handle at the same relative height when the door is resized:
@@ -64,63 +56,7 @@ export function clampToWall(
return { clampedX, clampedY }
}
/**
* Checks if a proposed door position overlaps any existing wall children.
* Handles item, window, and door types.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1]
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
// Wall-child overlap is shared by door + window placement (one source of
// truth in `shared/wall-attach-target.ts`). Re-exported here so existing
// `./door-math` importers don't change.
export { hasWallChildOverlap } from '../shared/wall-attach-target'
+45 -7
View File
@@ -8,12 +8,13 @@ import {
type WallNode,
WallNode as WallNodeSchema,
} from '@pascal-app/core'
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './door-math'
@@ -87,6 +88,25 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
// needs a wall. Starts true so a click before any move keeps the door put.
let onWall = true
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move. Read in `canCommit` so a Shift-
// held commit over a collision lands instead of reverting.
let forcePlace = false
// Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click
// per grid step, identical free-following or sliding on a wall, keyed on the
// RAW cursor (not the snapped along-wall value). No separate floor→wall cue —
// that distinct sound was the "double" the user heard. 2D `apply` runs once per
// pointermove, so the step-key dedup is sufficient (no per-frame guard needed).
const STEP_M = 0.1
let lastStepKey: string | null = null
const tickGridStep = (...coords: number[]) => {
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key !== lastStepKey) {
lastStepKey = key
triggerSFX('sfx:grid-snap')
}
}
// Off-wall: float the faithful door symbol at the cursor (via a synthetic
// wall fed to the placement-preview layer) and hide the real node, so the
@@ -104,14 +124,23 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Reflect the R-flip on the floating ghost so its swing-arc faces the side
// that will be committed (the synthetic wall is plan-X aligned, so a back
// facing is a π yaw; the symbol builder also reads `side`).
const ghostSide: DoorNode['side'] = flipped
? node.side === 'front'
? 'back'
: 'front'
: node.side
const ghost = {
...node,
side: ghostSide,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, node.position[1], 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
visible: true,
} as DoorNode
usePlacementPreview.getState().set(ghost, wall)
@@ -125,6 +154,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
},
apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveDoorTool` (it
// publishes one on every wall hover). The 2D registry layer renders
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node,
@@ -140,7 +170,9 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) {
// Off any wall — free-follow the cursor (not committable).
// Off any wall — free-follow the cursor (not committable). Click per grid
// cell as the ghost slides over open floor.
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
freeFollow(resolvedPlanPoint)
return
}
@@ -168,6 +200,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`,
// not the snapped value) so the wall slide ticks at the same cadence as the
// off-wall ghost — the same SFX, no separate snap cue.
tickGridStep(hit.localX)
// Apply the R-flip on top of the wall-derived side.
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
@@ -204,9 +241,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
if (!live || live.type !== 'door') return false
// Block commit if the door overlaps any other wall child at its
// current position. The 3D port has the same guard.
const overlapping = hasWallChildOverlap(
// Block commit if the door overlaps another wall child — UNLESS Shift
// force-places (same `placeable` rule as the 3D move + the shared
// `resolveOpeningPlacement`).
const collides = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
@@ -214,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
live.height,
live.id,
)
return !overlapping
return resolveOpeningPlacement({ collides, forcePlace }).placeable
},
commit() {
// Own the atomic write so the overlay takes the deterministic
+226 -34
View File
@@ -14,7 +14,6 @@ import {
type WallEvent,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
@@ -26,7 +25,7 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -38,8 +37,10 @@ import {
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
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,
@@ -51,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
// The door preview ghost. Shown for the WHOLE move so the user always sees a
// translucent door tinted by placement state — red off-wall or colliding,
// green on a valid wall — exactly like the free-follow ghost. The real node
// stays hidden until commit (the wall still cuts its hole from the node data,
// so the opening reads correctly behind the ghost). `null` = not previewing
// (committed / torn down). See the matching `DoorPreview` tint.
const [ghostPose, setGhostPose] = useState<{
position: [number, number, number]
rotationY: number
tint: 'valid' | 'invalid'
// The door's facing side at the cursor. R-flip changes it mid-placement and
// the door geometry's swing/hinge depends on it, so the ghost must rebuild
// with the LIVE side — otherwise the preview shows the pre-flip orientation
// while commit places the flipped one.
side: DoorNode['side']
} | null>(null)
// Ghost preview node: the moving door with a zeroed transform + the live
// facing side. `updateDoorMesh` bakes `position`/`rotation` into the mesh (the
// `<group>` wrapper already places it, so we zero those to avoid a double
// offset) and reads `side` for the swing/hinge direction — so the ghost
// matches exactly what commit will place, including an R-flip. Falls back to
// the moving node's own side when no pose is active.
const ghostSide = ghostPose?.side ?? movingDoorNode.side
const ghostNode = useMemo(
() => ({
...movingDoorNode,
side: ghostSide,
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}),
[movingDoorNode, ghostSide],
)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -76,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata,
// Free-follow hides the node (visible:false); every revert path must
// restore the original visibility or an existing door cancelled over open
// floor would stay invisible.
visible: movingDoorNode.visible,
}
if (!isNew) {
@@ -95,6 +134,35 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
let freeFollowing = false
let lastMeshEventTime = -1
// Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change
// while free-following can re-run the ghost at the same spot with the new
// facing/tint — no pointer move required.
let lastFloorPoint: [number, number] | null = null
// Live Shift state (force-place). Tracked here so the preview tint can be
// re-evaluated when Shift is pressed/released with the pointer stationary —
// the stored WallEvent carries a STALE shiftKey from the last move.
let shiftHeld = false
// Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a
// grid step — identical whether free-following over open floor or sliding
// along a wall, so the two feel the same (the user's ask). Always keyed on
// the RAW cursor position (continuous ~0.1m cadence), never the snapped
// along-wall value, so the wall slide ticks at the same rate as the ghost.
// Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when
// the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM
// pointermove — a wall mesh can emit `wall:move` more than once per move, and
// the grid + wall paths can both run). No separate snap cue: a distinct
// floor→wall sound was the "double" the user heard.
const STEP_M = 0.1
let lastStepKey: string | null = null
let lastTickFrame = -1
const tickGridStep = (frame: number, ...coords: number[]) => {
if (frame === lastTickFrame) return
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key === lastStepKey) return
lastStepKey = key
lastTickFrame = frame
triggerSFX('sfx:grid-snap')
}
// The door's chosen facing side. R flips it mid-placement (front ↔ back,
// same as the committed-selected R flip) so the user can reorient before
// committing. Initialised from the moving node's side.
@@ -104,7 +172,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: string
side: DoorNode['side']
itemRotation: number
cursorRotation: number
clampedX: number
clampedY: number
valid: boolean
@@ -143,6 +210,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setGhostPose(null)
}
// Alignment candidates — anchors of every OTHER alignable object (the
@@ -172,8 +240,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
return {
side,
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
cursorRotation:
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
}
}
@@ -185,7 +251,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
if (event.node.parentId !== getLevelId()) return
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
const { side, itemRotation } = getPlacementOrientation(event)
const rawLocalX = event.localPosition[0]
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
@@ -201,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX,
width: movingDoorNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
// Alt still hard-disables alignment (no guides). Shift = free-place:
// land at the raw cursor but keep showing the alignment guides.
bypass: event.nativeEvent?.altKey === true,
freePlace: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -225,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
@@ -234,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
// Same click as the off-wall ghost: one grid-snap tick per grid step,
// keyed on the RAW cursor along-wall position (not the snapped clampedX,
// whose ~0.5m jumps would tick at a different cadence). Per-frame guard
// collapses any duplicate wall events on the same pointermove.
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
// green when placeable, red when it collides — the same translucent ghost
// the free-follow uses, so validity reads at a glance. The node position is
// still written (so the wall cuts the hole at the right spot) but
// `visible:false` keeps the pale solid mesh from competing with the ghost.
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingDoorNode.id, {
position: [target.clampedX, target.clampedY, 0],
@@ -243,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
markHostDirty(currentHostId)
currentHostId = target.wallId
@@ -260,17 +338,37 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
})
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
// Position the tinted ghost at the wall opening (world frame), facing the
// wall normal + the live side (so an R-flip shows correctly). The
// wireframe cursor is no longer used on a wall. Tint comes from the SHARED
// placement decision — green when placeable (incl. Shift force-place over a
// collision), red otherwise — the SAME `placeable` the commit gate uses.
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
const placement = resolveOpeningPlacement({
collides: !target.valid,
forcePlace: shiftHeld,
})
// The committed door is a CHILD of the wall mesh (group yaw = -wallAngle)
// with wall-local `itemRotation` (0 front / π back). The ghost is a
// scene-root world-space group, so its world yaw must be
// `-wallAngle + itemRotation` to face the same way as commit.
// `cursorRotation` (the old symmetric-wireframe yaw) is π off here.
const wallAngle = Math.atan2(
target.wallNode.end[1] - target.wallNode.start[1],
target.wallNode.end[0] - target.wallNode.start[0],
)
setGhostPose({
position: wallLocalToWorld(
target.wallNode,
target.clampedX,
target.clampedY,
getLevelYOffset(),
getSlabElevation(target.event),
),
target.cursorRotation,
target.valid,
)
rotationY: target.itemRotation - wallAngle,
tint: placement.tint,
side: target.side,
})
publishOpeningGuidesForWallEvent({
wall: target.wallNode,
@@ -351,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
// The moving node is hidden during free-follow; the committed door
// must be visible regardless of the pre-commit free-follow state.
visible: true,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -364,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -375,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== target.wallId) {
@@ -400,7 +503,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
// Shift force-places: commit even when the door overlaps another opening.
// The preview keeps its red invalid tint as a warning; Shift just lifts the
// commit block. Read shift from THIS event so it's never stale at commit.
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
commitToWall(target)
event.stopPropagation()
}
@@ -420,13 +527,29 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
lastRoofEvent = null
}
// Free-follow: the door rides the cursor over empty floor, parented to the
// level like an item node (lifted so it stands on the floor). No wall to
// attach to, so it is not committable here.
const freeFollowAt = (localX: number, localZ: number) => {
// Reveal the real door node + drop the ghost. Used by the roof-face path,
// which previews with the real mesh (the ghost-tint flow is wall-specific).
const revealRealNode = () => {
setGhostPose(null)
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
if (live && live.visible === false) {
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
}
}
// Free-follow: over open floor there's no wall to host the door, so instead
// of dragging the real (pale, near-invisible-on-grid) node around we hide it
// and float a red translucent ghost at the cursor — same treatment the raw
// `DoorTool` build path uses. The node still re-parents to the level so a
// later wall-snap / commit has a clean base, but stays `visible:false` until
// a wall is hovered.
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
freeFollowing = true
lastTarget = null
lastRoofEvent = null
// Click per grid cell as the ghost slides over open floor (X+Z) — the
// same `tickGridStep` the on-wall slide uses, so both feel identical.
tickGridStep(frame, localX, localZ)
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
const levelId = getLevelId()
@@ -445,6 +568,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
currentHostId = levelId
} else {
@@ -452,8 +576,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [localX, y, localZ],
rotation: [0, yaw, 0],
side: sideOverride,
visible: false,
})
}
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted so
// it stands on the floor, matching the door's chosen facing (sideOverride
// carries the R-flip so the ghost swing direction matches commit).
setGhostPose({
position: [localX, getLevelYOffset() + y, localZ],
rotationY: yaw,
tint: 'invalid',
side: sideOverride,
})
}
const onGridMove = (event: GridEvent) => {
@@ -468,7 +602,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// so snapping engages only when the cursor ray actually hovers a wall
// (`onWallMove`). Over open floor the door just follows the cursor.
const [x, , z] = event.localPosition
freeFollowAt(x, z)
lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -505,6 +640,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
// On a roof face the real mesh is the preview — drop the free-follow ghost
// and reveal the node.
revealRealNode()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
@@ -514,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
visible: true,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
@@ -531,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target too (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
committed = true
const segmentId = target.segment.id
@@ -553,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
visible: true,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
@@ -566,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -578,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== segmentId) {
@@ -622,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
@@ -633,8 +778,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
// Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick.
if (lastTarget?.valid && !freeFollowing) {
// target commits via commitToWall; a roof face via onRoofClick. Shift
// force-places over a colliding wall target (the tint stays red as a
// warning); read shift from this pointerup so it's current at commit.
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
commitToWall(lastTarget)
return
}
@@ -655,23 +802,30 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
) {
return
}
// Only act where a flip is meaningful — on a wall hover or while
// free-following. On a roof face leave it to the default R (no flip, no
// sfx) so the cue never fires without a visible effect.
const onWall = lastTarget !== null
if (!(onWall || freeFollowing)) return
// Ignore OS key-repeat so a held R doesn't flip many times per press.
if (e.repeat) return
e.preventDefault()
// ALWAYS toggle the persistent flip intent — never a no-op. (The old gate
// dropped R before the first pointermove, so initial-placement R needed a
// second press.) Then re-render whatever preview is current so the flip
// shows live and matches what commit will write.
sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate')
if (onWall) {
if (lastTarget) {
// On a wall: re-resolve with the flipped side and re-preview.
const next = resolveMoveTarget(lastTarget!.event)
const next = resolveMoveTarget(lastTarget.event)
if (next) {
lastTarget = next
applyPreview(next)
}
} else if (lastFloorPoint) {
// Free-following: re-run at the same spot so the floating ghost rebuilds
// with the flipped side (its swing/hinge geometry depends on `side`).
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
} else {
// Free-following on the level: flip the draft's facing in place.
// No preview yet (R pressed before the first pointermove at initial
// placement): flip the hidden node so the FIRST preview/commit already
// reflects the chosen side.
useScene.getState().updateNode(movingDoorNode.id, {
side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
@@ -679,6 +833,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
}
// Shift toggles force-place. Track it live and re-run the on-wall preview so
// the tint flips green↔red the instant Shift is pressed/released, even with
// the pointer stationary — the ghost and the commit gate read the same
// `placeable`. (Commit gates still read shift fresh from their own event.)
const onShiftToggle = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
const held = e.type === 'keydown'
if (held === shiftHeld) return
shiftHeld = held
if (!committed && lastTarget) applyPreview(lastTarget)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -691,6 +857,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
@@ -711,9 +879,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
} else if (current && current.visible === false) {
// Safety net: a fresh (isNew) clone isn't marked `isTransient`, so the
// branch above skips it. If we unmount mid-free-follow it would be left
// hidden — reveal it so it never becomes an invisible orphan. (The
// `place-preset` movingNode subscription deletes a truly-cancelled
// clone separately.)
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
}
useLiveTransforms.getState().clear(movingDoorNode.id)
useAlignmentGuides.getState().clear()
@@ -731,6 +907,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
}
}, [movingDoorNode, exitMoveMode])
@@ -747,9 +925,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
{/* Placement ghost shown for the whole move (the real pale node stays
hidden): red off-wall / colliding, green on a valid wall. Uses the
moving node's own dimensions so the ghost matches its type. */}
{ghostPose && (
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
<DoorPreview
invalid={ghostPose.tint === 'invalid'}
node={ghostNode}
valid={ghostPose.tint === 'valid'}
/>
</group>
)}
</>
)
}
+12 -4
View File
@@ -16,16 +16,24 @@ import type { DoorNode } from './schema'
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
*/
const DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => {
const DoorPreview = ({
node,
invalid,
valid,
}: {
node: DoorNode
invalid?: boolean
valid?: boolean
}) => {
const mesh = useMemo(() => {
const m = buildDoorPreviewMesh(node)
m.layers.set(EDITOR_LAYER)
return m
}, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount])
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
// flips; its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
+49 -13
View File
@@ -74,13 +74,25 @@ const DoorTool: React.FC = () => {
// 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.
// when a draft + wireframe is shown this is null and vice-versa. `side`
// carries the R-flip so the floating ghost faces the side that will be
// committed (its swing/hinge geometry depends on `side`).
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
side: DoorNode['side']
} | null>(null)
const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), [])
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
const ghostStub = useMemo(
() =>
DoorNode.parse({
position: [0, 0, 0],
rotation: [0, 0, 0],
side: fallbackPose?.side ?? 'front',
}),
[fallbackPose?.side],
)
useEffect(() => {
useScene.temporal.getState().pause()
@@ -98,6 +110,9 @@ const DoorTool: React.FC = () => {
// re-applied to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
// Last open-floor cursor point (level-local X/Z) so an R-flip while
// free-following can re-render the floating ghost with the new facing.
let lastFloorPoint: [number, number, number] | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
@@ -149,9 +164,15 @@ const DoorTool: React.FC = () => {
// Off-host fallback: hide the wireframe outline and float the real door
// geometry (tinted invalid) at the cursor so the armed tool is visible.
// `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating
// ghost, and the door swing/hinge geometry reads `side`.
const showGhostAt = (position: [number, number, number]) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
setFallbackPose({
position,
rotationY: sideFlip ? Math.PI : 0,
side: sideFlip ? 'back' : 'front',
})
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
@@ -176,13 +197,16 @@ const DoorTool: React.FC = () => {
bypassSnap: boolean,
ignoreId?: string,
) => {
// bypassSnap is set by Shift (see callers). Shift = free-place: land at the
// raw cursor but keep the along-wall guides visible. bypass (Alt) still
// hard-disables alignment.
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass,
bypassSnap,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -403,7 +427,8 @@ const DoorTool: React.FC = () => {
bypassSnap,
draftRef.current.id,
)
if (!valid) return
// Shift force-places over a collision (the draft stays red as a warning).
if (!valid && !bypassSnap) return
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
@@ -433,8 +458,9 @@ const DoorTool: React.FC = () => {
hostKind = null
lastWallEvent = null
const [x, y, z] = event.localPosition
lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z]
destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2, z])
showGhostAt(lastFloorPoint)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -497,7 +523,9 @@ const DoorTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current
@@ -568,18 +596,26 @@ const DoorTool: React.FC = () => {
}
// 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.
// committed-selected R flip. ALWAYS toggles the persistent flip intent —
// never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before
// the first wall hover, so it "needed two presses"). Then re-renders whatever
// preview is current so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (!lastWallEvent) return
if (e.repeat) 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)
if (lastWallEvent) {
// On a wall: re-resolve + re-preview with the flipped side.
onWallHover(lastWallEvent)
} else if (lastFloorPoint) {
// Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`).
showGhostAt(lastFloorPoint)
}
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
}
emitter.on('wall:enter', onWallHover)