fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration

The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move
(freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move
(DOM) de-dup compared event.timeStamp across two event systems with different
clocks, so it never matched and the floor free-follow ran during on-wall
slides too, ping-ponging the host and churning the nodes ref → framerate
collapse in both 2D and 3D. Replace it with a single-clock wall-ownership
window (performance.now, ~4 frames): the floor follow stands down while a
wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh +
useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that
the per-frame churn is gone.

Also completes the door/window modifier-model migration (#10): Shift=cycle /
Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude
ground-line candidates from along-wall opening alignment; emit the move SFX
once per snapped step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 10:21:37 -04:00
co-authored by Claude Opus 4.8
parent 8a57105eec
commit 76096ffe72
13 changed files with 486 additions and 439 deletions
+3
View File
@@ -166,6 +166,7 @@ const doorHandles: HandleDescriptor<DoorNodeType>[] = [
*/
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
kind: 'door',
snapProfile: 'item',
schemaVersion: 1,
schema: DoorNode,
category: 'structure',
@@ -251,6 +252,8 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
toolHints: [
{ key: 'Left click', label: 'Place door on wall' },
{ key: 'R', label: 'Flip side' },
{ key: 'Alt', label: 'Force place' },
{ key: 'Esc', label: 'Cancel' },
],
+77 -64
View File
@@ -3,12 +3,20 @@ import {
type DoorNode,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
type WallNode,
WallNode as WallNodeSchema,
} from '@pascal-app/core'
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import {
isGridSnapActive,
isMagneticSnapActive,
snapToHalf,
triggerSFX,
useEditor,
usePlacementPreview,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import {
@@ -36,6 +44,7 @@ import { clampToWall, hasWallChildOverlap } from './door-math'
*/
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
const nodeId = node.id as AnyNodeId
// Snapshot of the door's "valid" state at move-start — used by
// canCommit to decide whether the current snapped position is OK.
// The level that owns the wall-snap candidates — resolves the wall-hosted,
@@ -72,6 +81,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
wallId: string
roofSegmentId: undefined
roofFace: undefined
visible: true
} | null = null
// R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives
@@ -88,20 +98,30 @@ 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-
// Alt force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move. Read in `canCommit` so an Alt-
// held commit over a collision lands instead of reverting.
let forcePlace = false
let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId)
let liveOverrideKey: string | null = null
let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId
const setLiveOverride = (key: string, values: Record<string, unknown>) => {
if (liveOverrideKey === key) return
liveOverrideKey = key
useLiveNodeOverrides.getState().set(nodeId, values)
}
// 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
// each time the door's PLACED position crosses a step. Keyed on the SNAPPED
// position, quantized by the live grid step in grid mode else a gentle fixed
// cadence — so grid mode ticks once per cell (not on every micro mouse-move
// while the door sits in a cell) while lines/off still tick as the door moves.
const FREE_STEP_M = 0.1
let lastStepKey: string | null = null
const tickGridStep = (...coords: number[]) => {
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M
const key = coords.map((c) => Math.round(c / step)).join(',')
if (key !== lastStepKey) {
lastStepKey = key
triggerSFX('sfx:grid-snap')
@@ -115,9 +135,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
const freeFollow = (planPoint: readonly [number, number]) => {
onWall = false
lastValid = null
if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible) {
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
if (liveTransformActive) {
useLiveTransforms.getState().clear(nodeId)
liveTransformActive = false
}
setLiveOverride('free-follow', { visible: false })
const half = node.width / 2 + 0.5
const wall = WallNodeSchema.parse({
start: [planPoint[0] - half, planPoint[1]],
@@ -144,28 +166,18 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
visible: true,
} as DoorNode
usePlacementPreview.getState().set(ghost, wall)
placementPreviewActive = true
}
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
affectedIds: [nodeId],
flipSide() {
flipped = !flipped
if (lastApply) this.apply(lastApply)
},
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,
// but this 2D move writes the scene node — so a leftover 3D entry would
// freeze the symbol on its wall and the slide wouldn't show. Only the
// 2D path runs during an opening move (the panel gates the 3D tool's
// events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded
// on existence: `clear` always allocates a new Map + re-renders.
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
useLiveTransforms.getState().clear(node.id as AnyNodeId)
}
forcePlace = modifiers.altKey === true
const nodes = useScene.getState().nodes
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
@@ -178,32 +190,30 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
}
// Back on a wall — drop the free-follow ghost + reveal the real node.
onWall = true
usePlacementPreview.getState().clear()
if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) {
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
if (placementPreviewActive) {
usePlacementPreview.getState().clear()
placementPreviewActive = false
}
// Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends); it competes with — and wins over — the 0.5m
// grid snap. Falls back to the grid snap when nothing aligns. Alt
// bypasses alignment; Shift bypasses all snap.
const neighborX =
modifiers.altKey || modifiers.shiftKey
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width: node.width,
selfId: node.id as AnyNodeId,
nodes,
})
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
// openings / wall ends); it competes with — and wins over — the grid
// snap. Follows the magnetic ("lines") mode; the grid component lives in
// `snapToHalf` (mode-aware → raw when grid is off).
const neighborX = !isMagneticSnapActive()
? null
: snapLocalXToNeighbors({
wall: hit.wall,
localX: hit.localX,
width: node.width,
selfId: nodeId,
nodes,
})
const snappedLocalX = neighborX ?? 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)
// One click per real position step, keyed on the SNAPPED along-wall value
// so it ticks only when the door actually moves to a new cell.
tickGridStep(clampedX)
// Apply the R-flip on top of the wall-derived side.
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
@@ -219,35 +229,38 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
visible: true,
}
// Build the updates atomically — position + rotation + side +
// parentId + wallId in a single scene write. The current door's
// parent might be a different wall; re-anchoring requires moving
// the node in the parent's children list (the registry's
// updateNode does this when parentId changes).
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
data: lastValid,
},
])
setLiveOverride(`wall:${hit.wall.id}:${side}`, {
parentId: hit.wall.id,
wallId: hit.wall.id,
side,
roofSegmentId: undefined,
roofFace: undefined,
visible: true,
})
useLiveTransforms.getState().set(nodeId, {
position: lastValid.position,
rotation: itemRotation,
})
liveTransformActive = true
},
canCommit() {
// Off-wall the door is free-following in mid-air — not placeable. The
// overlay then reverts to the pre-move snapshot (door returns to its
// original wall), matching the 3D move where an open-floor click commits
// nothing.
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 another wall child — UNLESS Shift
if (!onWall || !lastValid) return false
const live = useScene.getState().nodes[nodeId] as DoorNode | undefined
if (live?.type !== 'door') return false
// Block commit if the door overlaps another wall child — UNLESS Alt
// 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],
lastValid.parentId,
lastValid.position[0],
lastValid.position[1],
live.width,
live.height,
live.id,
@@ -266,7 +279,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
if (!lastValid) return
useScene.getState().updateNodes([
{
id: node.id as AnyNodeId,
id: nodeId,
data: lastValid,
},
])
+97 -73
View File
@@ -1,6 +1,5 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
DoorNode,
emitter,
type GridEvent,
@@ -18,6 +17,8 @@ import {
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isGridSnapActive,
isMagneticSnapActive,
isValidWallSideFace,
stripPlacementMetadataFlags,
triggerSFX,
@@ -38,7 +39,10 @@ import {
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import {
collectWallOpeningAlignmentCandidates,
resolveWallSlideAlignment,
} from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
@@ -129,35 +133,48 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// Off-wall free-follow: when the cursor is over empty floor (no wall under
// the ray) the door is parented to the level and tracks the cursor like an
// item node. `freeFollowing` distinguishes that state so the placement
// commit no-ops in open space (a door needs a wall). `lastMeshEventTime`
// defers the floor handler whenever a wall/roof mesh event owns the same
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
// commit no-ops in open space (a door needs a wall).
let freeFollowing = false
let lastMeshEventTime = -1
// Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change
// Last open-floor cursor point (level-local X/Z), so an R-flip or Alt 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
// The floor free-follow (`grid:move`, a DOM event) and the wall/roof snap
// (`wall:move`/`roof:move`, R3F mesh events) are INDEPENDENT event streams
// with different clocks, so the old `event.timeStamp` de-dup never matched —
// the free-follow ran during on-wall slides too, and both wrote the scene
// node every frame (a per-frame `nodes` churn that tanked 2D + 3D framerate).
// Instead, stamp one monotonic clock whenever a wall/roof hit owns the
// pointer; the floor handler stands down while that stamp is fresh. `wall:move`
// fires every frame on-wall, so the stamp stays fresh across the pointermove
// interval and the free-follow only re-engages once the cursor is off any wall.
let wallOwnedPointerAt = Number.NEGATIVE_INFINITY
// ~4 frames: comfortably longer than the pointermove interval (so a fast
// on-wall slide never lets the floor follow slip through) yet short enough
// that leaving a wall re-engages the free-follow without a perceptible stick.
const WALL_OWNS_POINTER_MS = 64
const markWallOwnedPointer = () => {
wallOwnedPointerAt = performance.now()
}
const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS
// Live Alt state (force-place). Tracked here so the preview tint can be
// re-evaluated when Alt is pressed/released with the pointer stationary —
// the stored WallEvent carries a STALE altKey from the last move.
let altHeld = false
// Movement SFX: ONE soft `sfx:grid-snap` click each time the door's PLACED
// position crosses a step. Keyed on the SNAPPED value (passed by the caller),
// quantized by the live grid step in grid mode, else a gentle fixed cadence —
// so grid mode ticks once per cell (not on every micro mouse-move while the
// door sits in a cell) while lines/off still tick as the door moves. Two
// guards prevent a doubled cue: `lastStepKey` (cell change) + `lastTickFrame`
// (one per pointermove — wall + grid paths can both run on the same move).
const FREE_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(',')
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M
const key = coords.map((c) => Math.round(c / step)).join(',')
if (key === lastStepKey) return
lastStepKey = key
lastTickFrame = frame
@@ -188,7 +205,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const now = globalThis.performance?.now?.() ?? Date.now()
const last = lastHostDirtyAt.get(hostId) ?? 0
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
if (now - last > 120) {
if (now - last > 60) {
lastHostDirtyAt.set(hostId, now)
markHostDirty(hostId)
}
@@ -213,9 +230,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
setGhostPose(null)
}
// Alignment candidates — anchors of every OTHER alignable object (the
// moving door is excluded so it never aligns to itself).
const alignmentCandidates = collectAlignmentAnchors(
// Alignment candidates — only OTHER things on a wall (sibling openings +
// wall-mounted items), never ground objects, so the along-wall guides don't
// line up with furniture on the floor. The moving door is excluded.
const alignmentCandidates = collectWallOpeningAlignmentCandidates(
useScene.getState().nodes,
movingDoorNode.id,
)
@@ -267,10 +285,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rawLocalX: targetLocalX,
width: movingDoorNode.width,
candidates: alignmentCandidates,
// 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,
// Along-wall alignment follows the magnetic ("lines") mode; the grid
// component lives in `snapToHalf` (itself mode-aware).
bypass: !isMagneticSnapActive(),
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -301,11 +318,11 @@ 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])
// One grid-snap tick per real position step, keyed on the SNAPPED
// along-wall position so it ticks only when the door actually moves to a
// new cell (not on every micro mouse-move). Per-frame guard collapses any
// duplicate wall events on the same pointermove.
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX)
// 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
@@ -341,12 +358,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// 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
// placement decision — green when placeable (incl. Alt 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,
forcePlace: altHeld,
})
// The committed door is a CHILD of the wall mesh (group yaw = -wallAngle)
// with wall-local `itemRotation` (0 front / π back). The ghost is a
@@ -385,12 +402,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const onWallEnter = (event: WallEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveMoveTarget(event)
if (!target) {
onWallLeave()
return
}
// Valid wall hit owns the pointer for the next few frames; the floor
// free-follow stands down until the cursor genuinely leaves the wall.
markWallOwnedPointer()
freeFollowing = false
lastTarget = target
lastRoofEvent = null
@@ -399,7 +418,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const onWallMove = (event: WallEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
@@ -418,6 +436,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
onWallLeave()
return
}
// Valid wall hit owns the pointer for the next few frames; the floor
// free-follow stands down until the cursor genuinely leaves the wall.
markWallOwnedPointer()
freeFollowing = false
lastTarget = target
lastRoofEvent = null
@@ -503,11 +524,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)
// 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.
// Alt force-places: commit even when the door overlaps another opening.
// The preview keeps its red invalid tint as a warning; Alt just lifts the
// commit block. Read alt from THIS event so it's never stale at commit.
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
if (!target.valid && event.nativeEvent?.altKey !== true) return
commitToWall(target)
event.stopPropagation()
}
@@ -543,13 +564,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// `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) => {
const freeFollowAt = (localX: number, localZ: 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)
// No snap SFX here: the free-follow fires off-wall (an invalid red ghost,
// not a placeable position) AND interleaves with the on-wall slide on the
// same pointer move (R3F `wall:move` and DOM `grid:move` carry different
// timestamps, so the de-dupe guard can't merge them). Emitting here was the
// source of the constant click while sliding a door along a wall — the
// on-wall `applyPreview` already ticks once per along-wall cell.
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
const levelId = getLevelId()
@@ -593,17 +617,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onGridMove = (event: GridEvent) => {
if (committed) return
if (useViewer.getState().cameraDragging) return
// A wall/roof mesh handler owns this exact pointermove (shared DOM
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
// the cursor is over open floor — free-follow it.
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
// No proximity magnet: in 3D the wall side faces are big raycast targets,
// so snapping engages only when the cursor ray actually hovers a wall
// (`onWallMove`). Over open floor the door just follows the cursor.
// (`onWallMove`). A wall/roof handler owning the pointer right now means the
// cursor is on a wall/roof that snaps — skip the floor follow (see
// `wallOwnsPointer`). Over open floor the door just follows the cursor.
if (wallOwnsPointer()) return
const [x, , z] = event.localPosition
lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
freeFollowAt(x, z)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -626,12 +648,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const onRoofHover = (event: RoofEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveRoofMoveTarget(event)
if (!target) {
onRoofLeave()
return
}
// Valid roof hit owns the pointer for the next few frames; the floor
// free-follow stands down until the cursor genuinely leaves the roof.
markWallOwnedPointer()
// Wall-frame drag anchor / live transform don't apply on a roof face.
freeFollowing = false
dragAnchor = null
@@ -670,9 +694,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
// Shift force-places over a colliding roof-face target too (see onWallClick).
// Alt force-places over a colliding roof-face target too (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
if (!target.valid && event.nativeEvent?.altKey !== true) return
committed = true
const segmentId = target.segment.id
@@ -778,10 +802,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. Shift
// target commits via commitToWall; a roof face via onRoofClick. Alt
// 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)) {
// warning); read alt from this pointerup so it's current at commit.
if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) {
commitToWall(lastTarget)
return
}
@@ -821,7 +845,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
} 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)
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1])
} else {
// No preview yet (R pressed before the first pointermove at initial
// placement): flip the hidden node so the FIRST preview/commit already
@@ -833,15 +857,15 @@ 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
// Alt toggles force-place. Track it live and re-run the on-wall preview so
// the tint flips green↔red the instant Alt 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
// `placeable`. (Commit gates still read alt fresh from their own event.)
const onAltToggle = (e: KeyboardEvent) => {
if (e.key !== 'Alt') return
const held = e.type === 'keydown'
if (held === shiftHeld) return
shiftHeld = held
if (held === altHeld) return
altHeld = held
if (!committed && lastTarget) applyPreview(lastTarget)
}
@@ -857,8 +881,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)
window.addEventListener('keydown', onAltToggle)
window.addEventListener('keyup', onAltToggle)
return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
@@ -907,8 +931,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)
window.removeEventListener('keydown', onAltToggle)
window.removeEventListener('keyup', onAltToggle)
}
}, [movingDoorNode, exitMoveMode])
+6 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { type DoorNode, useLiveNodeOverrides, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
@@ -16,6 +16,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'door')
const liveVisible = useLiveNodeOverrides((s) => {
const visible = s.get(node.id)?.visible
return typeof visible === 'boolean' ? visible : undefined
})
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const mesh = (
@@ -26,7 +30,7 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
receiveShadow
ref={ref}
rotation={node.rotation}
visible={node.visible}
visible={liveVisible ?? node.visible}
{...(isTransient ? {} : handlers)}
>
<boxGeometry args={[0, 0, 0]} />
+21 -30
View File
@@ -1,6 +1,5 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
DoorNode,
emitter,
type GridEvent,
@@ -18,6 +17,7 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isMagneticSnapActive,
isValidWallSideFace,
triggerSFX,
useAlignmentGuides,
@@ -36,7 +36,10 @@ import {
resolveRoofWallOpeningTarget,
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import {
collectWallOpeningAlignmentCandidates,
resolveWallSlideAlignment,
} from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
import DoorPreview from './preview'
@@ -143,7 +146,7 @@ const DoorTool: React.FC = () => {
// 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, '')
let alignmentCandidates = collectWallOpeningAlignmentCandidates(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
@@ -194,19 +197,17 @@ const DoorTool: React.FC = () => {
width: number,
height: number,
bypass: boolean,
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.
// `bypass` disables along-wall alignment — set when magnetic ("lines")
// snap is off. The grid component lives in `snapToHalf`, which is itself
// mode-aware (raw cursor when grid is off).
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
bypass,
})
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -224,9 +225,8 @@ const DoorTool: React.FC = () => {
itemRotation: number
cursorRotationY: number
bypass: boolean
bypassSnap: boolean
}) => {
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass } = args
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
@@ -249,7 +249,6 @@ const DoorTool: React.FC = () => {
width,
height,
bypass,
bypassSnap,
draftRef.current.id,
)
@@ -361,7 +360,7 @@ const DoorTool: React.FC = () => {
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
@@ -387,17 +386,13 @@ const DoorTool: React.FC = () => {
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,
bypass: !isMagneticSnapActive(),
})
event.stopPropagation()
}
@@ -415,20 +410,16 @@ const DoorTool: React.FC = () => {
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,
!isMagneticSnapActive(),
draftRef.current.id,
)
// Shift force-places over a collision (the draft stays red as a warning).
if (!valid && !bypassSnap) return
// Alt force-places over a collision (the draft stays red as a warning).
if (!valid && event.nativeEvent?.altKey !== true) return
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
@@ -448,9 +439,9 @@ const DoorTool: React.FC = () => {
// 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.
// A wall/roof mesh handler processed this pointermove (shared DOM
// 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
@@ -523,9 +514,9 @@ const DoorTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
// Shift force-places over a colliding roof-face target (see onWallClick).
// Alt force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
if (!target.valid && event.nativeEvent?.altKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current