fix(editor): harden editor interactions and WebGPU rendering

Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
This commit is contained in:
Aymeric Rabot
2026-06-11 13:03:34 -04:00
committed by GitHub
parent 2d2dba5dba
commit aab48e053f
111 changed files with 2211 additions and 955 deletions
@@ -14,7 +14,6 @@ import {
isSegmentLongEnough,
snapFenceDraftPoint,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
} from '@pascal-app/editor'
/**
@@ -165,14 +164,13 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
preview: (ctx, point, modifiers) => {
const planPoint: FencePlanPoint = [point[0], point[1]]
// Endpoint move = grid snap only; the 45°-from-start angle snap
// is draft-only. Shift switches to the fine grid step for
// precision, mirroring the wall convention.
// is draft-only. Shift is a hard snap bypass.
const snapped = snapFenceDraftPoint({
point: planPoint,
walls: ctx.levelWalls,
fences: ctx.levelFences,
ignoreFenceIds: [ctx.fenceId as string],
step: modifiers.shift ? WALL_FINE_GRID_STEP : undefined,
bypassSnap: modifiers.shift,
})
// Figma-style alignment: nudge the dragged endpoint onto another wall /
@@ -180,7 +178,7 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
// guide. The resolver connects to the NEAREST real anchor, so the dot
// always sits on an actual point. Alt is reserved for detach.
let aligned = snapped
if (ctx.alignCandidates.length > 0) {
if (!modifiers.shift && ctx.alignCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId: ctx.fenceId as string, kind: 'corner', x: snapped[0], z: snapped[1] }],
candidates: ctx.alignCandidates,
@@ -190,6 +188,8 @@ export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveF
aligned = [snapped[0] + ar.snap.dx, snapped[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
} else {
useAlignmentGuides.getState().clear()
}
const nextStart = ctx.endpoint === 'start' ? aligned : ctx.fixedPoint
+5 -3
View File
@@ -89,11 +89,12 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep()
const localX = shiftPressedRef.current
const localX = bypassSnap
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
const localZ = bypassSnap
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
@@ -101,7 +102,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
const snappedOffset = bypassSnap
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
@@ -110,6 +111,7 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
)
if (
!bypassSnap &&
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
+1 -1
View File
@@ -223,7 +223,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
toolHints: [
{ key: 'Left click', label: 'Set fence start / end' },
{ key: 'Shift', label: 'Allow non-45° angles' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -20,7 +20,6 @@ import {
snapFenceDraftPoint,
snapScalarToGrid,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
} from '@pascal-app/editor'
@@ -159,16 +158,15 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
const sceneNodes = useScene.getState().nodes
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
// Endpoint move = grid snap only; the 45°-from-start angle
// snap is draft-only. Shift switches to the fine grid step for
// precision, matching the 3D fence endpoint action.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
// snap is draft-only. Shift bypasses grid, magnetic, and alignment snap.
const snapped = snapFenceDraftPoint({
point: planPoint as FencePlanPoint,
walls: nextWalls,
fences: nextFences,
ignoreFenceIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep) as FencePlanPoint,
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP) as FencePlanPoint,
})
// Figma-style alignment on the dragged endpoint — snaps it onto
// another object's edge / wall face and publishes a guide, matching
@@ -176,6 +174,7 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
// siblings (which cascade with the endpoint) are excluded from the
// candidate pool. Alt is reserved for detach here, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
excludeIds: [node.id, ...linkedOriginals.map((l) => l.id)],
}) as FencePlanPoint
const nextStart = endpoint === 'start' ? aligned : fixedPoint
@@ -1,6 +1,13 @@
'use client'
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
import {
emitter,
type FenceNode,
type GridEvent,
getWallCurveLength,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
CursorSphere,
type FencePlanPoint,
@@ -121,13 +128,43 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const movingPoint = endpoint === 'start' ? liveStart : liveEnd
// Ticker SFX on each grid-snap step, mirroring the wall endpoint tool.
// The action snaps the point before writing to the scene, so `movingPoint`
// only changes in discrete grid steps — the right cadence for the click.
// First tick just seeds the ref (no sound on mount).
// First tick just seeds the ref (no sound on mount). The drag action receives
// the Shift modifier through grid events, so mirror that modifier here to
// avoid playing grid ticks while snap is bypassed.
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
useEffect(() => {
const onGridMove = (event: GridEvent) => {
shiftPressedRef.current = event.nativeEvent?.shiftKey === true
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') shiftPressedRef.current = true
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') shiftPressedRef.current = false
}
const onBlur = () => {
shiftPressedRef.current = false
}
emitter.on('grid:move', onGridMove)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
}
}, [])
useEffect(() => {
const prev = previousGridPosRef.current
if (prev && (prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])) {
if (
!shiftPressedRef.current &&
prev &&
(prev[0] !== movingPoint[0] || prev[1] !== movingPoint[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = movingPoint
+3
View File
@@ -193,14 +193,17 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = event.nativeEvent?.shiftKey === true
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [fenceId],
bypassSnap,
})
if (
!bypassSnap &&
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
+44 -13
View File
@@ -30,7 +30,7 @@ import {
triggerSFX,
useAlignmentGuides,
useEditor,
WALL_FINE_GRID_STEP,
useSegmentDraftChain,
} from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
@@ -485,6 +485,7 @@ export const FenceTool: React.FC = () => {
buildingState.current = 0
previewRef.current.visible = false
setDraftMeasurement(null)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear()
}
@@ -492,20 +493,29 @@ export const FenceTool: React.FC = () => {
if (!(cursorRef.current && previewRef.current)) return
const { walls, fences } = getCurrentLevelElements()
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default = active grid step; Shift switches to the fine step
// (0.05m). No 45° angle snap — see `wall/tool.tsx` for rationale.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
// While drafting, the segment locks to 15° rays from its start
// unless Shift is held. Shift also bypasses grid and magnetic snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const snappedLocal = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
bypassAlign,
snapFenceDraftPoint({
point: localPoint,
walls,
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
}),
bypassAlign || angleLocked,
)
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
cursorRef.current.position.copy(endingPoint.current)
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousFenceEnd &&
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
) {
@@ -532,7 +542,7 @@ export const FenceTool: React.FC = () => {
)
} else {
const snappedPoint = alignPoint(
snapFenceDraftPoint({ point: localPoint, walls, fences, step }),
snapFenceDraftPoint({ point: localPoint, walls, fences, bypassSnap }),
bypassAlign,
)
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
@@ -548,12 +558,12 @@ export const FenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements()
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const clickStep = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
if (buildingState.current === 0) {
const snappedStart = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
snapFenceDraftPoint({ point: localClick, walls, fences, bypassSnap }),
bypassAlign,
)
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
@@ -563,9 +573,17 @@ export const FenceTool: React.FC = () => {
previewRef.current.visible = true
setDraftMeasurement(null)
} else {
const angleLocked = !bypassSnap
const snappedEnd = alignPoint(
snapFenceDraftPoint({ point: localClick, walls, fences, step: clickStep }),
bypassAlign,
snapFenceDraftPoint({
point: localClick,
walls,
fences,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
}),
bypassAlign || angleLocked,
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
@@ -582,6 +600,10 @@ export const FenceTool: React.FC = () => {
useAlignmentGuides.getState().clear()
const nextStart = createdFence.end
// Publish the resolved chain start so the 2D floor-plan draft
// chains its next segment from the same point (its own snap
// pipeline can resolve a slightly different endpoint).
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
endingPoint.current.copy(startingPoint.current)
cursorRef.current?.position.copy(startingPoint.current)
@@ -599,6 +621,12 @@ export const FenceTool: React.FC = () => {
if (e.key === 'Shift') shiftPressed.current = false
}
// Cmd-tabbing away mid-draft never delivers the keyup — reset so the
// angle lock isn't stuck off when focus returns.
const onBlur = () => {
shiftPressed.current = false
}
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
@@ -611,6 +639,7 @@ export const FenceTool: React.FC = () => {
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onBlur)
return () => {
emitter.off('grid:move', onGridMove)
@@ -618,6 +647,8 @@ export const FenceTool: React.FC = () => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useSegmentDraftChain.getState().clear('fence')
useAlignmentGuides.getState().clear()
}
}, [unit])