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:
co-authored by
Claude Opus 4.7
parent
cf93c32183
commit
bc0c73d449
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user