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
+1
View File
@@ -54,6 +54,7 @@ export {
DEFAULT_GRID_STEP,
type SnapServices,
snapAngleToList,
snapPointAlongAngleRay,
snapPointToAngle,
snapPointToGrid,
snapScalar,
+49
View File
@@ -3,6 +3,7 @@ import {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
snapAngleToList,
snapPointAlongAngleRay,
snapPointToAngle,
snapPointToGrid,
snapScalar,
@@ -87,6 +88,54 @@ describe('snapPointToAngle', () => {
})
})
describe('snapPointAlongAngleRay', () => {
test('stays exactly on the 15° ray while distance-snapping to the grid step', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [2, 0.5] // ≈14° — snaps to 15°
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.25)
expect(Math.atan2(snapped[1], snapped[0])).toBeCloseTo(Math.PI / 12, 10)
const distance = Math.hypot(snapped[0], snapped[1])
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10)
})
test('grid-snapping after the angle projection would pull the point off the ray', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [2, 0.5]
const offRay = snapPointToAngle(from, cursor, Math.PI / 12, 0.25)
expect(Math.atan2(offRay[1], offRay[0])).not.toBeCloseTo(Math.PI / 12, 4)
})
test('45° back-compat: locks to the diagonal with grid-multiple distance', () => {
const from: Vec2 = [1, 1]
const cursor: Vec2 = [2.1, 1.9] // near 45° from `from`
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 4, 0.25)
expect(Math.atan2(snapped[1] - 1, snapped[0] - 1)).toBeCloseTo(Math.PI / 4, 10)
const distance = Math.hypot(snapped[0] - 1, snapped[1] - 1)
expect(distance / 0.25).toBeCloseTo(Math.round(distance / 0.25), 10)
})
test('preserves the projected distance when no distanceStep is given', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.05] // near 0°
const snapped = snapPointAlongAngleRay(from, cursor, Math.PI / 12)
expect(snapped[0]).toBeCloseTo(1, 10) // projection of (1, 0.05) onto 0° ray
expect(snapped[1]).toBeCloseTo(0, 10)
})
test('returns `from` for a zero-length segment', () => {
expect(snapPointAlongAngleRay([2, 3], [2, 3], Math.PI / 12, 0.25)).toEqual([2, 3])
})
test('is idempotent on its own output', () => {
const from: Vec2 = [0.5, -1]
const cursor: Vec2 = [3.2, 0.4]
const once = snapPointAlongAngleRay(from, cursor, Math.PI / 12, 0.5)
const twice = snapPointAlongAngleRay(from, once, Math.PI / 12, 0.5)
expect(twice[0]).toBeCloseTo(once[0], 10)
expect(twice[1]).toBeCloseTo(once[1], 10)
})
})
describe('snapAngleToList', () => {
test('snaps to the nearest entry within tolerance', () => {
const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]
+27 -1
View File
@@ -15,7 +15,7 @@ export type Vec3 = readonly [number, number, number]
/** Default planar grid spacing in meters. Matches the editor's wall tool. */
export const DEFAULT_GRID_STEP = 0.25
/** Default angle-snap step — π/12 = 15°. Wall tools also use π/4 (45°). */
/** Default angle-snap step — π/12 = 15°. */
export const DEFAULT_ANGLE_STEP = Math.PI / 12
// ─── Grid snap ────────────────────────────────────────────────────────
@@ -111,6 +111,32 @@ export function snapPointToAngle(
return gridStep == null ? projected : snapPointToGrid(projected, gridStep)
}
/**
* Snaps a cursor point onto the nearest angle ray from `from` (multiples of
* `angleStep`), projecting the cursor onto that ray, then snaps the distance
* ALONG the ray to `distanceStep`. Unlike `snapPointToAngle` with a
* `gridStep`, the result stays exactly on the snapped ray — grid-snapping
* after the angle projection pulls points off non-axis rays.
*/
export function snapPointAlongAngleRay(
from: Vec2,
cursor: Vec2,
angleStep: number = DEFAULT_ANGLE_STEP,
distanceStep?: number,
): Vec2 {
const dx = cursor[0] - from[0]
const dz = cursor[1] - from[1]
if (dx === 0 && dz === 0) return [from[0], from[1]]
const angle = Math.atan2(dz, dx)
const snappedAngle = angleStep > 0 ? Math.round(angle / angleStep) * angleStep : angle
const dirX = Math.cos(snappedAngle)
const dirZ = Math.sin(snappedAngle)
const projected = dx * dirX + dz * dirZ
const distance =
distanceStep != null && distanceStep > 0 ? snapScalar(projected, distanceStep) : projected
return [from[0] + dirX * distance, from[1] + dirZ * distance]
}
/**
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
* radians). Returns the original angle if no entry is within `toleranceRad`.