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:
@@ -54,6 +54,7 @@ export {
|
||||
DEFAULT_GRID_STEP,
|
||||
type SnapServices,
|
||||
snapAngleToList,
|
||||
snapPointAlongAngleRay,
|
||||
snapPointToAngle,
|
||||
snapPointToGrid,
|
||||
snapScalar,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user