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
+4 -2
View File
@@ -85,11 +85,12 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const snapStep = getSegmentGridStep()
// Snap the cursor on the WORLD XZ grid (still in building-local
// coords for the rest of the math) so a rotated building doesn't
// pull the curve handle off the visible grid lines.
const [snappedLocalX, snappedLocalZ] = shiftPressedRef.current
const [snappedLocalX, snappedLocalZ] = bypassSnap
? [event.localPosition[0], event.localPosition[2]]
: snapBuildingLocalToWorldGrid([event.localPosition[0], event.localPosition[2]], snapStep)
const localX = snappedLocalX
@@ -99,7 +100,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ 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(
@@ -108,6 +109,7 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
)
if (
!bypassSnap &&
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
+1 -1
View File
@@ -108,7 +108,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
toolHints: [
{ key: 'Left click', label: 'Set wall start / end' },
{ key: 'Shift', label: 'Allow non-45° angles' },
{ key: 'Shift', label: 'Free angle (no 15° snap)' },
{ key: 'Esc', label: 'Cancel' },
],
@@ -18,7 +18,6 @@ import {
snapScalarToGrid,
snapWallDraftPoint,
useAlignmentGuides,
WALL_FINE_GRID_STEP,
WALL_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -187,23 +186,22 @@ export const wallMoveEndpointAffordance: FloorplanAffordance<WallNode> = {
// the legacy flow.
const sceneNodes = useScene.getState().nodes
const walls = collectLevelWalls(sceneNodes, node.id)
// Endpoint move = grid snap, never 45° from the fixed corner
// the angle snap is for initial draft only. Shift switches to
// the fine grid step for precision, matching the 3D
// `MoveWallEndpointTool`.
const worldStep = modifiers.shiftKey ? WALL_FINE_GRID_STEP : WALL_GRID_STEP
// Endpoint move = grid snap, never 45° from the fixed corner.
// Shift bypasses grid, magnetic, and alignment snap.
const snapped = snapWallDraftPoint({
point: planPoint as WallPlanPoint,
walls,
ignoreWallIds: [node.id],
step: modifiers.shiftKey ? WALL_FINE_GRID_STEP : undefined,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, worldStep),
bypassSnap: modifiers.shiftKey,
magnetic: !modifiers.shiftKey,
gridSnap: (p) => snapBuildingLocalToWorldGrid(p, WALL_GRID_STEP),
})
// Figma-style alignment on the dragged corner — snaps it onto another
// object's edge / wall face and publishes a guide. The dragged wall
// and its linked siblings (which cascade with the corner) are excluded
// from the candidate pool. Alt is reserved for detach, NOT bypass.
const aligned = alignFloorplanDraftPoint(snapped, {
bypass: modifiers.shiftKey,
excludeIds: [node.id, ...linkedWalls.map((w) => w.id)],
}) as WallPlanPoint
@@ -28,7 +28,6 @@ import {
useAlignmentGuides,
useEditor,
useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -288,16 +287,15 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// drag by warping the endpoint onto the nearest 45° line from
// the fixed corner.
//
// Shift switches to the *fine* grid step (`WALL_FINE_GRID_STEP`)
// for precision placement, so it can land on positions the
// active grid would skip (e.g. 0.05m increments when the active
// grid is 0.5m). It does NOT bypass snap.
// Shift is a hard snap bypass: raw endpoint position, no grid,
// no magnetic wall snap, and no alignment guide snap.
const bypassSnap = shiftPressedRef.current || event.nativeEvent.shiftKey
const snapResult = snapWallDraftPointDetailed({
point: planPoint,
walls: levelWalls,
ignoreWallIds: [nodeId],
step: shiftPressedRef.current ? WALL_FINE_GRID_STEP : undefined,
magnetic: useEditor.getState().magneticSnap,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
})
const snappedPoint = snapResult.point
@@ -308,7 +306,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
// midpoint), never an empty-space bbox corner. Layered on top of the
// grid + corner snap above; Alt is reserved for corner-detach here.
let alignedPoint = snappedPoint
if (wallAlignmentCandidates.length > 0) {
if (!bypassSnap && wallAlignmentCandidates.length > 0) {
const ar = resolveAlignment({
moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }],
candidates: wallAlignmentCandidates,
@@ -318,9 +316,12 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
alignedPoint = [snappedPoint[0] + ar.snap.dx, snappedPoint[1] + ar.snap.dz]
}
useAlignmentGuides.getState().set(ar.guides)
} else {
useAlignmentGuides.getState().clear()
}
if (
!bypassSnap &&
previousGridPosRef.current &&
(alignedPoint[0] !== previousGridPosRef.current[0] ||
alignedPoint[1] !== previousGridPosRef.current[1])
+4 -2
View File
@@ -437,6 +437,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const bypassSnap = shiftPressedRef.current || event.nativeEvent?.shiftKey === true
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const snapStep = getSegmentGridStep()
@@ -467,11 +468,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
if (axis) {
const originalProj = originalCenter[0] * axis[0] + originalCenter[1] * axis[1]
const rawProj = originalProj + rawDeltaX * axis[0] + rawDeltaZ * axis[1]
const snappedProj = shiftPressedRef.current ? rawProj : snapScalarToGrid(rawProj, snapStep)
const snappedProj = bypassSnap ? rawProj : snapScalarToGrid(rawProj, snapStep)
const perpDelta = snappedProj - originalProj
deltaX = axis[0] * perpDelta
deltaZ = axis[1] * perpDelta
} else if (shiftPressedRef.current) {
} else if (bypassSnap) {
deltaX = rawDeltaX
deltaZ = rawDeltaZ
} else {
@@ -491,6 +492,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
if (
!bypassSnap &&
previousGridPosRef.current &&
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
constrainedGridPos[1] !== previousGridPosRef.current[1])
+42 -19
View File
@@ -26,8 +26,8 @@ import {
triggerSFX,
useAlignmentGuides,
useEditor,
useSegmentDraftChain,
useWallSnapIndicator,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
@@ -532,6 +532,7 @@ export const WallTool: React.FC = () => {
setAxisGuide(null)
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall')
}
const onGridMove = (event: GridEvent) => {
@@ -539,20 +540,21 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
// Default to the active grid step; Shift switches to the fine
// step (0.05m) for precision. No 45° angle snap — we want the
// cursor to track grid lines in every direction. Orthogonal
// walls fall out of grid snap naturally when the start sits on
// a grid intersection.
const step = shiftPressed.current ? WALL_FINE_GRID_STEP : undefined
const bypassAlign = event.nativeEvent?.altKey === true
// Default path: grid + magnetic snap, with 15° angle lock while
// drafting. Shift is a hard snap bypass: no grid, magnetic, angle,
// or alignment snap.
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
const angleLocked = buildingState.current === 1 && !bypassSnap
const bypassAlign = event.nativeEvent?.altKey === true || bypassSnap
const snapResult = snapWallDraftPointDetailed({
point: localPoint,
walls,
step,
magnetic: useEditor.getState().magneticSnap,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
})
gridPosition = alignPoint(snapResult.point, bypassAlign)
gridPosition = alignPoint(snapResult.point, bypassAlign || angleLocked)
// Stand the magnetic beacon at the endpoint when it locked onto an
// existing wall corner / wall point; clear it for plain grid/angle moves.
useWallSnapIndicator
@@ -579,6 +581,7 @@ export const WallTool: React.FC = () => {
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
if (
!bypassSnap &&
previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
) {
@@ -611,6 +614,8 @@ export const WallTool: React.FC = () => {
}
const onGridClick = (event: GridEvent) => {
if (!wallPreviewRef.current) return
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
stopDrafting()
return
@@ -619,16 +624,16 @@ export const WallTool: React.FC = () => {
const walls = getCurrentLevelWalls()
const localClick: WallPlanPoint = [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(
snapWallDraftPointDetailed({
point: localClick,
walls,
step: clickStep,
magnetic: useEditor.getState().magneticSnap,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point,
bypassAlign,
)
@@ -651,14 +656,17 @@ export const WallTool: React.FC = () => {
// `onGridMove` writes a real BoxGeometry skips that frame.
setDraftMeasurement(null)
} else if (buildingState.current === 1) {
const angleLocked = !bypassSnap
const snappedEnd = alignPoint(
snapWallDraftPointDetailed({
point: localClick,
walls,
step: clickStep,
magnetic: useEditor.getState().magneticSnap,
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
bypassSnap,
magnetic: !bypassSnap && useEditor.getState().magneticSnap,
}).point,
bypassAlign,
bypassAlign || angleLocked,
)
const dx = snappedEnd[0] - startingPoint.current.x
const dz = snappedEnd[1] - startingPoint.current.z
@@ -684,6 +692,10 @@ export const WallTool: React.FC = () => {
}
const nextStart = createdWall.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('wall', [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)
@@ -698,7 +710,9 @@ export const WallTool: React.FC = () => {
// BoxGeometry stays visible for a frame on top of the
// freshly-committed real wall, producing a brief
// double-paint at the new wall's position.
wallPreviewRef.current.visible = false
if (wallPreviewRef.current) {
wallPreviewRef.current.visible = false
}
setDraftMeasurement(null)
}
}
@@ -711,6 +725,12 @@ export const WallTool: 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()
@@ -723,6 +743,7 @@ export const WallTool: 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)
@@ -730,8 +751,10 @@ export const WallTool: React.FC = () => {
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onBlur)
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
useSegmentDraftChain.getState().clear('wall')
}
}, [unit])