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:
co-authored by
Claude Opus 4.8
parent
8a57105eec
commit
76096ffe72
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user