diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index efcc79aa..670abe9b 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -9,3 +9,14 @@ export { pickHost, type Vec3, } from './hosting' +export { + DEFAULT_ANGLE_STEP, + DEFAULT_GRID_STEP, + type SnapServices, + snapAngleToList, + snapPointToAngle, + snapPointToGrid, + snapScalar, + snapServices, + snapVec3ToGrid, +} from './snap' diff --git a/packages/core/src/services/snap.test.ts b/packages/core/src/services/snap.test.ts new file mode 100644 index 00000000..1a4ddb38 --- /dev/null +++ b/packages/core/src/services/snap.test.ts @@ -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), + ) + }) +}) diff --git a/packages/core/src/services/snap.ts b/packages/core/src/services/snap.ts new file mode 100644 index 00000000..74aa9cd8 --- /dev/null +++ b/packages/core/src/services/snap.ts @@ -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, + }, +}