Files
OneCAD-Draft/src/snap/mod.rs
T
Hakan SevenandClaude Sonnet 4.6 bfd8d0411a perf: AABB snap pre-rejection via acadrust bounding_box() (Option K upgrade)
Add `aabb: [f32; 4]` field to WireModel (world-space 2-D bounding box).
In tessellate_entity() set each wire's AABB from e.as_entity().bounding_box()
via new entity_aabb() helper; Insert sub-entities each get their own AABB.
Preview/interim wires and any entity returning a zero-extent default box get
UNBOUNDED_AABB so they are never pre-rejected.

Replace the chord-sphere heuristic in Snapper::wire_in_range() with a proper
AABB vs snap-circle overlap test.  The new check is four scalar comparisons and
correctly handles closed curves (circles, arcs) which the chord-sphere approach
had to special-case.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-26 12:21:51 +03:00

725 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! H7CAD-style object snap (OSNAP) engine.
//!
//! Implemented modes:
//! Endpoint, Midpoint, Center, Node, Quadrant, Intersection,
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
use glam::{Mat4, Vec3};
use iced::{Point, Rectangle};
use crate::command::TangentObject;
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
use crate::ui::overlay::CROSSHAIR_ARM;
// ── Snap type ─────────────────────────────────────────────────────────────
/// Every OSNAP mode — mirrors the H7CAD list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SnapType {
Endpoint,
Midpoint,
Center,
Node,
Quadrant,
Intersection,
Extension,
Insertion,
Perpendicular,
Tangent,
Nearest,
ApparentIntersection,
Parallel,
Grid,
}
/// Ordered list used by the popup and snap engine.
pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[
(SnapType::Endpoint, "◻", "Endpoint"),
(SnapType::Midpoint, "△", "Midpoint"),
(SnapType::Center, "◯", "Center"),
(SnapType::Node, "◆", "Node"),
(SnapType::Quadrant, "◇", "Quadrant"),
(SnapType::Intersection, "✕", "Intersection"),
(SnapType::Extension, "—", "Extension"),
(SnapType::Insertion, "⊾", "Insertion"),
(SnapType::Perpendicular, "⊥", "Perpendicular"),
(SnapType::Tangent, "⌒", "Tangent"),
(SnapType::Nearest, "✧", "Nearest"),
(SnapType::ApparentIntersection, "✗", "Apparent Intersection"),
(SnapType::Parallel, "∥", "Parallel"),
(SnapType::Grid, "⊞", "Grid"),
];
// ── Snap result ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub struct SnapResult {
pub world: Vec3,
pub screen: Point,
pub snap_type: SnapType,
/// Set when `snap_type == Tangent`; provides entity geometry for TTR/TTT.
pub tangent_obj: Option<TangentObject>,
}
// ── Snapper ───────────────────────────────────────────────────────────────
use std::collections::HashSet;
pub struct Snapper {
/// Global snap on/off toggle. When false, all snapping is bypassed
/// but the `enabled` set is preserved so it can be restored.
pub snap_enabled: bool,
/// Which snap modes are configured (used when `snap_enabled` is true).
pub enabled: HashSet<SnapType>,
/// World-space grid spacing.
pub grid_spacing: f32,
/// Pixel-radius threshold.
pub snap_radius_px: f32,
/// Object Snap Tracking on/off (F11).
pub otrack_enabled: bool,
/// Acquired OST points (world XZ, Y=0 plane).
pub tracking_points: Vec<Vec3>,
/// Last snap world position (for dwell detection).
pub last_snap_world: Option<Vec3>,
/// How many consecutive moves the cursor has been near last_snap_world.
pub dwell_count: u32,
}
impl Default for Snapper {
fn default() -> Self {
let mut enabled = HashSet::new();
enabled.insert(SnapType::Endpoint);
enabled.insert(SnapType::Midpoint);
enabled.insert(SnapType::Center);
enabled.insert(SnapType::Node);
enabled.insert(SnapType::Quadrant);
enabled.insert(SnapType::Intersection);
enabled.insert(SnapType::Nearest);
Self {
snap_enabled: true,
enabled,
grid_spacing: 1.0,
snap_radius_px: CROSSHAIR_ARM,
otrack_enabled: false,
tracking_points: Vec::new(),
last_snap_world: None,
dwell_count: 0,
}
}
}
impl Snapper {
/// True when snap is globally on AND at least one mode is configured.
pub fn is_active(&self) -> bool {
self.snap_enabled && !self.enabled.is_empty()
}
pub fn is_on(&self, t: SnapType) -> bool {
self.enabled.contains(&t)
}
pub fn toggle_global(&mut self) {
self.snap_enabled = !self.snap_enabled;
}
pub fn toggle(&mut self, t: SnapType) {
if !self.enabled.remove(&t) {
self.enabled.insert(t);
}
}
pub fn all_on(&self) -> bool {
ALL_SNAP_MODES
.iter()
.all(|(t, _, _)| self.enabled.contains(t))
}
pub fn none_on(&self) -> bool {
self.enabled.is_empty()
}
pub fn enable_all(&mut self) {
for &(t, _, _) in ALL_SNAP_MODES {
self.enabled.insert(t);
}
}
pub fn disable_all(&mut self) {
self.enabled.clear();
}
/// Update dwell tracking and possibly acquire a new OST point.
/// Should be called on every ViewportMove when snap is active.
/// `snap_world` is the current snap result world point (if any).
pub fn update_otrack_dwell(&mut self, snap_world: Option<Vec3>, view_proj: glam::Mat4, bounds: iced::Rectangle) {
if !self.otrack_enabled {
self.dwell_count = 0;
self.last_snap_world = None;
return;
}
const DWELL_THRESHOLD: u32 = 4;
const DWELL_PX: f32 = 8.0;
match snap_world {
None => {
self.dwell_count = 0;
self.last_snap_world = None;
}
Some(p) => {
// Convert to screen to measure pixel distance.
let is_same = if let Some(prev) = self.last_snap_world {
let dp = world_to_screen(p, view_proj, bounds);
let dp2 = world_to_screen(prev, view_proj, bounds);
let dx = dp.x - dp2.x;
let dy = dp.y - dp2.y;
(dx * dx + dy * dy).sqrt() < DWELL_PX
} else {
false
};
if is_same {
self.dwell_count += 1;
if self.dwell_count == DWELL_THRESHOLD {
// Acquire this point (max 4 tracked points).
if !self.tracking_points.iter().any(|t| {
let d = (*t - p).length();
d < self.grid_spacing * 0.1
}) {
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
}
self.tracking_points.push(p);
}
}
} else {
self.dwell_count = 1;
self.last_snap_world = Some(p);
}
}
}
}
/// Given the current cursor world position, check if it aligns with any
/// tracking point horizontally or vertically. Returns the snapped world
/// position (and index of the tracking point) if alignment is found within
/// `snap_radius_px` screen pixels.
pub fn otrack_snap(
&self,
cursor_world: Vec3,
view_proj: glam::Mat4,
bounds: iced::Rectangle,
) -> Option<(Vec3, usize)> {
if !self.otrack_enabled || self.tracking_points.is_empty() {
return None;
}
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
let r = self.snap_radius_px;
for (idx, &tp) in self.tracking_points.iter().enumerate() {
// Horizontal alignment: cursor.z ≈ tp.z
let aligned_h = Vec3::new(cursor_world.x, 0.0, tp.z);
let s = world_to_screen(aligned_h, view_proj, bounds);
let dy = (s.y - cursor_screen.y).abs();
if dy < r {
return Some((aligned_h, idx));
}
// Vertical alignment: cursor.x ≈ tp.x
let aligned_v = Vec3::new(tp.x, 0.0, cursor_world.z);
let s = world_to_screen(aligned_v, view_proj, bounds);
let dx = (s.x - cursor_screen.x).abs();
if dx < r {
return Some((aligned_v, idx));
}
}
None
}
/// Clear all acquired tracking points (e.g. when command ends).
pub fn clear_tracking(&mut self) {
self.tracking_points.clear();
self.dwell_count = 0;
self.last_snap_world = None;
}
/// Only runs Tangent snap — used when a command needs object picks via tangent.
pub fn snap_tangent_only(
&self,
cursor_world: Vec3,
cursor_screen: Point,
wires: &[WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<SnapResult> {
let tmp = Snapper {
snap_enabled: true,
enabled: {
let mut s = HashSet::new();
s.insert(SnapType::Tangent);
s
},
grid_spacing: self.grid_spacing,
snap_radius_px: self.snap_radius_px,
otrack_enabled: false,
tracking_points: Vec::new(),
last_snap_world: None,
dwell_count: 0,
};
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
}
/// Find the best snap candidate near the cursor.
pub fn snap(
&self,
cursor_world: Vec3,
cursor_screen: Point,
wires: &[WireModel],
view_proj: Mat4,
bounds: Rectangle,
) -> Option<SnapResult> {
if !self.snap_enabled {
return None;
}
let mut best: Option<SnapResult> = None;
let mut best_d2 = self.snap_radius_px * self.snap_radius_px;
// World-space snap radius — derived from the view scale so wires whose
// entire extent is clearly outside the snap circle can be skipped cheaply
// before projecting any of their vertices to screen space.
// view_proj col-0 x = 2*zoom / viewport_width for an orthographic camera,
// so scale_x * (width/2) = pixels per world unit.
let world_snap_r = {
let s = view_proj.col(0).x.abs() * bounds.width * 0.5;
if s > 1e-6 { self.snap_radius_px / s } else { f32::MAX }
};
// Returns false when the wire's AABB does not overlap the snap circle —
// safe to skip all vertex work for this wire.
// UNBOUNDED_AABB (±infinity) passes through automatically without a
// special-case branch because the arithmetic is exact for infinities.
let wire_in_range = |wire: &WireModel| -> bool {
let r = world_snap_r;
cursor_world.x + r >= wire.aabb[0]
&& cursor_world.x - r <= wire.aabb[2]
&& cursor_world.y + r >= wire.aabb[1]
&& cursor_world.y - r <= wire.aabb[3]
};
let mut try_pt = |world: Vec3, snap_type: SnapType| {
let screen = world_to_screen(world, view_proj, bounds);
let d2 = dist2(screen, cursor_screen);
if d2 < best_d2 {
best_d2 = d2;
best = Some(SnapResult {
world,
screen,
snap_type,
tangent_obj: None,
});
}
};
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
for wire in wires {
for &(world, hint) in &wire.snap_pts {
let snap_type = match hint {
SnapHint::Center => SnapType::Center,
SnapHint::Node => SnapType::Node,
SnapHint::Quadrant => SnapType::Quadrant,
SnapHint::Insertion => SnapType::Insertion,
};
if self.is_on(snap_type) {
try_pt(world, snap_type);
}
}
}
// ── Endpoint ───────────────────────────────────────────────────────
if self.is_on(SnapType::Endpoint) {
for wire in wires {
if !wire_in_range(wire) { continue; }
if !wire.key_vertices.is_empty() {
// Use explicit vertices (Line, LwPolyline): every vertex is an endpoint.
for &p in &wire.key_vertices {
try_pt(Vec3::from(p), SnapType::Endpoint);
}
} else {
// Tessellated curves (Circle, Arc, Ellipse): only arc endpoints.
if let Some(&p) = wire.points.first() {
try_pt(Vec3::from(p), SnapType::Endpoint);
}
if wire.points.len() > 1 {
if let Some(&p) = wire.points.last() {
try_pt(Vec3::from(p), SnapType::Endpoint);
}
}
}
}
}
// ── Midpoint ───────────────────────────────────────────────────────
if self.is_on(SnapType::Midpoint) {
for wire in wires {
if !wire_in_range(wire) { continue; }
if !wire.key_vertices.is_empty() {
// Use explicit vertices for accurate per-segment midpoints.
for seg in wire.key_vertices.windows(2) {
let a = Vec3::from(seg[0]);
let b = Vec3::from(seg[1]);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
}
}
} else {
// Tessellated curves: midpoint of whole arc (first↔last midpoint skipped
// for closed curves; arcs use their own quadrant snaps instead).
for seg in wire.points.windows(2) {
let a = Vec3::from(seg[0]);
let b = Vec3::from(seg[1]);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
}
}
}
}
}
// ── Nearest — closest point on any segment (clamped) ──────────────
if self.is_on(SnapType::Nearest) {
for wire in wires {
if !wire_in_range(wire) { continue; }
for seg in wire.points.windows(2) {
let p =
nearest_on_segment(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]));
try_pt(p, SnapType::Nearest);
}
}
}
// ── Perpendicular — foot of perpendicular from cursor (unclamped) ──
if self.is_on(SnapType::Perpendicular) {
for wire in wires {
if !wire_in_range(wire) { continue; }
for seg in wire.points.windows(2) {
if let Some(foot) =
perp_foot(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]))
{
try_pt(foot, SnapType::Perpendicular);
}
}
}
}
// ── Intersection — segment-segment intersections ──────────
if self.is_on(SnapType::Intersection) {
let r_world = self.snap_radius_px * 0.5; // rough world-space cull
for i in 0..wires.len() {
if !wire_in_range(&wires[i]) { continue; }
for j in (i + 1)..wires.len() {
if !wire_in_range(&wires[j]) { continue; }
for seg_a in wires[i].points.windows(2) {
for seg_b in wires[j].points.windows(2) {
let a0 = Vec3::from(seg_a[0]);
let a1 = Vec3::from(seg_a[1]);
let b0 = Vec3::from(seg_b[0]);
let b1 = Vec3::from(seg_b[1]);
// Quick AABB cull before expensive calculation.
let seg_mid_a = (a0 + a1) * 0.5;
let seg_mid_b = (b0 + b1) * 0.5;
if (seg_mid_a - cursor_world).length_squared()
> (r_world * 60.0).powi(2)
&& (seg_mid_b - cursor_world).length_squared()
> (r_world * 60.0).powi(2)
{
continue;
}
if let Some(pt) = seg_intersect_xy(a0, a1, b0, b1) {
try_pt(pt, SnapType::Intersection);
}
}
}
}
}
}
// ── Extension — along the extension of a segment beyond endpoints ──
if self.is_on(SnapType::Extension) {
for wire in wires {
let n = wire.points.len();
if n < 2 {
continue;
}
// Extend beyond the first point.
{
let p0 = Vec3::from(wire.points[0]);
let p1 = Vec3::from(wire.points[1]);
if let Some(ext) = extension_snap(
cursor_world,
p0,
p0 - p1,
view_proj,
bounds,
self.snap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
// Extend beyond the last point.
{
let p_last = Vec3::from(wire.points[n - 1]);
let p_prev = Vec3::from(wire.points[n - 2]);
if let Some(ext) = extension_snap(
cursor_world,
p_last,
p_last - p_prev,
view_proj,
bounds,
self.snap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
}
}
// ── Apparent Intersection — screen-space intersections ─────────────
if self.is_on(SnapType::ApparentIntersection) {
for i in 0..wires.len() {
if !wire_in_range(&wires[i]) { continue; }
for j in (i + 1)..wires.len() {
if !wire_in_range(&wires[j]) { continue; }
for seg_a in wires[i].points.windows(2) {
for seg_b in wires[j].points.windows(2) {
let sa0 = world_to_screen(Vec3::from(seg_a[0]), view_proj, bounds);
let sa1 = world_to_screen(Vec3::from(seg_a[1]), view_proj, bounds);
let sb0 = world_to_screen(Vec3::from(seg_b[0]), view_proj, bounds);
let sb1 = world_to_screen(Vec3::from(seg_b[1]), view_proj, bounds);
if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) {
// World position: interpolate along segment A.
let wa0 = Vec3::from(seg_a[0]);
let wa1 = Vec3::from(seg_a[1]);
let world = wa0 + ta * (wa1 - wa0);
try_pt(world, SnapType::ApparentIntersection);
}
}
}
}
}
}
// ── Grid ───────────────────────────────────────────────────────────
if self.is_on(SnapType::Grid) {
let s = self.grid_spacing;
let gx = (cursor_world.x / s).round() * s;
let gy = (cursor_world.y / s).round() * s;
let gz = (cursor_world.z / s).round() * s;
try_pt(Vec3::new(gx, gy, gz), SnapType::Grid);
}
// ── Tangent ────────────────────────────────────────────────────────
// Operates directly on tangent_geoms geometry — independent of the
// wire.points rendering structure so polyline segments work correctly.
if self.is_on(SnapType::Tangent) {
for wire in wires {
for tg in &wire.tangent_geoms {
let (world_pt, d2) = match tg {
TangentGeom::Line { p1, p2 } => {
let sp0 = world_to_screen(Vec3::from(*p1), view_proj, bounds);
let sp1 = world_to_screen(Vec3::from(*p2), view_proj, bounds);
let d2 = dist2_to_segment(cursor_screen, sp0, sp1);
let t = t_on_segment(cursor_screen, sp0, sp1);
let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1));
(w, d2)
}
TangentGeom::Circle { center, radius } => {
let cv = Vec3::from(*center);
let sc = world_to_screen(cv, view_proj, bounds);
let rim = world_to_screen(
Vec3::new(cv.x + radius, cv.y, cv.z),
view_proj,
bounds,
);
let sr = dist2(sc, rim).sqrt();
let dc = dist2(cursor_screen, sc).sqrt();
let edge_d = (dc - sr).abs();
// Snap point: point on circle edge facing cursor
let dx = cursor_screen.x - sc.x;
let dy = cursor_screen.y - sc.y;
let dl = (dx * dx + dy * dy).sqrt();
let (nx, ny) = if dl > 1e-6 {
(dx / dl, -dy / dl)
} else {
(1.0, 0.0)
};
let w = Vec3::new(cv.x + radius * nx, cv.y, cv.y + radius * ny);
(w, edge_d * edge_d)
}
};
if d2 < best_d2 {
best_d2 = d2;
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
let tangent_obj = match tg {
TangentGeom::Line { p1, p2 } => TangentObject::Line {
p1: Vec3::from(*p1),
p2: Vec3::from(*p2),
},
TangentGeom::Circle { center, radius } => TangentObject::Circle {
center: Vec3::from(*center),
radius: *radius,
},
};
best = Some(SnapResult {
world: world_pt,
screen: screen_pt,
snap_type: SnapType::Tangent,
tangent_obj: Some(tangent_obj),
});
}
}
}
}
best
}
}
// ── Geometric helpers ─────────────────────────────────────────────────────
/// Closest point on segment [p0, p1] to `query`.
fn nearest_on_segment(query: Vec3, p0: Vec3, p1: Vec3) -> Vec3 {
let d = p1 - p0;
let len2 = d.x * d.x + d.y * d.y;
if len2 < 1e-12 {
return p0;
}
let t = ((query.x - p0.x) * d.x + (query.y - p0.y) * d.y) / len2;
let t = t.clamp(0.0, 1.0);
Vec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z)
}
/// Foot of perpendicular from `query` to the line through [p0, p1] (XY plane, unclamped).
/// Returns `None` if the segment is degenerate.
fn perp_foot(query: Vec3, p0: Vec3, p1: Vec3) -> Option<Vec3> {
let d = p1 - p0;
let len2 = d.x * d.x + d.y * d.y;
if len2 < 1e-12 {
return None;
}
let t = ((query.x - p0.x) * d.x + (query.y - p0.y) * d.y) / len2;
// Reject if the foot is far outside the segment (more than 2× segment length).
if t < -1.0 || t > 2.0 {
return None;
}
Some(Vec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z))
}
/// XY-plane segment-segment intersection. Returns `None` if parallel or outside.
fn seg_intersect_xy(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<Vec3> {
let d1x = a1.x - a0.x;
let d1y = a1.y - a0.y;
let d2x = b1.x - b0.x;
let d2y = b1.y - b0.y;
let cross = d1x * d2y - d1y * d2x;
if cross.abs() < 1e-9 {
return None;
} // parallel
let ex = b0.x - a0.x;
let ey = b0.y - a0.y;
let t = (ex * d2y - ey * d2x) / cross;
let s = (ex * d1y - ey * d1x) / cross;
if t < 0.0 || t > 1.0 || s < 0.0 || s > 1.0 {
return None;
}
Some(Vec3::new(a0.x + t * d1x, a0.y + t * d1y, 0.0))
}
/// Screen-space 2D segment intersection. Returns `(t, s)` parameters if found.
fn seg_intersect_2d(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f32, f32)> {
let d1x = a1.x - a0.x;
let d1y = a1.y - a0.y;
let d2x = b1.x - b0.x;
let d2y = b1.y - b0.y;
let cross = d1x * d2y - d1y * d2x;
if cross.abs() < 1e-6 {
return None;
}
let ex = b0.x - a0.x;
let ey = b0.y - a0.y;
let t = (ex * d2y - ey * d2x) / cross;
let s = (ex * d1y - ey * d1x) / cross;
if t < 0.0 || t > 1.0 || s < 0.0 || s > 1.0 {
return None;
}
Some((t, s))
}
/// Snap to the extension of a ray beyond `origin` in `dir` direction.
/// Returns `None` if the cursor is not near the extension line.
fn extension_snap(
cursor_world: Vec3,
origin: Vec3,
dir: Vec3,
view_proj: Mat4,
bounds: Rectangle,
radius_px: f32,
) -> Option<Vec3> {
let len2 = dir.x * dir.x + dir.y * dir.y;
if len2 < 1e-12 {
return None;
}
let t = ((cursor_world.x - origin.x) * dir.x + (cursor_world.y - origin.y) * dir.y) / len2;
if t < 0.05 {
return None;
} // only beyond the endpoint
let world_pt = Vec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
return None;
}
Some(world_pt)
}
// ── Projection helpers ────────────────────────────────────────────────────
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point {
let ndc = view_proj.project_point3(world);
Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
)
}
#[inline]
fn dist2(a: Point, b: Point) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
dx * dx + dy * dy
}
/// Squared distance from point p to line segment [a, b] in screen space.
fn dist2_to_segment(p: Point, a: Point, b: Point) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len2 = dx * dx + dy * dy;
if len2 < 1e-6 {
let ex = p.x - a.x;
let ey = p.y - a.y;
return ex * ex + ey * ey;
}
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
let t = t.clamp(0.0, 1.0);
let nx = a.x + t * dx - p.x;
let ny = a.y + t * dy - p.y;
nx * nx + ny * ny
}
/// Parameter t ∈ [0,1] of the closest point on segment [a,b] to p.
fn t_on_segment(p: Point, a: Point, b: Point) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len2 = dx * dx + dy * dy;
if len2 < 1e-6 {
return 0.0;
}
(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0)
}