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>
1608 lines
69 KiB
Rust
1608 lines
69 KiB
Rust
//! 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);
|
||
}
|
||
}
|