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
@@ -1,8 +1,30 @@
import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core'
import {
type AlignmentAnchor,
type AnyNode,
collectAlignmentAnchors,
resolveAlignment,
type WallNode,
} from '@pascal-app/core'
import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor'
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
/**
* Alignment candidates for a wall opening (door / window): only OTHER things
* hosted ON a wall — sibling openings and wall-mounted items. Floor/ground
* objects are excluded so an opening's along-wall guides line up with what's on
* the walls, never with furniture sitting on the floor below.
*/
export function collectWallOpeningAlignmentCandidates(
nodes: Readonly<Record<string, AnyNode>>,
excludeId: string,
): AlignmentAnchor[] {
return collectAlignmentAnchors(nodes, excludeId).filter((anchor) => {
const parentId = (nodes[anchor.nodeId] as { parentId?: string } | undefined)?.parentId
return !!parentId && nodes[parentId]?.type === 'wall'
})
}
/**
* A wall opening (door / window) can only slide ALONG its host wall, so it can
* only satisfy an x- or z-guide when the wall runs along that axis. Below this
@@ -16,19 +38,14 @@ const MIN_AXIS_COMPONENT = 0.5
* Resolve a wall opening's along-wall position with Figma-style alignment to
* other objects, publishing the matching guide as a side effect.
*
* The probe is the RAW cursor position on the wall (not the 0.5m snap) so
* The probe is the RAW cursor position on the wall (not the grid snap) so
* off-grid anchors are caught; we then keep only the guide on an axis the wall
* runs along and map it to the along-wall coordinate that lands the opening on
* it. Falls back to the half-metre snap when nothing aligns, and clears the
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
* half-metre fallback.
*
* `freePlace` (Shift) is the "place anywhere, but still show me where I'd
* align" mode: the opening lands at the EXACT raw cursor (no grid snap, no
* jump-to-guide), yet the alignment guides are still computed and shown so the
* user keeps the visual reference while overriding the magnetic pull. It
* supersedes `bypass`/`bypassSnap` when set.
* it. Falls back to the grid snap when nothing aligns, and clears the guide on
* bypass / no-match. Returns the localX to use (X-clamped to the wall given
* `width`). `bypass` disables alignment — set by the caller when magnetic
* ("lines") snap is off; the grid component lives in `snapToHalf`, which is
* itself mode-aware (raw cursor when grid snap is off).
*/
export function resolveWallSlideAlignment(args: {
wallNode: WallNode
@@ -36,55 +53,9 @@ export function resolveWallSlideAlignment(args: {
width: number
candidates: readonly AlignmentAnchor[]
bypass: boolean
bypassSnap?: boolean
freePlace?: boolean
}): number {
const {
wallNode,
rawLocalX,
width,
candidates,
bypass,
bypassSnap = false,
freePlace = false,
} = args
const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX)
const dxAxis = wallNode.end[0] - wallNode.start[0]
const dzAxis = wallNode.end[1] - wallNode.start[1]
const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis)
// Shift / free-place: land at the raw cursor but still publish the guides so
// the user sees alignment relationships without being snapped to them. The
// guides are re-resolved at the freely-placed point so they connect to the
// opening, not the snap target.
if (freePlace) {
if (candidates.length === 0 || axisLength < 1e-6) {
useAlignmentGuides.getState().clear()
return base
}
const c = dxAxis / axisLength
const s = dzAxis / axisLength
const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base))
const shown = resolveAlignment({
moving: [
{
nodeId: '__wall-opening-draft__',
kind: 'corner',
x: wallNode.start[0] + placedX * c,
z: wallNode.start[1] + placedX * s,
},
],
candidates,
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
})
const axisGuides = shown.guides.filter(
(g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT,
)
if (axisGuides.length === 0) useAlignmentGuides.getState().clear()
else useAlignmentGuides.getState().set(axisGuides)
return placedX
}
const { wallNode, rawLocalX, width, candidates, bypass } = args
const base = snapToHalf(rawLocalX)
if (bypass || candidates.length === 0) {
useAlignmentGuides.getState().clear()