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>
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
}
|