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:
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user