cad-editor/src/snap.rs
Hakan Seven e512e57a7c fix(snap): correct circle tangent point + no endpoint on closed curves
Two object-snap fixes:

- Tangent on a circle built the snap point as
  `(cx + r·nx, cy, cy + r·ny)` — the y-offset landed in Z and Y was stuck
  at the centre, so the tangent point sat at the wrong place. Build it as
  `(cx + r·nx, cy + r·ny, cz)`.

- Endpoint snapped to a full circle / ellipse: for tessellated curves it
  snaps the first/last point, but a closed curve's first/last is a seam,
  not an endpoint. Skip Endpoint for closed curves — identified by their
  Quadrant snap hints, which arcs never carry — so arcs still snap their
  real endpoints.

Closes #274, closes #275

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:58:44 +03:00

1608 lines
69 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.

//! OpenCADStudio-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::time::Instant;
use iced::{Point, Rectangle};
use crate::command::TangentObject;
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
use crate::ui::overlay::CROSSHAIR_ARM;
// ── Snap type ─────────────────────────────────────────────────────────────
/// Every OSNAP mode — mirrors the OpenCADStudio 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,
/// Object acquisition (domain-object pick, e.g. network structure) — orange marker.
ObjectPick,
}
/// 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"),
// NOTE: Grid is intentionally NOT an object-snap mode. Grid snap is a
// separate system (`Snapper::grid_snap_on`) so object snap never catches a
// grid point; it is toggled on its own and handled directly in `snap()`.
];
// ── Snap result ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub struct SnapResult {
pub world: glam::DVec3,
pub screen: Point,
pub snap_type: SnapType,
/// Set when `snap_type == Tangent`; provides entity geometry for TTR/TTT.
pub tangent_obj: Option<TangentObject>,
/// Screen position of the endpoint an Extension snap extends from, so the
/// overlay can draw the dashed extension guide line back to it. `None` for
/// every other snap type. (#238)
pub extension_base: Option<Point>,
/// Second extension-guide base, set only for an extended intersection
/// (`snap_type == Intersection` where two extension lines cross): the
/// overlay draws a dashed guide from each base to the crossing so both
/// contributing extensions stay visible. `None` otherwise. (#247, #259)
pub extension_base2: Option<Point>,
}
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
/// acquired tracking point.
#[derive(Debug, Clone, Copy)]
pub struct OtrackHit {
/// Cursor projected onto the tracking ray.
pub aligned: Vec3,
/// Unit ray direction toward the cursor side (for typed-distance entry).
pub dir: Vec3,
/// The tracking point the ray emanates from.
pub base: Vec3,
}
// ── Snapper ───────────────────────────────────────────────────────────────
use rustc_hash::FxHashSet as 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>,
/// Grid snap on/off — a system fully separate from object snap. When on,
/// `snap()` can pick the nearest grid corner; object snap never does.
pub grid_snap_on: bool,
/// World-space grid spacing.
pub grid_spacing: f32,
/// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and
/// extension so the catch distance is the same everywhere.
pub osnap_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>,
/// Edge directions at each acquired point (parallel to `tracking_points`):
/// the line direction of every wire segment meeting at that corner, so
/// OTRACK can offer an alignment ray along a segment's extension, not only
/// the ortho/polar axes. Pulling the cursor along an acquired corner's edge
/// then locks to that line (#219). Empty for a point that is not a segment
/// endpoint (e.g. a midpoint or centre acquisition).
pub tracking_dirs: Vec<Vec<Vec3>>,
/// Last snap world position (for dwell detection).
pub last_snap_world: Option<Vec3>,
/// When the cursor first rested near `last_snap_world`.
pub dwell_since: Option<Instant>,
/// Whether the current dwell already acquired/removed a point (fire once).
pub dwell_acquired: bool,
/// The point the in-progress command is drawing *from* (the rubber-band
/// origin), if any. Perpendicular snap drops its foot from here so the new
/// segment is genuinely perpendicular to the target — without it, perp
/// would just give the nearest point on the line. Set before each `snap`.
pub from_point: Option<Vec3>,
}
impl Default for Snapper {
fn default() -> Self {
let mut enabled = HashSet::default();
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: false,
enabled,
grid_snap_on: false,
grid_spacing: 1.0,
osnap_radius_px: CROSSHAIR_ARM * 0.25,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
from_point: None,
}
}
}
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)
}
/// Whether temporary tracking points are being acquired and drawn: OTRACK
/// on, or the Extension object snap on. Extension tracks a segment's line
/// only from an acquired endpoint, and works independently of OTRACK's
/// on/off state (#262).
pub fn tracking_active(&self) -> bool {
self.otrack_enabled || (self.snap_enabled && self.is_on(SnapType::Extension))
}
/// True when `p` coincides with one of the acquired temporary tracking
/// points. Extension snaps a segment's line only from such acquired
/// endpoints (#262), so extensions aren't live for every object in the
/// drawing. The tolerance mirrors `edge_dirs_at`: acquired points are f32
/// truncations of the true vertices, so the match window scales with
/// coordinate magnitude with a tight floor near the origin.
fn is_tracked_endpoint(&self, p: glam::DVec3) -> bool {
if self.tracking_points.is_empty() {
return false;
}
let pf = p.as_vec3();
let tol = 1e-4_f32.max(4e-7 * pf.x.abs().max(pf.y.abs()));
let tol2 = tol * tol;
self.tracking_points.iter().any(|t| {
let dx = t.x - pf.x;
let dy = t.y - pf.y;
dx * dx + dy * dy < tol2
})
}
pub fn toggle_global(&mut self) {
self.snap_enabled = !self.snap_enabled;
}
/// Grid snap on/off — independent of the object-snap master and mode set.
pub fn grid_snap(&self) -> bool {
self.grid_snap_on
}
pub fn toggle_grid_snap(&mut self) {
self.grid_snap_on = !self.grid_snap_on;
}
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>,
wires: &[WireModel],
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
now: Instant,
) {
// Temporary tracking points are acquired when OTRACK is on, OR when the
// Extension object snap is on: Extension tracks a segment's line only
// from an endpoint the user has acquired, independently of OTRACK's
// on/off state (#262).
if !self.tracking_active() {
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
return;
}
// With OTRACK off, acquisition is Extension-driven, and Extension tracks
// a line only from a real segment endpoint — so acquire endpoints only.
// This stops a paused cursor on an extension foot (or a midpoint/centre)
// from being acquired and evicting, through the 4-point cap, the very
// endpoint the user acquired — the reason the marker vanished after a
// few pauses (#262). OTRACK keeps acquiring any snap point.
let endpoints_only = !self.otrack_enabled;
// The cursor must rest near a snap point for this long before it is
// acquired, so that brushing past snap points while moving the mouse
// does not create accidental tracking points.
const DWELL_MS: u128 = 250;
const DWELL_PX: f32 = 8.0;
match snap_world {
None => {
// Leaving all geometry: capture the point we were dwelling on if
// it qualified, before the reset loses it.
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
}
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.as_dvec3(), view_rot, eye, bounds);
let dp2 = world_to_screen(prev.as_dvec3(), view_rot, eye, 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 {
let elapsed = self
.dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if !self.dwell_acquired && elapsed >= DWELL_MS {
self.dwell_acquired = true;
// Dwelling over an already-acquired point removes it;
// otherwise acquire it.
let existing = self.tracking_points.iter().position(|t| {
let d = (*t - p).length();
d < self.grid_spacing * 0.1
});
match existing {
Some(idx) => {
self.tracking_points.remove(idx);
if idx < self.tracking_dirs.len() {
self.tracking_dirs.remove(idx);
}
}
None => self.acquire_tracking_point(p, wires, endpoints_only),
}
}
} else {
// Moved to a different snap point: capture the previous one
// first if it was dwelt on long enough, so a pause-then-drag
// gesture reliably acquires it even without in-place events.
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
self.last_snap_world = Some(p);
self.dwell_since = Some(now);
self.dwell_acquired = false;
}
}
}
}
/// Add `p` as a tracking point (capturing its corner edge directions) unless
/// it is already tracked; drops the oldest when the 4-point cap is reached.
/// Edge directions are scanned once here, at acquisition, so OTRACK can align
/// to a segment's extension without rescanning geometry per move (#219).
fn acquire_tracking_point(&mut self, p: Vec3, wires: &[WireModel], endpoints_only: bool) {
if self
.tracking_points
.iter()
.any(|t| (*t - p).length() < self.grid_spacing * 0.1)
{
return;
}
// Edge directions double as an endpoint test: a point with no incident
// segment — a midpoint, centre, intersection or extension foot — has
// none. Extension-driven acquisition (#262) keeps only endpoints.
let dirs = edge_dirs_at(p, wires);
if endpoints_only && dirs.is_empty() {
return;
}
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
if !self.tracking_dirs.is_empty() {
self.tracking_dirs.remove(0);
}
}
self.tracking_points.push(p);
self.tracking_dirs.push(dirs);
}
/// If the cursor dwelt on a snap point long enough but the in-place check
/// never fired (a perfectly still cursor emits no move events, so the timer
/// is only re-examined once the cursor moves off), acquire it now as the
/// cursor leaves. This makes "pause on a corner, then drag along its edge"
/// reliably capture the corner (#219).
fn acquire_on_leave(
&mut self,
now: Instant,
dwell_ms: u128,
wires: &[WireModel],
endpoints_only: bool,
) {
if self.dwell_acquired {
return; // already handled by the in-place branch
}
let Some(prev) = self.last_snap_world else {
return;
};
let elapsed = self
.dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if elapsed >= dwell_ms {
self.acquire_tracking_point(prev, wires, endpoints_only);
}
}
/// Project the cursor onto a tracking ray emanating from one of the
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
/// rays are horizontal / vertical (0° / 90°); with it, every polar
/// increment is a candidate so the user can track along POLAR angles. Each
/// acquired corner also contributes a ray along its own edge directions, so
/// pulling the cursor along a segment's extension locks to that line (#219).
///
/// When the cursor sits near the crossing of two active vectors from
/// different origins the intersection point wins, so the cursor locks onto
/// the exact crossing rather than a free point along one vector:
/// * two OTRACK vectors from different tracking points (#112), and
/// * a POLAR vector from `last_point` crossing an OTRACK vector (#111).
///
/// Returns the aligned point, the unit ray direction (pointing toward the
/// cursor side, used for typed-distance entry), and the originating point.
pub fn otrack_snap(
&self,
cursor_world: Vec3,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
polar_step_deg: Option<f32>,
last_point: Option<Vec3>,
// Ortho on: the axis from `last_point` is a hard lock. Only crossings of
// an acquired ray with that axis lock; single tracking rays are
// suppressed so the cursor can't leave the ortho axis. (#218)
ortho: bool,
// UCS→world rotation: tracking rays run along the UCS axes, matching
// ortho/polar. Identity = world-aligned rays.
ucs: glam::Mat4,
) -> Option<OtrackHit> {
if !self.otrack_enabled || self.tracking_points.is_empty() {
return None;
}
let cursor_screen = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds);
// Use the same aperture as OSNAP so the catch distance is uniform.
let r = self.osnap_radius_px;
let screen_dist = |w: Vec3| {
let s = world_to_screen(w.as_dvec3(), view_rot, eye, bounds);
((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt()
};
// Candidate angles in [0,180); each ray extends both ways via the
// signed projection `t`, so 0°/90° cover horizontal/vertical.
let mut angles: Vec<f32> = Vec::new();
match polar_step_deg.filter(|s| *s > 1e-3) {
Some(step) => {
let mut a = 0.0_f32;
while a < 180.0 - 1e-3 {
angles.push(a);
a += step;
}
}
None => {
angles.push(0.0);
angles.push(90.0);
}
}
// Build candidate rays tagged by origin group so two rays sharing an
// origin (a parallel pencil that only meets at that origin) are never
// intersected with each other.
struct Ray {
origin: Vec3,
dir: Vec3,
group: usize,
}
let mut rays: Vec<Ray> = Vec::new();
for (gi, &tp) in self.tracking_points.iter().enumerate() {
for &adeg in &angles {
let ar = adeg.to_radians();
rays.push(Ray {
origin: tp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
group: gi,
});
}
// Extension rays along the corner's own edges (world-space geometry
// directions — already oriented, no UCS rotation). Included in the
// single-ray set so pulling the cursor along a segment's extension
// locks to it. (#219)
if let Some(edirs) = self.tracking_dirs.get(gi) {
for &d in edirs {
rays.push(Ray {
origin: tp,
dir: d,
group: gi,
});
}
}
}
// OTRACK rays come first; the auxiliary rays appended below (polar from
// last_point, ortho axis from last_point) only participate in
// intersection locking, never in single-ray fallback.
let otrack_ray_count = rays.len();
const POLAR_GROUP: usize = usize::MAX;
const ORTHO_GROUP: usize = usize::MAX - 1;
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
let mut a = 0.0_f32;
while a < 180.0 - 1e-3 {
let ar = a.to_radians();
rays.push(Ray {
origin: lp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
group: POLAR_GROUP,
});
a += step;
}
}
// Ortho axis rays from `last_point`, so a tracking ray crossing the
// ortho axis locks on-axis (the useful corner-finding case). (#218)
let ortho_lock = ortho && last_point.is_some();
if let (true, Some(lp)) = (ortho_lock, last_point) {
for &adeg in &[0.0_f32, 90.0] {
let ar = adeg.to_radians();
rays.push(Ray {
origin: lp,
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
group: ORTHO_GROUP,
});
}
}
// ── Intersection lock — crossing of two vectors from distinct origins.
let mut best_x: Option<(f32, OtrackHit)> = None;
for i in 0..rays.len() {
for j in (i + 1)..rays.len() {
if rays[i].group == rays[j].group {
continue;
}
// Under an ortho lock only crossings that involve the ortho axis
// are valid — every other crossing lies off it. (#218)
if ortho_lock && rays[i].group != ORTHO_GROUP && rays[j].group != ORTHO_GROUP {
continue;
}
let Some(x) =
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
else {
continue;
};
let sd = screen_dist(x);
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
// Report an acquired tracking ray (not an auxiliary
// last_point ray) as base/dir for typed-distance entry.
let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP {
&rays[i]
} else {
&rays[j]
};
let t = (x.x - ot.origin.x) * ot.dir.x + (x.y - ot.origin.y) * ot.dir.y;
let dir_out = if t >= 0.0 { ot.dir } else { -ot.dir };
best_x = Some((
sd,
OtrackHit {
aligned: x,
dir: dir_out,
base: ot.origin,
},
));
}
}
}
if let Some((_, h)) = best_x {
return Some(h);
}
// With Ortho on and a base point, the axis is a hard lock: no single
// tracking ray may pull the cursor off it. Only crossings with the
// ortho axis (handled above) lock; otherwise defer to the caller's
// ortho constraint. (#218)
if ortho_lock {
return None;
}
// ── Single-ray alignment (OTRACK rays only) ──
let mut best: Option<(f32, OtrackHit)> = None;
for ray in rays.iter().take(otrack_ray_count) {
let t = (cursor_world.x - ray.origin.x) * ray.dir.x
+ (cursor_world.y - ray.origin.y) * ray.dir.y;
let aligned = Vec3::new(
ray.origin.x + ray.dir.x * t,
ray.origin.y + ray.dir.y * t,
ray.origin.z,
);
let sd = screen_dist(aligned);
if sd < r && best.as_ref().map_or(true, |(bd, _)| sd < *bd) {
let dir_out = if t >= 0.0 { ray.dir } else { -ray.dir };
best = Some((
sd,
OtrackHit {
aligned,
dir: dir_out,
base: ray.origin,
},
));
}
}
best.map(|(_, h)| h)
}
/// Clear all acquired tracking points (e.g. when command ends).
pub fn clear_tracking(&mut self) {
self.tracking_points.clear();
self.tracking_dirs.clear();
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
}
/// 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_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<SnapResult> {
let tmp = Snapper {
snap_enabled: true,
enabled: {
let mut s = HashSet::default();
s.insert(SnapType::Tangent);
s
},
grid_snap_on: false,
grid_spacing: self.grid_spacing,
osnap_radius_px: self.osnap_radius_px,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
from_point: None,
};
// Tangent-only: Grid is disabled here, so the grid basis is irrelevant.
tmp.snap(
cursor_world.as_dvec3(),
cursor_screen,
wires,
view_rot,
eye,
bounds,
Vec3::ZERO,
Mat4::IDENTITY,
)
}
/// Find the best snap candidate near the cursor.
pub fn snap(
&self,
cursor_world: glam::DVec3,
cursor_screen: Point,
wires: &[WireModel],
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
// Grid origin (render/wire space) and UCS→world rotation, so grid snap
// lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid.
grid_origin: Vec3,
grid_rot: Mat4,
) -> Option<SnapResult> {
// Object-snap selection is priority-then-distance, NOT nearest-wins.
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
// and are therefore almost always closer to the cursor than a discrete
// Endpoint/Midpoint/Center, so a pure-distance pick would let them mask
// every other enabled snap. Instead a higher-priority snap inside the
// snap circle wins even when a lower-priority one is closer; distance
// only breaks ties within the same priority. See #118.
let radius2 = self.osnap_radius_px * self.osnap_radius_px;
let mut best: Option<SnapResult> = None;
let mut best_rank = u8::MAX;
let mut best_d2 = f32::MAX;
// Reject candidates projecting outside the pane rectangle. The GPU
// scissors viewport content to exactly `bounds`, but the hit-test wire
// set reaches past it (the cull keeps a margin and lines run beyond the
// rect), so without this a snap could land on geometry clipped out of
// the viewport. `bounds` is the full canvas in model space, so this is a
// no-op there.
let in_bounds = |s: Point| -> bool {
s.x >= 0.0 && s.x <= bounds.width && s.y >= 0.0 && s.y <= bounds.height
};
// ── Grid snap — a SEPARATE system from object snap ───────────────────
// Grid snap has its own toggle (`grid_snap_on`) and is independent of
// the object-snap master (`snap_enabled`) and the object-snap mode set.
// Object snaps therefore NEVER catch grid points; only when grid snap is
// on can a grid corner be picked. It is evaluated first and at the
// lowest priority, so any object snap inside the aperture overrides it.
if self.grid_snap_on {
let s = self.grid_spacing as f64;
if s.abs() > 1e-9 {
// Round in the UCS grid frame, then map back to world.
let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3();
let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3();
let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3();
let origin = grid_origin.as_dvec3();
let rel = cursor_world - origin;
let ux = (rel.dot(ax) / s).round() * s;
let uy = (rel.dot(ay) / s).round() * s;
let uz = (rel.dot(az) / s).round() * s;
let gp = origin + ax * ux + ay * uy + az * uz;
let screen = world_to_screen(gp, view_rot, eye, bounds);
let d2 = dist2(screen, cursor_screen);
if d2 < radius2 && in_bounds(screen) {
best = Some(SnapResult {
world: gp,
screen,
snap_type: SnapType::Grid,
tangent_obj: None,
extension_base: None,
extension_base2: None,
});
best_rank = snap_priority(SnapType::Grid);
best_d2 = d2;
}
}
}
// Object snaps are gated by the object-snap master toggle. With it off
// only the grid result (if any) stands.
if !self.snap_enabled {
return best;
}
// 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_rot.col(0).x.abs() * bounds.width * 0.5;
if s > 1e-6 {
self.osnap_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 {
// The AABB is stored in f32, so at UTM-scale coordinates each bound
// is quantized by up to ~1 ulp (≈ coord × 2⁻²³ ≈ 0.7 m at 5.7e6).
// When zoomed in hard the snap radius shrinks below that, so the
// raw f32 bound can wrongly exclude a wire the cursor is on. Pad the
// test by the bound's own quantization so the cull never rejects a
// genuinely in-range wire (it only ever over-includes, which the
// per-vertex screen test below then rejects precisely).
let mag = wire
.aabb
.iter()
.fold(0.0f32, |m, c| m.max(c.abs()));
let pad = (mag * f32::EPSILON * 2.0) as f64;
let r = world_snap_r as f64 + pad;
cursor_world.x + r >= wire.aabb[0] as f64
&& cursor_world.x - r <= wire.aabb[2] as f64
&& cursor_world.y + r >= wire.aabb[1] as f64
&& cursor_world.y - r <= wire.aabb[3] as f64
};
let mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
let screen = world_to_screen(world, view_rot, eye, bounds);
if !in_bounds(screen) {
return;
}
let d2 = dist2(screen, cursor_screen);
// `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from
// degenerate geometry is rejected: with priority selection a NaN
// would otherwise pass the gate and be chosen on rank alone,
// feeding a NaN snap point to the renderer. (#118)
if !(d2 < radius2) {
return;
}
let rank = snap_priority(snap_type);
if rank < best_rank || (rank == best_rank && d2 < best_d2) {
best_rank = rank;
best_d2 = d2;
best = Some(SnapResult {
world,
screen,
snap_type,
tangent_obj: None,
extension_base: None,
extension_base2: 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,
SnapHint::Midpoint => SnapType::Midpoint,
};
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(
glam::DVec3::new(p[0], p[1], p[2]),
SnapType::Endpoint,
);
}
} else {
// Tessellated curves (Circle, Arc, Ellipse): only an OPEN
// one (an arc) has real endpoints. A full circle / ellipse is
// closed — its tessellation's first/last is a seam point, not
// an endpoint — and is the only tessellated curve that carries
// Quadrant snap hints (arcs never do), so emit no Endpoint for
// those (#275).
let closed = wire
.snap_pts
.iter()
.any(|(_, h)| matches!(h, SnapHint::Quadrant));
if !closed {
if let Some(&p) = wire.points.first() {
try_pt(glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), SnapType::Endpoint);
}
if wire.points.len() > 1 {
if let Some(&p) = wire.points.last() {
try_pt(glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), SnapType::Endpoint);
}
}
}
}
}
}
// ── Midpoint ───────────────────────────────────────────────────────
// Only explicit vertex sets (Line, LwPolyline) contribute per-segment
// midpoints. Tessellated curves (Circle, Arc, Ellipse, Spline) emit a
// single `SnapHint::Midpoint` snap_pt where one exists — iterating
// every chord here would otherwise turn a circle's tessellation into
// a haze of false midpoint hits. See #34.
if self.is_on(SnapType::Midpoint) {
for wire in wires {
if !wire_in_range(wire) {
continue;
}
if !wire.key_vertices.is_empty() {
for seg in wire.key_vertices.windows(2) {
let a = glam::DVec3::new(seg[0][0], seg[0][1], seg[0][2]);
let b = glam::DVec3::new(seg[1][0], seg[1][1], seg[1][2]);
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 i in 0..wire.points.len().saturating_sub(1) {
let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
try_pt(p, SnapType::Nearest);
}
}
}
// ── Perpendicular — foot of perpendicular from the drawing base ──
// Drop the foot from the point the command is drawing *from* (so the
// new segment is truly perpendicular to the target). Only when there
// is no base point — e.g. picking the very first point — does it fall
// back to the cursor (a plain nearest-on-line). The candidate is gated
// on its screen distance to the cursor like every other snap, so it
// offers when the cursor is near the perpendicular foot. (#118)
if self.is_on(SnapType::Perpendicular) {
let q = self.from_point.map(|v| v.as_dvec3()).unwrap_or(cursor_world);
for wire in wires {
if !wire_in_range(wire) {
continue;
}
for i in 0..wire.points.len().saturating_sub(1) {
if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) {
try_pt(foot, SnapType::Perpendicular);
}
}
}
}
// ── Intersection — segment-segment intersections ──────────
if self.is_on(SnapType::Intersection) {
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 ai in 0..wires[i].points.len().saturating_sub(1) {
// S: pre-convert outside inner loop
let a0 = wp_f64(&wires[i], ai);
let a1 = wp_f64(&wires[i], ai + 1);
let a_min_x = a0.x.min(a1.x);
let a_max_x = a0.x.max(a1.x);
let a_min_y = a0.y.min(a1.y);
let a_max_y = a0.y.max(a1.y);
for bi in 0..wires[j].points.len().saturating_sub(1) {
let b0 = wp_f64(&wires[j], bi);
let b1 = wp_f64(&wires[j], bi + 1);
// O: tight per-segment AABB overlap cull
if a_max_x < b0.x.min(b1.x)
|| a_min_x > b0.x.max(b1.x)
|| a_max_y < b0.y.min(b1.y)
|| a_min_y > b0.y.max(b1.y)
{
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 ──
// Every segment's line can be extended past either endpoint, so a
// polyline offers an extension off each of its vertices, not just the
// first and last (#259). Extension is live only from endpoints the user
// has acquired as temporary tracking points (#262), so with none
// acquired there is nothing to extend — skip the whole scan. That is the
// common case and keeps a large drawing responsive.
if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() {
for wire in wires {
let n = wire.points.len();
if n < 2 {
continue;
}
for i in 0..n - 1 {
let a = wp_f64(wire, i);
let b = wp_f64(wire, i + 1);
// NaN sentinels separate sub-paths — skip a segment spanning one.
if !a.x.is_finite() || !b.x.is_finite() || (a - b).length_squared() < 1e-18 {
continue;
}
// Only extend from an endpoint the user has acquired as a
// temporary tracking point, so the extension isn't live for
// every object in the drawing (#262).
// Beyond `a`, away from `b`.
if self.is_tracked_endpoint(a) {
if let Some(ext) = extension_snap(
cursor_world,
a,
a - b,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
// Beyond `b`, away from `a`.
if self.is_tracked_endpoint(b) {
if let Some(ext) = extension_snap(
cursor_world,
b,
b - a,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
}
}
}
}
// Extended intersection: where two segments would cross if their
// lines were extended. That crossing can be far from both segments,
// so `wire_in_range` (near-cursor segment) is the wrong gate — gather
// segments whose *infinite line* passes near the cursor instead, then
// pair them. A crossing inside both segments is a real Intersection,
// so skip it here (#247).
let filter2 = (self.osnap_radius_px * 2.0).powi(2);
let mut cand: Vec<(glam::DVec3, glam::DVec3)> = Vec::new();
for wire in wires {
for k in 0..wire.points.len().saturating_sub(1) {
let a0 = wp_f64(wire, k);
let a1 = wp_f64(wire, k + 1);
if !a0.x.is_finite() || !a1.x.is_finite() {
continue;
}
// Only lines with an acquired endpoint contribute an extended
// crossing, matching the per-segment extension gate (#262).
if !self.is_tracked_endpoint(a0) && !self.is_tracked_endpoint(a1) {
continue;
}
let s0 = world_to_screen(a0, view_rot, eye, bounds);
let s1 = world_to_screen(a1, view_rot, eye, bounds);
let ex = s1.x - s0.x;
let ey = s1.y - s0.y;
let l2 = ex * ex + ey * ey;
if l2 < 1e-6 {
continue;
}
// Perpendicular screen distance² from the cursor to the line.
let cross = ex * (cursor_screen.y - s0.y) - ey * (cursor_screen.x - s0.x);
if cross * cross / l2 <= filter2 {
cand.push((a0, a1));
}
}
}
for i in 0..cand.len() {
let (a0, a1) = cand[i];
let d1 = a1 - a0;
for &(b0, b1) in cand.iter().skip(i + 1) {
let d2 = b1 - b0;
let denom = d1.x * d2.y - d1.y * d2.x;
if denom.abs() < 1e-12 {
continue; // parallel
}
let t1 = ((b0.x - a0.x) * d2.y - (b0.y - a0.y) * d2.x) / denom;
let t2 = ((b0.x - a0.x) * d1.y - (b0.y - a0.y) * d1.x) / denom;
if (0.0..=1.0).contains(&t1) && (0.0..=1.0).contains(&t2) {
continue; // real crossing — handled by Intersection
}
// Emit as an Intersection, not an Extension: the crossing is
// a distinct point and must outrank the per-segment extension
// feet (which sit closer to the cursor on their own lines),
// or the cursor would snap to a line instead of the crossing.
let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z);
try_pt(pt, SnapType::Intersection);
}
}
}
// ── Apparent Intersection — screen-space intersections ─────────────
// L: pre-project each in-range wire's points to screen once, not once per segment pair.
if self.is_on(SnapType::ApparentIntersection) {
let screen_pts: Vec<Option<Vec<Point>>> = wires
.iter()
.map(|w| {
if !wire_in_range(w) {
return None;
}
Some(
(0..w.points.len())
.map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds))
.collect::<Vec<_>>(),
)
})
.collect();
for i in 0..wires.len() {
let Some(ref si) = screen_pts[i] else {
continue;
};
for j in (i + 1)..wires.len() {
let Some(ref sj) = screen_pts[j] else {
continue;
};
for ai in 0..wires[i].points.len().saturating_sub(1) {
let sa0 = si[ai];
let sa1 = si[ai + 1];
for bi in 0..wires[j].points.len().saturating_sub(1) {
let sb0 = sj[bi];
let sb1 = sj[bi + 1];
if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) {
let wa0 = wp_f64(&wires[i], ai);
let wa1 = wp_f64(&wires[i], ai + 1);
try_pt(wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection);
}
}
}
}
}
}
// ── 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(glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64), view_rot, eye, bounds);
let sp1 = world_to_screen(glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), view_rot, eye, 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.as_dvec3(), view_rot, eye, bounds);
let rim = world_to_screen(
glam::DVec3::new((cv.x + radius) as f64, cv.y as f64, cv.z as f64),
view_rot,
eye,
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)
};
// Circle lies in its own plane at cv.z; the point
// facing the cursor is center + radius·(nx, ny) in XY
// (the y-offset must land in Y, not Z — #274).
let w = Vec3::new(cv.x + radius * nx, cv.y + radius * ny, cv.z);
(w, edge_d * edge_d)
}
};
let rank = snap_priority(SnapType::Tangent);
let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds);
if d2 < radius2
&& in_bounds(screen_pt)
&& (rank < best_rank || (rank == best_rank && d2 < best_d2))
{
best_rank = rank;
best_d2 = d2;
let tangent_obj = match tg {
TangentGeom::Line { p1, p2 } => TangentObject::Line {
p1: glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64),
p2: glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64),
},
TangentGeom::Circle { center, radius } => TangentObject::Circle {
center: glam::DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64),
radius: *radius as f64,
},
};
best = Some(SnapResult {
world: world_pt.as_dvec3(),
screen: screen_pt,
snap_type: SnapType::Tangent,
tangent_obj: Some(tangent_obj),
extension_base: None,
extension_base2: None,
});
}
}
}
}
// ── Center via curve proximity ─────────────────────────────────────
// A circle/arc/ellipse's centre is offset from its curve — for an arc
// it usually sits in empty space well off the geometry — so gating the
// Center snap purely on the cursor's distance to the centre *point*
// (the pre-baked pass above) means hovering the curve, the natural
// gesture, never offers it. Mirror running-osnap behaviour: when the
// cursor is near such a curve, offer its centre, ranked by how close
// the cursor is to the curve. Runs here, after `try_pt`'s borrow ends,
// so it can update the candidate state directly. (#152)
if self.is_on(SnapType::Center) {
for wire in wires {
if !wire_in_range(wire) {
continue;
}
// Only tessellated curves carry a pre-baked Center hint; reuse
// it as the snap target. Lines / polylines have none → skip.
let Some(center) = wire
.snap_pts
.iter()
.find(|(_, h)| matches!(h, SnapHint::Center))
.map(|&(c, _)| c)
else {
continue;
};
// Nearest screen distance from the cursor to the curve itself.
let mut curve_d2 = f32::INFINITY;
for i in 0..wire.points.len().saturating_sub(1) {
let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
let sp = world_to_screen(p, view_rot, eye, bounds);
curve_d2 = curve_d2.min(dist2(sp, cursor_screen));
}
let screen = world_to_screen(center, view_rot, eye, bounds);
let rank = snap_priority(SnapType::Center);
if curve_d2 < radius2
&& in_bounds(screen)
&& (rank < best_rank || (rank == best_rank && curve_d2 < best_d2))
{
best_rank = rank;
best_d2 = curve_d2;
best = Some(SnapResult {
world: center,
screen,
snap_type: SnapType::Center,
tangent_obj: None,
extension_base: None,
extension_base2: None,
});
}
}
}
// If an Extension snap or an extended intersection won, re-find the
// endpoint(s) whose ray(s) it lies on so the overlay can draw the dashed
// guide line(s) back to them. An Extension yields one base; an extended
// intersection yields both crossing extensions. A genuine on-segment
// intersection yields none, so its guides simply don't draw. (#238, #247, #259)
if let Some(b) = best.as_mut() {
if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) {
let (b1, b2) = extension_bases_screen(b.world, wires, view_rot, eye, bounds);
b.extension_base = b1;
b.extension_base2 = b2;
}
}
best
}
}
// ── Object-snap priority ───────────────────────────────────────────────────
/// Selection priority for an object snap — lower wins. Discrete snaps that
/// land on a specific feature (Endpoint, Midpoint, Center, …) outrank the
/// "continuous" snaps (Perpendicular, Tangent, Nearest) that can sit anywhere
/// along the geometry, so enabling a continuous snap can't suppress the
/// discrete ones the user also turned on. Mirrors the usual CAD running-osnap
/// precedence. See #118.
fn snap_priority(t: SnapType) -> u8 {
match t {
SnapType::Endpoint => 0,
SnapType::Intersection => 1,
SnapType::ApparentIntersection => 2,
SnapType::Midpoint => 3,
SnapType::Center => 4,
SnapType::Node => 5,
SnapType::Quadrant => 6,
SnapType::Insertion => 7,
SnapType::ObjectPick => 8,
SnapType::Perpendicular => 9,
SnapType::Tangent => 10,
SnapType::Parallel => 11,
SnapType::Extension => 12,
SnapType::Nearest => 13,
SnapType::Grid => 14,
}
}
// ── Geometric helpers ─────────────────────────────────────────────────────
/// Line directions of every wire segment that has an endpoint at `p` (an
/// acquired corner), deduped by near-parallelism and capped. OTRACK offers an
/// alignment ray along each so the cursor can track a segment's extension, not
/// just the ortho/polar axes (#219). Scanned once, at acquisition — not per
/// move. Empty when `p` is not a segment endpoint (midpoint / centre / node).
fn edge_dirs_at(p: Vec3, wires: &[WireModel]) -> Vec<Vec3> {
// The acquired point is an f32 truncation of the true (f64) vertex, so its
// error grows with coordinate magnitude (~1 ULP ≈ 1.2e-7·mag). Scale the
// endpoint-match tolerance to a few multiples of that, with a tight floor
// near the origin — a fixed fraction would span metres at UTM scale and
// match unrelated vertices, while a magnitude-blind floor would miss the
// corner once f32 rounding exceeds it.
let tol = 1e-4_f32.max(4e-7 * p.x.abs().max(p.y.abs()));
let tol2 = (tol * tol) as f64;
let pd = p.as_dvec3();
let mut dirs: Vec<Vec3> = Vec::new();
'outer: for wire in wires {
let n = wire.points.len();
if n < 2 {
continue;
}
for i in 0..n - 1 {
let a = wp_f64(wire, i);
let b = wp_f64(wire, i + 1);
if !a.x.is_finite() || !b.x.is_finite() {
continue; // NaN sentinel separates sub-paths
}
if (a - pd).length_squared() >= tol2 && (b - pd).length_squared() >= tol2 {
continue; // neither endpoint is the acquired corner
}
let seg = b - a;
let l = (seg.x * seg.x + seg.y * seg.y).sqrt();
if l < 1e-9 {
continue;
}
let d = Vec3::new((seg.x / l) as f32, (seg.y / l) as f32, 0.0);
// Skip a direction already present (parallel within ~0.5°); the ray
// is bidirectional, so opposite signs are the same alignment line.
if dirs.iter().any(|e| (e.x * d.x + e.y * d.y).abs() > 0.99996) {
continue;
}
dirs.push(d);
if dirs.len() >= 6 {
break 'outer;
}
}
}
dirs
}
/// Reconstruct the absolute f64 position of wire vertex `i` from its
/// double-single high/low pair. At UTM-scale coordinates the `points` (high)
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so
/// computed snaps (nearest/perp/intersection/extension) land on the geometry.
#[inline]
fn wp_f64(wire: &WireModel, i: usize) -> glam::DVec3 {
let h = wire.points[i];
let l = wire.points_low.get(i).copied().unwrap_or([0.0; 3]);
glam::DVec3::new(
h[0] as f64 + l[0] as f64,
h[1] as f64 + l[1] as f64,
h[2] as f64 + l[2] as f64,
)
}
/// Closest point on segment [p0, p1] to `query`.
fn nearest_on_segment(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> glam::DVec3 {
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);
glam::DVec3::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: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option<glam::DVec3> {
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(glam::DVec3::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: glam::DVec3, a1: glam::DVec3, b0: glam::DVec3, b1: glam::DVec3) -> Option<glam::DVec3> {
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(glam::DVec3::new(a0.x + t * d1x, a0.y + t * d1y, 0.0))
}
/// Intersection of two infinite lines in the XY plane, each given by an origin
/// and a direction. Returns `None` when the lines are parallel.
fn line_intersect_xy(o1: Vec3, d1: Vec3, o2: Vec3, d2: Vec3) -> Option<Vec3> {
let cross = d1.x * d2.y - d1.y * d2.x;
if cross.abs() < 1e-9 {
return None;
}
let ex = o2.x - o1.x;
let ey = o2.y - o1.y;
let t = (ex * d2.y - ey * d2.x) / cross;
Some(Vec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z))
}
/// 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: glam::DVec3,
origin: glam::DVec3,
dir: glam::DVec3,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
radius_px: f32,
) -> Option<glam::DVec3> {
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 = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
return None;
}
Some(world_pt)
}
/// Find the endpoint(s) whose outward extension the snapped point lies on, and
/// return their screen positions so the overlay can draw a dashed guide from
/// each back to the snap point. A lone Extension snap yields one base; an
/// extended intersection (two extension lines crossing) yields both — so both
/// contributing extensions stay drawn when the crossing is caught. A genuine
/// on-segment intersection yields none: its crossing is between the endpoints,
/// never past them (`t < 0.05`). (#238, #247, #259)
fn extension_bases_screen(
snapped: glam::DVec3,
wires: &[WireModel],
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> (Option<Point>, Option<Point>) {
let snapped_screen = world_to_screen(snapped, view_rot, eye, bounds);
// Collect qualifying endpoints, off-ray distance measured in screen space so
// the tolerance stays scale-independent at UTM coordinates (a world² test
// would reject the crossing base once coordinates reach ~1e7).
let mut found: Vec<(f32, glam::DVec3, Point)> = Vec::new();
for wire in wires {
let n = wire.points.len();
if n < 2 {
continue;
}
// Match the extension snap: every segment can be extended past either
// endpoint, so scan them all to find the base(s) the snapped point sits on.
for i in 0..n - 1 {
let a = wp_f64(wire, i);
let b = wp_f64(wire, i + 1);
if !a.x.is_finite() || !b.x.is_finite() {
continue;
}
for (origin, other) in [(a, b), (b, a)] {
let dir = origin - other;
let len2 = dir.x * dir.x + dir.y * dir.y;
if len2 < 1e-12 {
continue;
}
let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2;
if t < 0.05 {
continue; // must be beyond the endpoint, matching extension_snap
}
let on = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
let off = dist2(world_to_screen(on, view_rot, eye, bounds), snapped_screen);
if off <= 4.0 {
let base = world_to_screen(origin, view_rot, eye, bounds);
found.push((off, origin, base));
}
}
}
}
// Nearest-fit first, then keep up to two with distinct origins (collinear
// segments sharing an endpoint must not draw the same guide twice).
found.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
let mut bases: [Option<Point>; 2] = [None, None];
let mut origins: Vec<glam::DVec3> = Vec::new();
for (_, origin, base) in found {
if origins.iter().any(|o| (*o - origin).length_squared() < 1e-12) {
continue;
}
origins.push(origin);
if bases[0].is_none() {
bases[0] = Some(base);
} else {
bases[1] = Some(base);
break;
}
}
(bases[0], bases[1])
}
// ── Projection helpers ────────────────────────────────────────────────────
/// Project a world point to screen relative-to-eye: subtract the f64 eye first
/// so the result is precise at UTM-scale absolute coordinates (a full
/// view-projection with a ~1e7 translation cancels catastrophically in f32).
/// `view_rot` is the rotation-only view-projection (Camera::view_proj_rte).
fn world_to_screen(world: glam::DVec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
let rel = (world - eye).as_vec3();
let ndc = view_rot.project_point3(rel);
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)
}
#[cfg(test)]
mod ext_tests {
use super::*;
#[test]
fn tracking_active_covers_otrack_and_extension() {
let mut s = Snapper::default();
// OTRACK off, Extension not enabled → no acquisition.
s.snap_enabled = true;
s.otrack_enabled = false;
assert!(!s.tracking_active());
// Extension on with the snap master on → acquire, independent of OTRACK.
s.enabled.insert(SnapType::Extension);
assert!(s.tracking_active());
// Extension is gated by the snap master.
s.snap_enabled = false;
assert!(!s.tracking_active());
// OTRACK acquires regardless of the object-snap master.
s.enabled.remove(&SnapType::Extension);
s.otrack_enabled = true;
assert!(s.tracking_active());
}
#[test]
fn extension_only_tracks_acquired_endpoints() {
let mut s = Snapper::default();
// Nothing acquired → no endpoint is a live extension source (#262).
assert!(!s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0)));
// Acquire an endpoint → only that vertex tracks.
s.tracking_points.push(Vec3::new(10.0, 0.0, 0.0));
assert!(s.is_tracked_endpoint(glam::DVec3::new(10.0, 0.0, 0.0)));
assert!(!s.is_tracked_endpoint(glam::DVec3::new(5.0, 0.0, 0.0)));
// The match tolerance scales with coordinate magnitude, so an acquired
// vertex at UTM scale still matches its f32-truncated tracking point.
let big = 1_234_567.0_f64;
s.tracking_points.push(Vec3::new(big as f32, 0.0, 0.0));
assert!(s.is_tracked_endpoint(glam::DVec3::new(big, 0.0, 0.0)));
}
#[test]
fn extension_acquisition_keeps_only_endpoints() {
let mut s = Snapper::default();
// A single line segment (0,0)-(10,0): its endpoints are vertices, its
// midpoint and any extension foot are not.
let wire = WireModel {
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
..Default::default()
};
let wires = [wire];
// Extension-driven acquisition (endpoints_only): a midpoint — like an
// extension foot the cursor paused on — is ignored, so it can't fill the
// buffer and evict the real endpoint (#262).
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, true);
assert!(s.tracking_points.is_empty());
// The genuine endpoint is acquired.
s.acquire_tracking_point(Vec3::new(10.0, 0.0, 0.0), &wires, true);
assert_eq!(s.tracking_points.len(), 1);
// OTRACK (endpoints_only = false) still acquires any snap point.
s.acquire_tracking_point(Vec3::new(5.0, 0.0, 0.0), &wires, false);
assert_eq!(s.tracking_points.len(), 2);
}
}