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