Add SnapServices (grid + angle) to core (Phase 1, 3/6)

Pure math, no React, no scene access. Three primitives plus a facade:

- `snapScalar(value, step)` / `snapPointToGrid(point, step)` /
  `snapVec3ToGrid(point, step)` — regular grid snapping. Default
  step 0.25m matches the editor's wall tool.
- `snapPointToAngle(from, cursor, angleStep, gridStep?)` — locks a
  cursor to the nearest angle multiple from a fixed point, preserves
  distance, optionally re-grids the projected point. Default angle
  step π/12 (15°).
- `snapAngleToList(angle, list, tolerance)` — snaps a free angle to
  the nearest entry in a fixed list (e.g. 0/45/90/135) within a
  tolerance; returns the original angle otherwise. Handles wrap.
- `snapServices` facade — `grid.*` + `angle.*` namespaces. Stable
  contract that `DragAction.snap` callbacks receive. Phase 3 ports
  the existing `snapWallDraftPoint` family from
  `editor/.../wall-drafting.ts` under a `wall.*` namespace.

17 unit tests cover the math + the facade pass-through. No existing
callers re-wired yet — Phase 2 column/shelf tools are the first
consumers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 12:27:25 -04:00
co-authored by Claude Opus 4.7
parent cf93c32183
commit bc0c73d449
3 changed files with 257 additions and 0 deletions
+11
View File
@@ -9,3 +9,14 @@ export {
pickHost, pickHost,
type Vec3, type Vec3,
} from './hosting' } from './hosting'
export {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
type SnapServices,
snapAngleToList,
snapPointToAngle,
snapPointToGrid,
snapScalar,
snapServices,
snapVec3ToGrid,
} from './snap'
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_ANGLE_STEP,
DEFAULT_GRID_STEP,
snapAngleToList,
snapPointToAngle,
snapPointToGrid,
snapScalar,
snapServices,
snapVec3ToGrid,
type Vec2,
} from './snap'
describe('snapScalar', () => {
test('rounds to multiples of step', () => {
expect(snapScalar(0.27, 0.25)).toBe(0.25)
expect(snapScalar(0.13, 0.25)).toBe(0.25)
expect(snapScalar(0.12, 0.25)).toBe(0)
expect(snapScalar(1.7, 0.25)).toBeCloseTo(1.75)
})
test('returns input unchanged when step is non-positive', () => {
expect(snapScalar(0.42, 0)).toBe(0.42)
expect(snapScalar(0.42, -1)).toBe(0.42)
})
test('default step is 0.25m', () => {
expect(snapScalar(0.3)).toBe(0.25)
expect(snapScalar(0.4)).toBe(0.5)
expect(DEFAULT_GRID_STEP).toBe(0.25)
})
})
describe('snapPointToGrid', () => {
test('snaps both components independently', () => {
expect(snapPointToGrid([0.3, 0.6], 0.25)).toEqual([0.25, 0.5])
})
test('preserves exact-grid points', () => {
expect(snapPointToGrid([1, 2], 0.5)).toEqual([1, 2])
})
})
describe('snapVec3ToGrid', () => {
test('snaps X and Z, leaves Y untouched', () => {
expect(snapVec3ToGrid([0.3, 1.7, 0.6], 0.25)).toEqual([0.25, 1.7, 0.5])
})
})
describe('snapPointToAngle', () => {
test('snaps to axis (0°) when cursor is near horizontal', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.05] // near 0°
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
expect(snapped[0]).toBeCloseTo(1, 1)
expect(snapped[1]).toBeCloseTo(0, 5)
})
test('snaps to 45° at π/4 step', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.9] // near 45°
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
// distance preserved (≈ √(1² + 0.9²) ≈ 1.345), angle locked to 45°
const expectedDist = Math.hypot(1, 0.9)
expect(snapped[0]).toBeCloseTo(expectedDist * Math.cos(Math.PI / 4))
expect(snapped[1]).toBeCloseTo(expectedDist * Math.sin(Math.PI / 4))
})
test('default angle step is π/12 (15°)', () => {
expect(DEFAULT_ANGLE_STEP).toBeCloseTo(Math.PI / 12)
})
test('grid-snaps the projected point when gridStep is provided', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1.05, 0.02] // ~horizontal, slightly off grid
const snapped = snapPointToAngle(from, cursor, Math.PI / 4, 0.25)
// After 0° lock + 0.25m grid, X must be a 0.25 multiple.
expect(snapped[0] / 0.25).toBeCloseTo(Math.round(snapped[0] / 0.25))
})
test('preserves distance from `from`', () => {
const from: Vec2 = [2, 3]
const cursor: Vec2 = [3, 4]
const distance = Math.hypot(1, 1)
const snapped = snapPointToAngle(from, cursor, Math.PI / 4)
expect(Math.hypot(snapped[0] - 2, snapped[1] - 3)).toBeCloseTo(distance)
})
})
describe('snapAngleToList', () => {
test('snaps to the nearest entry within tolerance', () => {
const targets = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]
expect(snapAngleToList(0.05, targets, Math.PI / 36)).toBe(0)
expect(snapAngleToList(Math.PI / 2 + 0.02, targets, Math.PI / 36)).toBe(Math.PI / 2)
})
test('returns original angle when no target is within tolerance', () => {
const targets = [0, Math.PI / 2]
expect(snapAngleToList(0.5, targets, Math.PI / 36)).toBe(0.5)
})
test('handles wrap-around near ±π', () => {
const targets = [Math.PI]
expect(snapAngleToList(-Math.PI + 0.01, targets, Math.PI / 36)).toBe(Math.PI)
})
})
describe('snapServices facade', () => {
test('grid.snap matches snapPointToGrid', () => {
expect(snapServices.grid.snap([0.3, 0.6], 0.25)).toEqual(snapPointToGrid([0.3, 0.6], 0.25))
})
test('grid.snapScalar matches snapScalar', () => {
expect(snapServices.grid.snapScalar(0.3, 0.25)).toBe(snapScalar(0.3, 0.25))
})
test('angle.snapTo matches snapPointToAngle', () => {
const from: Vec2 = [0, 0]
const cursor: Vec2 = [1, 0.9]
expect(snapServices.angle.snapTo(from, cursor, Math.PI / 4)).toEqual(
snapPointToAngle(from, cursor, Math.PI / 4),
)
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Pure snap math — no React, no R3F, no scene access.
*
* Phase 1 ships the kind-agnostic snappers (grid + angle). Wall-specific
* snapping (snap-to-endpoint, snap-along-T) currently lives in
* `editor/src/components/tools/wall/wall-drafting.ts` and stays there until
* Phase 3, when the wall migration ports it here behind a `wallSnap` namespace.
*
* The functions here are stable contract — Phase 3 only adds, never removes.
*/
export type Vec2 = readonly [number, number]
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°). */
export const DEFAULT_ANGLE_STEP = Math.PI / 12
// ─── Grid snap ────────────────────────────────────────────────────────
/** Snaps a single scalar to the nearest multiple of `step`. */
export function snapScalar(value: number, step: number = DEFAULT_GRID_STEP): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
/** Snaps a 2D point to a regular planar grid. */
export function snapPointToGrid(point: Vec2, step: number = DEFAULT_GRID_STEP): Vec2 {
return [snapScalar(point[0], step), snapScalar(point[1], step)]
}
/** Snaps a 3D point to a regular grid in the X/Z plane, preserving Y. */
export function snapVec3ToGrid(point: Vec3, step: number = DEFAULT_GRID_STEP): Vec3 {
return [snapScalar(point[0], step), point[1], snapScalar(point[2], step)]
}
// ─── Angle snap ───────────────────────────────────────────────────────
/**
* Snaps a cursor point to the nearest angle multiple of `angleStep` (radians)
* measured from `from`, preserving distance. Useful for axis/diagonal-locked
* placement and wall draft endpoint locking.
*
* After the angle snap, the result is grid-snapped if `gridStep` is provided
* — keeps endpoints landing on grid intersections.
*/
export function snapPointToAngle(
from: Vec2,
cursor: Vec2,
angleStep: number = DEFAULT_ANGLE_STEP,
gridStep?: number,
): Vec2 {
const dx = cursor[0] - from[0]
const dz = cursor[1] - from[1]
const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / angleStep) * angleStep
const distance = Math.hypot(dx, dz)
const projected: Vec2 = [
from[0] + Math.cos(snappedAngle) * distance,
from[1] + Math.sin(snappedAngle) * distance,
]
return gridStep == null ? projected : snapPointToGrid(projected, gridStep)
}
/**
* Snaps an angle (in radians) to the nearest entry in `snapAngles` (also in
* radians). Returns the original angle if no entry is within `toleranceRad`.
*/
export function snapAngleToList(
angle: number,
snapAngles: readonly number[],
toleranceRad: number = Math.PI / 36, // 5°
): number {
let best: number | null = null
let bestDelta = Number.POSITIVE_INFINITY
for (const target of snapAngles) {
// wrap delta to [-π, π]
let delta = ((angle - target) % (Math.PI * 2)) + Math.PI * 3
delta = (delta % (Math.PI * 2)) - Math.PI
const abs = Math.abs(delta)
if (abs < bestDelta && abs <= toleranceRad) {
bestDelta = abs
best = target
}
}
return best ?? angle
}
// ─── Top-level SnapServices facade ────────────────────────────────────
/**
* Stable surface that `DragAction.snap` callbacks receive. Phase 1 ships
* `grid` and `angle`. Phase 3 adds a `wall` namespace populated by wall
* migration. Plugin authors should target this facade rather than importing
* the individual functions, so future Phase contributions become visible
* without code changes.
*/
export type SnapServices = {
grid: {
snap: (point: Vec2, step?: number) => Vec2
snapVec3: (point: Vec3, step?: number) => Vec3
snapScalar: (value: number, step?: number) => number
}
angle: {
snapTo: (from: Vec2, cursor: Vec2, angleStep?: number, gridStep?: number) => Vec2
snapToList: (angle: number, list: readonly number[], toleranceRad?: number) => number
}
}
export const snapServices: SnapServices = {
grid: {
snap: snapPointToGrid,
snapVec3: snapVec3ToGrid,
snapScalar,
},
angle: {
snapTo: snapPointToAngle,
snapToList: snapAngleToList,
},
}