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
+38 -53
View File
@@ -1,6 +1,5 @@
import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type GridEvent,
isCurvedWall,
@@ -18,6 +17,7 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
isMagneticSnapActive,
isValidWallSideFace,
snapToHalf,
triggerSFX,
@@ -38,7 +38,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 { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview'
import {
@@ -157,7 +160,7 @@ const WindowTool: React.FC = () => {
// Alignment candidates — anchors of every alignable object; refreshed
// after each placement. A window aligns by the plan position of its centre
// (along-wall only; the floor-plane guides don't cover sill height).
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
@@ -209,9 +212,11 @@ const WindowTool: React.FC = () => {
}
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
// 0.5m grid when within threshold; Shift bypasses both. `movingId` is the
// draft's id once it exists (so it's excluded from the sibling scan), or ''
// before the draft is created (nothing to exclude yet).
// grid when within threshold — it's the magnetic ("lines") component for the
// vertical axis, so it runs only when magnetic snap is on; otherwise the
// grid `snapToHalf` (itself mode-aware) decides Y. `movingId` is the draft's
// id once it exists (so it's excluded from the sibling scan), or '' before
// the draft is created (nothing to exclude yet).
const resolvePlacementY = (args: {
wall: WallNode
movingId: string
@@ -219,18 +224,18 @@ const WindowTool: React.FC = () => {
rawLocalY: number
width: number
height: number
bypassSnap: boolean
}): number => {
if (args.bypassSnap) return args.rawLocalY
const sillY = resolveSillSnap({
wall: args.wall,
movingId: args.movingId,
localX: args.localX,
localY: args.rawLocalY,
width: args.width,
height: args.height,
nodes: useScene.getState().nodes,
})
const sillY = isMagneticSnapActive()
? resolveSillSnap({
wall: args.wall,
movingId: args.movingId,
localX: args.localX,
localY: args.rawLocalY,
width: args.width,
height: args.height,
nodes: useScene.getState().nodes,
})
: null
return sillY ?? snapToHalf(args.rawLocalY)
}
@@ -242,19 +247,16 @@ const WindowTool: 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` (mode-aware).
const localX = resolveWallSlideAlignment({
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
bypass,
})
const localY = resolvePlacementY({
wall,
@@ -263,7 +265,6 @@ const WindowTool: React.FC = () => {
rawLocalY,
width,
height,
bypassSnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
@@ -282,18 +283,8 @@ const WindowTool: React.FC = () => {
itemRotation: number
cursorRotationY: number
bypass: boolean
bypassSnap: boolean
}) => {
const {
wall,
rawLocalX,
rawLocalY,
side,
itemRotation,
cursorRotationY,
bypass,
bypassSnap,
} = args
const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, bypass } = args
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
@@ -317,7 +308,6 @@ const WindowTool: React.FC = () => {
width,
height,
bypass,
bypassSnap,
draftRef.current.id,
)
@@ -423,7 +413,7 @@ const WindowTool: 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()
}
@@ -449,8 +439,6 @@ const WindowTool: 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,
@@ -459,8 +447,7 @@ const WindowTool: React.FC = () => {
side,
itemRotation,
cursorRotationY: cursorRotation,
bypass,
bypassSnap,
bypass: !isMagneticSnapActive(),
})
event.stopPropagation()
}
@@ -478,8 +465,6 @@ const WindowTool: 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,
@@ -487,12 +472,11 @@ const WindowTool: React.FC = () => {
event.localPosition[1],
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
commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
@@ -512,9 +496,9 @@ const WindowTool: 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
@@ -540,7 +524,8 @@ const WindowTool: React.FC = () => {
ignoreId: draftRef.current?.id,
vertical: {
kind: 'free',
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
// `snapToHalf` is mode-aware (raw cursor when grid snap is off).
snap: snapToHalf,
},
})
@@ -591,9 +576,9 @@ const WindowTool: 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