diff --git a/README.md b/README.md index 9d07787..d906bcc 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ numbers): | Command line | Prompts with keywords, history, AutoComplete, aliases, `@dx,dy`, `@d Result<(), String> { let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT; let t = Instant::now(); - let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None).is_some()).count(); + let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count(); let total = ms(t); rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0))); diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 188ea9e..ea03fed 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::helpers::*; use super::machines::number; use super::*; -use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap}; pub fn specs() -> Vec { vec![ @@ -343,6 +343,23 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result { struct LineM { pts: Vec, handles: Vec, + /// A first point picked as a deferred tangent/perpendicular, resolved by the next point. + first: Option, +} + +impl LineM { + /// Draw the first segment from the deferred first point to `end`. + fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result { + let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else { + let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" }; + return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point."))); + }; + self.handles.push(s.add_entity(line(a, b))?); + self.pts = vec![a, b]; + self.first = None; + s.last_point = b; + Ok(Step::Continue) + } } impl Interactive for LineM { @@ -351,13 +368,35 @@ impl Interactive for LineM { } fn prompt(&self, _s: &Session) -> Prompt { match self.pts.len() { - 0 => Prompt::new("Specify first point", Accept::POINT), + 0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(), + 0 => Prompt::new("Specify first point", Accept::POINT).deferred(), 1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.pts.is_empty() + && let Some(first) = self.first + { + match i { + Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)), + Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)), + _ => {} + } + } match i { + Input::Deferred(d) if self.pts.is_empty() => { + self.first = Some(d); + Ok(Step::Continue) + } + // A deferred pick with a known last point (not offered by the prompt, but harmless). + Input::Deferred(d) => match self.pts.last().copied() { + Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) { + Some((_, p)) => self.input(s, Input::Point(p)), + None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())), + }, + None => Ok(Step::Continue), + }, Input::Point(p) => { if let Some(last) = self.pts.last().copied() { if last.near(p, 1e-12) { @@ -369,6 +408,9 @@ impl Interactive for LineM { Ok(Step::Continue) } Input::Keyword(k) if k == "Undo" => { + if self.first.take().is_some() { + return Ok(Step::Continue); + } if let Some(h) = self.handles.pop() { s.doc_mut()?.remove_entity(h); } @@ -385,7 +427,12 @@ impl Interactive for LineM { _ => Err(crate::EngineError::Other("Point or option keyword required.".into())), } } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(first) = self.first { + // Over a second curve, show the tangent-to-tangent line the click would draw. + let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred); + return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default(); + } self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default() } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1f6dbc9..96691ba 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -298,6 +298,9 @@ pub struct Session { pub last_point: Vec2, /// Cursor position in world coordinates (from the UI; used for direct distance entry). pub cursor: Vec2, + /// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the + /// rubber band show the line it resolves to). + pub cursor_deferred: Option, /// Viewport size in pixels (from the UI; used for zoom and pick apertures). pub viewport_px: (f64, f64), pub clipboard: Vec, @@ -331,6 +334,7 @@ impl Session { last_command: None, last_point: Vec2::ZERO, cursor: Vec2::ZERO, + cursor_deferred: None, viewport_px: (1200.0, 800.0), clipboard: Vec::new(), clipboard_base: Vec2::ZERO, @@ -546,6 +550,12 @@ impl Session { } return Ok(()); } + // A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the + // point it was picked at. + let input = match input { + Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at), + other => other, + }; // Generic selection handling during "Select objects:" prompts. let input = self.preprocess_selection(input)?; match input { diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index ddd42e2..d516e99 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -37,11 +37,15 @@ pub struct Prompt { pub accept: Accept, /// Rubber-band origin (for direct distance entry, ortho and polar). pub base: Option, + /// The command resolves deferred tangent/perpendicular snaps ([`Input::Deferred`]) at this + /// prompt (a point with no base yet, e.g. the first point of a line). Elsewhere a deferred + /// pick arrives as the plain point it was picked at. + pub deferred: bool, } impl Prompt { pub fn new(message: impl Into, accept: Accept) -> Self { - Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None } + Prompt { message: message.into(), keywords: Vec::new(), default: None, accept, base: None, deferred: false } } pub fn kw(mut self, k: &[&str]) -> Self { self.keywords = k.iter().map(|s| s.to_string()).collect(); @@ -55,6 +59,11 @@ impl Prompt { self.base = p; self } + /// Accept deferred tangent/perpendicular snaps (the `deferred` field). + pub fn deferred(mut self) -> Self { + self.deferred = true; + self + } pub fn default(mut self, d: impl Into) -> Self { self.default = Some(d.into()); self @@ -98,6 +107,9 @@ impl Prompt { #[serde(tag = "kind", content = "value", rename_all = "camelCase")] pub enum Input { Point(Vec2), + /// A deferred tangent/perpendicular snap, only sent to prompts that accept it + /// (`Prompt.deferred`). + Deferred(crate::snap::Deferred), Keyword(String), /// Free text or a number/distance/angle to be parsed by the command. Text(String), diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 92ecc0c..3958ec7 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -46,6 +46,49 @@ pub struct SnapHit { pub point: Vec2, pub mode: u32, pub name: &'static str, + /// A deferred tangent/perpendicular: `point` is only provisional (the nearest point on the + /// curve) until the other end of the line is known. See [`resolve_deferred`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub deferred: Option, +} + +impl SnapHit { + /// The command input a pick at this snap gives: a deferred snap or a plain point. + pub fn input(&self) -> crate::Input { + match self.deferred { + Some(d) => crate::Input::Deferred(d), + None => crate::Input::Point(self.point), + } + } +} + +/// The curve a deferred snap refers to. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum SnapCurve { + /// A circle, or the circle an arc lies on. + Circle { center: Vec2, radius: f64 }, + /// The infinite line through `a` and `b` (a line, xline or ray). + Line { a: Vec2, b: Vec2 }, +} + +/// A deferred tangent ([`mode::TAN`]) or perpendicular ([`mode::PER`]) snap, picked while there +/// is no base point yet (the first point of a line). The real point depends on the other end of +/// the line, so it is resolved later with [`resolve_deferred`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Deferred { + pub mode: u32, + pub curve: SnapCurve, + /// Where the curve was picked (the nearest point on it); chooses between solutions. + pub at: Vec2, +} + +/// One end of a line being solved: a known point or a deferred snap. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LineEnd { + Point(Vec2), + Deferred(Deferred), } fn name_of(m: u32) -> &'static str { @@ -75,14 +118,20 @@ fn prim_segments(p: &Prim) -> Vec { } /// Find the best object snap near `cursor` within `aperture` (world units). -pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option) -> Option { +/// +/// Tangent and perpendicular snaps need the base point (the other end of the line). With no +/// base and `deferred` set (the prompt says it can resolve them, `Prompt.deferred`), +/// they snap as *deferred*: the hit carries the curve and is resolved once the other end is +/// known. Deferred hits only win where no other snap does (they are as unspecific as Nearest). +pub fn osnap(d: &Drawing, space: &Space, cursor: Vec2, aperture: f64, osmode: u32, base: Option, deferred: bool) -> Option { if osmode == 0 || osmode & mode::OFF != 0 { return None; } let ix = crate::spatial::index(d, space); - osnap_with(d, space, &ix, cursor, aperture, osmode, base) + osnap_with(d, space, &ix, cursor, aperture, osmode, base, deferred) } +#[allow(clippy::too_many_arguments)] pub(crate) fn osnap_with( d: &Drawing, space: &Space, @@ -91,6 +140,7 @@ pub(crate) fn osnap_with( aperture: f64, osmode: u32, base: Option, + deferred: bool, ) -> Option { if osmode == 0 || osmode & mode::OFF != 0 { return None; @@ -251,7 +301,13 @@ pub(crate) fn osnap_with( da.total_cmp(&db) }); if let Some((m, p)) = best { - return Some(SnapHit { point: *p, mode: *m, name: name_of(*m) }); + return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), deferred: None }); + } + if base.is_none() + && deferred + && let Some(h) = deferred_hit(&near_prims, cursor, aperture, osmode) + { + return Some(h); } if osmode & mode::NEA != 0 { let mut nb: Option<(f64, Vec2)> = None; @@ -271,12 +327,152 @@ pub(crate) fn osnap_with( } } if let Some((_, p)) = nb { - return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest" }); + return Some(SnapHit { point: p, mode: mode::NEA, name: "Nearest", deferred: None }); } } None } +/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`. +fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Option { + let mut best: Option<(f64, SnapHit)> = None; + for p in prims { + let curve = match p { + Prim::Circle(c) => SnapCurve::Circle { center: c.center, radius: c.radius }, + Prim::Seg(Segment::Arc { arc, .. }) => SnapCurve::Circle { center: arc.center, radius: arc.radius }, + Prim::Seg(Segment::Line(l)) => SnapCurve::Line { a: l.a, b: l.b }, + Prim::Infinite { base, dir, .. } => SnapCurve::Line { a: *base, b: *base + *dir }, + _ => continue, + }; + let (m, name, at) = match curve { + SnapCurve::Circle { center, radius } if osmode & mode::TAN != 0 => { + (mode::TAN, "Deferred Tangent", Circle::new(center, radius).closest(cursor)) + } + SnapCurve::Circle { center, radius } if osmode & mode::PER != 0 => { + (mode::PER, "Deferred Perpendicular", Circle::new(center, radius).closest(cursor)) + } + SnapCurve::Line { a, b } if osmode & mode::PER != 0 => { + // Pick on the drawn extent: a segment's own span, an xline/ray anywhere. + let l = cadcraft_geom::Line::new(a, b); + (mode::PER, "Deferred Perpendicular", if matches!(p, Prim::Seg(_)) { l.closest(cursor) } else { l.project(cursor) }) + } + _ => continue, + }; + let dd = at.dist(cursor); + if at.is_finite() && dd <= aperture && best.is_none_or(|(bd, _)| dd < bd) { + best = Some((dd, SnapHit { point: at, mode: m, name, deferred: Some(Deferred { mode: m, curve, at }) })); + } + } + best.map(|(_, h)| h) +} + +/// Solve a line whose ends may be deferred tangent/perpendicular snaps: returns its two +/// endpoints (in the order given), or `None` when there is no such line (a point inside the +/// circle it should be tangent to, concentric circles, non-parallel lines both perpendicular to +/// it, degenerate or non-finite geometry). Among several solutions, the one whose deferred +/// points lie closest to where the curves were picked wins (as AutoCAD does). +pub fn resolve_deferred(a: LineEnd, b: LineEnd) -> Option<(Vec2, Vec2)> { + match (a, b) { + (LineEnd::Point(p), LineEnd::Point(q)) => (p.is_finite() && q.is_finite()).then_some((p, q)), + (LineEnd::Point(_), LineEnd::Deferred(_)) => resolve_deferred(b, a).map(|(q, p)| (p, q)), + (LineEnd::Deferred(d), LineEnd::Point(p)) => { + if !p.is_finite() { + return None; + } + let cands: Vec = match (d.mode, d.curve) { + (mode::TAN, SnapCurve::Circle { center, radius }) => Circle::new(center, radius).tangent_points(p), + (mode::PER, SnapCurve::Circle { center, radius }) => { + let u = (p - center).normalized(); + if u == Vec2::ZERO { + // Every radius is perpendicular: keep the picked point. + vec![Circle::new(center, radius).closest(d.at)] + } else { + vec![center + u * radius, center - u * radius] + } + } + (mode::PER, SnapCurve::Line { a, b }) => line_dir(a, b).map(|_| vec![cadcraft_geom::Line::new(a, b).project(p)]).unwrap_or_default(), + _ => Vec::new(), + }; + pick_best(cands.into_iter().map(|t| (t, p)), d.at, None) + } + (LineEnd::Deferred(d1), LineEnd::Deferred(d2)) => { + let pairs = deferred_pairs(&d1, &d2).or_else(|| deferred_pairs(&d2, &d1).map(|v| v.into_iter().map(|(q, p)| (p, q)).collect()))?; + pick_best(pairs.into_iter(), d1.at, Some(d2.at)) + } + } +} + +/// Candidate lines between two deferred snaps (`None` when this order isn't handled; the +/// caller retries swapped). +fn deferred_pairs(d1: &Deferred, d2: &Deferred) -> Option> { + use SnapCurve::{Circle as C, Line as L}; + Some(match ((d1.mode, d1.curve), (d2.mode, d2.curve)) { + ((mode::TAN, C { center: c1, radius: r1 }), (mode::TAN, C { center: c2, radius: r2 })) => { + Circle::new(c1, r1).common_tangents(&Circle::new(c2, r2)) + } + // Perpendicular to a circle = through its center. + ((mode::TAN, C { center, radius }), (mode::PER, C { center: k, radius: rk })) => Circle::new(center, radius) + .tangent_points(k) + .into_iter() + .flat_map(|t| { + let u = (t - k).normalized(); + [(t, k + u * rk), (t, k - u * rk)] + }) + .collect(), + // Perpendicular to a line: the tangent runs along the line's normal. + ((mode::TAN, C { center, radius }), (mode::PER, L { a, b })) => { + let Some(v) = line_dir(a, b) else { return Some(Vec::new()) }; + let l = cadcraft_geom::Line::new(a, b); + [center + v * radius, center - v * radius].into_iter().map(|t| (t, l.project(t))).collect() + } + ((mode::PER, C { center: k1, radius: r1 }), (mode::PER, C { center: k2, radius: r2 })) => { + let u = (k2 - k1).normalized(); + if u == Vec2::ZERO { + return Some(Vec::new()); + } + let mut v = Vec::new(); + for s1 in [1.0, -1.0] { + for s2 in [1.0, -1.0] { + v.push((k1 + u * (r1 * s1), k2 + u * (r2 * s2))); + } + } + v + } + ((mode::PER, C { center: k, radius: rk }), (mode::PER, L { a, b })) => { + let Some(v) = line_dir(a, b) else { return Some(Vec::new()) }; + let f = cadcraft_geom::Line::new(a, b).project(k); + let u = if f.dist(k) > cadcraft_geom::EPS { (f - k).normalized() } else { v.perp() }; + vec![(k + u * rk, f), (k - u * rk, f)] + } + ((mode::PER, L { a: a1, b: b1 }), (mode::PER, L { a: a2, b: b2 })) => { + // Only parallel lines share a perpendicular; it passes through the first pick. + let (Some(v1), Some(v2)) = (line_dir(a1, b1), line_dir(a2, b2)) else { return Some(Vec::new()) }; + if v1.cross(v2).abs() > 1e-9 { + return Some(Vec::new()); + } + let p = cadcraft_geom::Line::new(a1, b1).project(d1.at); + vec![(p, cadcraft_geom::Line::new(a2, b2).project(p))] + } + _ => return None, + }) +} + +/// Unit direction of the line through `a` and `b`, if it has one. +fn line_dir(a: Vec2, b: Vec2) -> Option { + let v = (b - a).normalized(); + (v != Vec2::ZERO && v.is_finite()).then_some(v) +} + +/// The finite, non-degenerate candidate closest to the picks. +fn pick_best(cands: impl Iterator, at1: Vec2, at2: Option) -> Option<(Vec2, Vec2)> { + cands + .filter(|(p, q)| p.is_finite() && q.is_finite() && p.dist(*q) > cadcraft_geom::EPS) + .map(|(p, q)| (p.dist(at1) + at2.map_or(0.0, |a| q.dist(a)), (p, q))) + .filter(|(cost, _)| cost.is_finite()) + .min_by(|x, y| x.0.total_cmp(&y.0)) + .map(|(_, l)| l) +} + /// Full circles are stored as zero-sweep arcs in `prim_segments`; give them a full sweep. fn fix_circle(s: &Segment) -> Segment { match *s { @@ -356,16 +552,16 @@ mod tests { #[test] fn endpoint_and_mid() { let d = drawing(); - let h = osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::MID, None).unwrap(); + let h = osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::MID, None, false).unwrap(); assert_eq!(h.point, Vec2::new(10.0, 0.0)); - let m = osnap(&d, &Space::Model, Vec2::new(5.1, 0.1), 0.5, mode::MID, None).unwrap(); + let m = osnap(&d, &Space::Model, Vec2::new(5.1, 0.1), 0.5, mode::MID, None, false).unwrap(); assert_eq!(m.point, Vec2::new(5.0, 0.0)); } #[test] fn intersection() { let d = drawing(); - let h = osnap(&d, &Space::Model, Vec2::new(5.2, 0.2), 0.5, mode::INT, None).unwrap(); + let h = osnap(&d, &Space::Model, Vec2::new(5.2, 0.2), 0.5, mode::INT, None, false).unwrap(); assert!(h.point.near(Vec2::new(5.0, 0.0), 1e-9)); assert_eq!(h.name, "Intersection"); } @@ -373,16 +569,16 @@ mod tests { #[test] fn center_quadrant_tangent() { let d = drawing(); - assert_eq!(osnap(&d, &Space::Model, Vec2::new(20.1, 0.1), 0.5, mode::CEN, None).unwrap().point, Vec2::new(20.0, 0.0)); - assert!(osnap(&d, &Space::Model, Vec2::new(20.0, 2.1), 0.5, mode::QUA, None).unwrap().point.near(Vec2::new(20.0, 2.0), 1e-9)); - let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0))); + assert_eq!(osnap(&d, &Space::Model, Vec2::new(20.1, 0.1), 0.5, mode::CEN, None, false).unwrap().point, Vec2::new(20.0, 0.0)); + assert!(osnap(&d, &Space::Model, Vec2::new(20.0, 2.1), 0.5, mode::QUA, None, false).unwrap().point.near(Vec2::new(20.0, 2.0), 1e-9)); + let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)), false); assert!(t.is_some()); } #[test] fn perpendicular_from_base() { let d = drawing(); - let h = osnap(&d, &Space::Model, Vec2::new(3.0, 0.2), 0.5, mode::PER, Some(Vec2::new(3.0, 4.0))).unwrap(); + let h = osnap(&d, &Space::Model, Vec2::new(3.0, 0.2), 0.5, mode::PER, Some(Vec2::new(3.0, 4.0)), false).unwrap(); assert!(h.point.near(Vec2::new(3.0, 0.0), 1e-9)); } @@ -399,6 +595,115 @@ mod tests { #[test] fn off_bit_disables() { let d = drawing(); - assert!(osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::OFF, None).is_none()); + assert!(osnap(&d, &Space::Model, Vec2::new(9.8, 0.1), 0.5, mode::END | mode::OFF, None, false).is_none()); + } + + /// Distance from `c` to the infinite line through `p` and `q`. + fn line_dist(c: Vec2, p: Vec2, q: Vec2) -> f64 { + ((q - p).cross(c - p) / p.dist(q)).abs() + } + + fn tan(center: (f64, f64), radius: f64, at: (f64, f64)) -> LineEnd { + let curve = SnapCurve::Circle { center: Vec2::new(center.0, center.1), radius }; + LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::new(at.0, at.1) }) + } + + fn pt(x: f64, y: f64) -> LineEnd { + LineEnd::Point(Vec2::new(x, y)) + } + + #[test] + fn deferred_tangent_without_base() { + let d = drawing(); + let at = Vec2::new(20.0, 2.1); + // Without a base, tangent snaps only as deferred, and only where the prompt resolves it. + assert!(osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, false).is_none()); + let h = osnap(&d, &Space::Model, at, 0.5, mode::TAN, None, true).unwrap(); + assert_eq!((h.mode, h.name), (mode::TAN, "Deferred Tangent")); + assert!(h.point.near(Vec2::new(20.0, 2.0), 1e-9)); + let df = h.deferred.unwrap(); + assert_eq!(df.curve, SnapCurve::Circle { center: Vec2::new(20.0, 0.0), radius: 2.0 }); + assert_eq!(h.input(), crate::Input::Deferred(df)); + // With a base it is an ordinary tangent. + let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)), true).unwrap(); + assert!(t.deferred.is_none() && t.name == "Tangent"); + // A specific snap still wins over a deferred one. + let q = osnap(&d, &Space::Model, at, 0.5, mode::TAN | mode::QUA, None, true).unwrap(); + assert_eq!(q.mode, mode::QUA); + // Deferred perpendicular on a line. + let p = osnap(&d, &Space::Model, Vec2::new(2.0, 0.2), 0.5, mode::PER, None, true).unwrap(); + assert_eq!(p.name, "Deferred Perpendicular"); + assert!(p.point.near(Vec2::new(2.0, 0.0), 1e-9)); + } + + #[test] + fn resolve_tangent_to_free_point() { + let (a, b) = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(20.0, 5.0)).unwrap(); + assert!(b.near(Vec2::new(20.0, 5.0), 1e-12)); + assert!((a.len() - 5.0).abs() < 1e-9 && (line_dist(Vec2::ZERO, a, b) - 5.0).abs() < 1e-9); + assert!(a.y > 0.0, "the tangent point nearest the pick wins"); + let (a2, _) = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, -5.0)), pt(20.0, 5.0)).unwrap(); + assert!(a2.y < 0.0); + // Order of the ends is kept. + let (p, q) = resolve_deferred(pt(20.0, 5.0), tan((0.0, 0.0), 5.0, (0.0, 5.0))).unwrap(); + assert!(p.near(Vec2::new(20.0, 5.0), 1e-12) && q.near(a, 1e-12)); + } + + #[test] + fn resolve_belt_tangent_between_circles() { + let (c1, r1, c2, r2) = (Vec2::ZERO, 10.0, Vec2::new(30.0, 0.0), 4.0); + // Both picked on top: the outer (belt) tangent above. + let (a, b) = resolve_deferred(tan((0.0, 0.0), r1, (0.0, 10.0)), tan((30.0, 0.0), r2, (30.0, 4.0))).unwrap(); + assert!((line_dist(c1, a, b) - r1).abs() < 1e-9 && (line_dist(c2, a, b) - r2).abs() < 1e-9); + assert!((a.dist(c1) - r1).abs() < 1e-9 && (b.dist(c2) - r2).abs() < 1e-9); + assert!(a.y > 0.0 && b.y > 0.0); + // Top of one, bottom of the other: the crossed (inner) tangent. + let (a, b) = resolve_deferred(tan((0.0, 0.0), r1, (0.0, 10.0)), tan((30.0, 0.0), r2, (30.0, -4.0))).unwrap(); + assert!((line_dist(c1, a, b) - r1).abs() < 1e-9 && (line_dist(c2, a, b) - r2).abs() < 1e-9); + assert!(a.y > 0.0 && b.y < 0.0); + } + + #[test] + fn resolve_perpendicular_cases() { + let per_line = |a: (f64, f64), b: (f64, f64), at: (f64, f64)| { + let curve = SnapCurve::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) }; + LineEnd::Deferred(Deferred { mode: mode::PER, curve, at: Vec2::new(at.0, at.1) }) + }; + // Perpendicular from a free point onto a line (its extension counts). + let (a, b) = resolve_deferred(per_line((0.0, 0.0), (10.0, 0.0), (5.0, 0.0)), pt(15.0, 7.0)).unwrap(); + assert!(a.near(Vec2::new(15.0, 0.0), 1e-9) && b.near(Vec2::new(15.0, 7.0), 1e-12)); + // Tangent to a circle and perpendicular to a line: runs along the line's normal. + let (a, b) = resolve_deferred(tan((0.0, 10.0), 2.0, (2.0, 10.0)), per_line((0.0, 0.0), (10.0, 0.0), (2.0, 0.0))).unwrap(); + assert!(a.near(Vec2::new(2.0, 10.0), 1e-9) && b.near(Vec2::new(2.0, 0.0), 1e-9)); + // Non-parallel lines have no common perpendicular. + assert!(resolve_deferred(per_line((0.0, 0.0), (10.0, 0.0), (1.0, 0.0)), per_line((0.0, 0.0), (0.0, 10.0), (0.0, 1.0))).is_none()); + } + + #[test] + fn resolve_degenerate_input_is_none_not_panic() { + // Point inside (or on) the circle: no tangent. + assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(1.0, 1.0)).is_none()); + assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(5.0, 0.0)).is_none()); + // Concentric or nested circles: no common tangent. + assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((0.0, 0.0), 2.0, (0.0, 2.0))).is_none()); + assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((1.0, 0.0), 2.0, (1.0, 2.0))).is_none()); + // Same circle twice. + assert!(resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), tan((0.0, 0.0), 5.0, (0.0, -5.0))).is_none()); + // Zero radius: degenerates to a line through the point, without panicking. + let _ = resolve_deferred(tan((0.0, 0.0), 0.0, (0.0, 0.0)), pt(10.0, 0.0)); + let _ = resolve_deferred(tan((0.0, 0.0), 0.0, (0.0, 0.0)), tan((10.0, 0.0), 0.0, (10.0, 0.0))); + // Non-finite and huge numbers. + for v in [f64::NAN, f64::INFINITY, -f64::INFINITY, 1e308, -1e308] { + let _ = resolve_deferred(tan((v, 0.0), 5.0, (0.0, 5.0)), pt(20.0, v)); + let _ = resolve_deferred(tan((0.0, 0.0), v, (v, 5.0)), tan((v, v), 2.0, (0.0, v))); + let r = resolve_deferred(tan((0.0, 0.0), 5.0, (0.0, 5.0)), pt(v, 0.0)); + assert!(r.is_none_or(|(a, b)| a.is_finite() && b.is_finite())); + } + // A degenerate line can't be perpendicular to anything. + let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::ZERO }; + assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::PER, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none()); + // Tangent to a line is meaningless. + let curve = SnapCurve::Line { a: Vec2::ZERO, b: Vec2::X }; + assert!(resolve_deferred(LineEnd::Deferred(Deferred { mode: mode::TAN, curve, at: Vec2::ZERO }), pt(3.0, 3.0)).is_none()); } } diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index 45bbcaf..a8b50e9 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -401,8 +401,8 @@ mod tests { let p = Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0)); let base = if k % 2 == 0 { Some(Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))) } else { None }; let m = modes[k % 3]; - let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base); - let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base); + let a = snap::osnap_with(&d, &Space::Model, &ix, p, 1.0, m, base, false); + let b = snap::osnap_with(&d, &Space::Model, &None, p, 1.0, m, base, false); assert_eq!(a, b, "p {p:?} mode {m}"); hits += usize::from(a.is_some()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 692ef54..c0bf388 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -606,3 +606,136 @@ fn mleader_and_qdim() { let r = s.execute("qdim", &json!({"handles": hs, "at": [0, -2]})).unwrap(); assert_eq!(r["handles"].as_array().unwrap().len(), 2); } + +/// Click at `at` the way the canvas does: object snap for the current prompt, then the input. +fn snap_click(s: &mut Session, at: Vec2) { + let prompt = s.current_prompt().unwrap(); + let hit = snap::osnap(s.doc().unwrap(), &cadcraft_doc::Space::Model, at, 0.5, s.settings.osmode, prompt.base, prompt.deferred); + s.input(hit.map_or(Input::Point(at), |h| h.input())).unwrap(); +} + +fn line_ends(s: &Session) -> Vec<(Vec2, Vec2)> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }) + .collect() +} + +/// Distance from `c` to the infinite line through `p` and `q`. +fn line_dist(c: Vec2, p: Vec2, q: Vec2) -> f64 { + ((q - p).cross(c - p) / p.dist(q)).abs() +} + +#[test] +fn line_first_point_deferred_tangent() { + let mut s = Session::new(); + s.cmdline("circle 0,0 5").unwrap(); + s.execute("osnap", &json!({"modes": ["tan"]})).unwrap(); + s.cmdline("line").unwrap(); + assert!(s.current_prompt().unwrap().deferred); + snap_click(&mut s, Vec2::new(0.1, 5.2)); // the top of the circle: deferred tangent + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Specify next point"); + assert!(p.base.is_none() && p.deferred); + // The rubber band already runs tangent from the circle to the cursor. + let pv = s.preview(Vec2::new(20.0, 5.0)); + assert_eq!(pv.len(), 1); + snap_click(&mut s, Vec2::new(20.0, 5.0)); // a free point + let ends = line_ends(&s); + assert_eq!(ends.len(), 1); + let (a, b) = ends[0]; + assert!(b.near(Vec2::new(20.0, 5.0), 1e-9)); + assert!((a.len() - 5.0).abs() < 1e-9 && (line_dist(Vec2::ZERO, a, b) - 5.0).abs() < 1e-9 && a.y > 0.0); + // The line goes on from the second point as usual. + let p = s.current_prompt().unwrap(); + assert_eq!(p.base, Some(b)); + assert!(!p.deferred); + s.cmdline("@0,5").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(line_ends(&s).len(), 2); + assert!(line_ends(&s)[1].0.near(b, 1e-12)); +} + +#[test] +fn line_belt_tangent_to_tangent() { + let mut s = Session::new(); + s.cmdline("circle 0,0 10").unwrap(); + s.cmdline("circle 30,0 4").unwrap(); + s.execute("osnap", &json!({"modes": ["tan"]})).unwrap(); + // Top of the large circle to the top of the small one: the outer (belt) tangent. + s.cmdline("line").unwrap(); + snap_click(&mut s, Vec2::new(-1.0, 10.1)); + // Hovering the small circle previews the line the click will draw. + let hover = snap::osnap(s.doc().unwrap(), &cadcraft_doc::Space::Model, Vec2::new(31.0, 4.1), 0.5, s.settings.osmode, None, true).unwrap(); + s.cursor_deferred = hover.deferred; + let pv = s.preview(hover.point); + s.cursor_deferred = None; + snap_click(&mut s, Vec2::new(31.0, 4.1)); + s.cmdline("").unwrap(); + assert_eq!(pv.len(), 1); + assert_eq!(Some(&pv[0].kind), s.doc().unwrap().model.iter().last().map(|e| &e.kind)); + // Bottom of the large one to the top of the small one: the crossed tangent. + s.cmdline("line").unwrap(); + snap_click(&mut s, Vec2::new(0.0, -10.2)); + snap_click(&mut s, Vec2::new(30.0, 4.2)); + s.cmdline("").unwrap(); + let ends = line_ends(&s); + assert_eq!(ends.len(), 2); + for (a, b) in &ends { + assert!((a.dist(Vec2::ZERO) - 10.0).abs() < 1e-9 && (b.dist(Vec2::new(30.0, 0.0)) - 4.0).abs() < 1e-9); + assert!((line_dist(Vec2::ZERO, *a, *b) - 10.0).abs() < 1e-9); + assert!((line_dist(Vec2::new(30.0, 0.0), *a, *b) - 4.0).abs() < 1e-9); + } + assert!(ends[0].0.y > 0.0 && ends[0].1.y > 0.0); + assert!(ends[1].0.y < 0.0 && ends[1].1.y > 0.0); +} + +#[test] +fn line_deferred_tangent_without_solution_reprompts() { + let mut s = Session::new(); + s.cmdline("circle 0,0 10").unwrap(); + s.cmdline("circle 0,0 4").unwrap(); + s.execute("osnap", &json!({"modes": ["tan"]})).unwrap(); + s.cmdline("line").unwrap(); + snap_click(&mut s, Vec2::new(0.0, 10.1)); + // A point inside the circle: no tangent; the command reports it and keeps prompting. + snap_click(&mut s, Vec2::new(1.0, 1.0)); + assert!(s.running.is_some() && line_ends(&s).is_empty()); + assert!(s.current_prompt().unwrap().deferred); + // Concentric circles: no common tangent either. + snap_click(&mut s, Vec2::new(0.0, 4.1)); + assert!(s.running.is_some() && line_ends(&s).is_empty()); + // Undo drops the deferred first point. + s.cmdline("u").unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify first point"); + s.cmdline("20,0 30,0").unwrap(); + assert_eq!(line_ends(&s).len(), 1); + s.cmdline("").unwrap(); + // Enter right after a deferred first point just ends the command. + s.cmdline("line").unwrap(); + snap_click(&mut s, Vec2::new(0.0, 10.1)); + s.cmdline("").unwrap(); + assert!(s.running.is_none() && line_ends(&s).len() == 1); +} + +#[test] +fn deferred_pick_elsewhere_is_a_plain_point() { + let mut s = Session::new(); + let curve = snap::SnapCurve::Circle { center: Vec2::ZERO, radius: 5.0 }; + let d = snap::Deferred { mode: snap::mode::TAN, curve, at: Vec2::new(0.0, 5.0) }; + // CIRCLE's center prompt doesn't resolve deferred snaps: it gets the picked point. + s.cmdline("circle").unwrap(); + assert!(!s.current_prompt().unwrap().deferred); + s.input(Input::Deferred(d)).unwrap(); + s.cmdline("2").unwrap(); + let c = s.doc().unwrap().model.iter().find_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + _ => None, + }); + assert_eq!(c, Some(Vec2::new(0.0, 5.0))); +} diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 62932f1..f657d1a 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -176,6 +176,37 @@ impl Circle { let off = (self.radius / d).clamp(-1.0, 1.0).acos(); vec![Vec2::polar(self.center, self.radius, base + off), Vec2::polar(self.center, self.radius, base - off)] } + /// Lines tangent to both `self` and `other`, as (point on `self`, point on `other`) pairs: + /// up to two outer (belt) tangents and two inner (crossed) tangents. Empty for concentric + /// circles or when one circle lies inside the other; tangents that touch both circles at the + /// same point (circles touching each other) are left out. + pub fn common_tangents(&self, other: &Circle) -> Vec<(Vec2, Vec2)> { + let (r1, r2) = (self.radius, other.radius); + let v = other.center - self.center; + let d = v.len(); + if !(d.is_finite() && r1.is_finite() && r2.is_finite()) || d <= EPS { + return Vec::new(); + } + let u = v / d; + let mut out = Vec::new(); + // `s` = +1: outer tangents (both circles on the same side); -1: inner tangents. + for s in [1.0, -1.0] { + let c = (r1 - s * r2) / d; + if c.abs() > 1.0 - EPS { + continue; + } + let h = (1.0 - c * c).max(0.0).sqrt(); + for k in [1.0, -1.0] { + // Unit normal of the tangent line, pointing from each center towards its tangent point. + let n = u * c + u.perp() * (h * k); + let (p, q) = (self.center + n * r1, other.center + n * (s * r2)); + if p.is_finite() && q.is_finite() && p.dist(q) > EPS { + out.push((p, q)); + } + } + } + out + } } /// An ellipse (or elliptical arc), DXF-style: center, major-axis endpoint vector, ratio, params. diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 996431a..22875b7 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -157,3 +157,27 @@ fn ocs_identity_for_z() { let p = m.apply(Vec3::new(1.0, 0.0, 0.0)); assert!((p.x + 1.0).abs() < 1e-9); } + +#[test] +fn common_tangents_of_two_circles() { + // Distance from `c` to the infinite line through `p`, `q`. + let dist = |c: Vec2, p: Vec2, q: Vec2| ((q - p).cross(c - p) / p.dist(q)).abs(); + let (a, b) = (Circle::new(Vec2::ZERO, 10.0), Circle::new(Vec2::new(30.0, 0.0), 4.0)); + let ts = a.common_tangents(&b); + assert_eq!(ts.len(), 4, "separate circles: 2 outer + 2 inner tangents"); + for (p, q) in &ts { + assert!(close(p.dist(a.center), 10.0) && close(q.dist(b.center), 4.0)); + assert!(close(dist(a.center, *p, *q), 10.0) && close(dist(b.center, *p, *q), 4.0)); + } + // Overlapping circles only have the two outer tangents. + assert_eq!(a.common_tangents(&Circle::new(Vec2::new(12.0, 0.0), 4.0)).len(), 2); + // Concentric, nested, touching inside, non-finite: none, and no panic. + assert!(a.common_tangents(&Circle::new(Vec2::ZERO, 3.0)).is_empty()); + assert!(a.common_tangents(&Circle::new(Vec2::new(1.0, 0.0), 3.0)).is_empty()); + assert!(a.common_tangents(&Circle::new(Vec2::new(6.0, 0.0), 4.0)).is_empty()); + assert!(a.common_tangents(&Circle::new(Vec2::new(f64::NAN, 0.0), 4.0)).is_empty()); + assert!(a.common_tangents(&Circle::new(Vec2::new(f64::INFINITY, 0.0), 4.0)).is_empty()); + // A zero-radius circle (a point): the tangents from that point. + let z = a.common_tangents(&Circle::new(Vec2::new(30.0, 0.0), 0.0)); + assert!(!z.is_empty() && z.iter().all(|(p, q)| close(dist(a.center, *p, *q), 10.0) && q.near(Vec2::new(30.0, 0.0), 1e-9))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38311c4..e976cf2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -418,6 +418,7 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 { let hot = app.canvas.hot_grip; let wants_point = hot.is_some() || prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); let base = hot.map(|g| g.base).or_else(|| prompt.as_ref().and_then(|p| p.base)); + let deferred = hot.is_none() && prompt.as_ref().is_some_and(|p| p.deferred); let s = app.session.settings.clone(); app.canvas.snap = None; app.canvas.polar_angle = None; @@ -426,7 +427,7 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 { } let ap = s.aperture / xf.scale; if let Ok(st) = app.session.state() - && let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base) + && let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base, deferred) { app.canvas.snap = Some(hit); return hit.point; @@ -552,9 +553,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let eff = effective_point(app, w, &xf); app.canvas.cursor = Some(eff); app.session.cursor = eff; + app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); } else { app.canvas.cursor = None; app.canvas.snap = None; + app.session.cursor_deferred = None; } // Clicks. @@ -565,7 +568,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { { if app.session.running.is_some() { app.canvas.hot_grip = None; - if let Err(e) = app.session.input(Input::Point(p)) { + // A deferred tangent/perpendicular goes to the command as such. + let input = app.canvas.snap.filter(|h| h.deferred.is_some()).map_or(Input::Point(p), |h| h.input()); + if let Err(e) = app.session.input(input) { app.session.echo(e.to_string()); } } else if app.canvas.hot_grip.is_some() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4a4acdf..08882a9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -174,6 +174,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou let wp = if world { Vec2::new(x, y) } else { app.canvas.xf.map(|xf| xf.to_world(egui::pos2(x as f32, y as f32))).unwrap_or(Vec2::new(x, y)) }; app.session.cursor = wp; + app.session.cursor_deferred = None; app.canvas.cursor = Some(wp); if action == "move" { if let Some(sp) = app.canvas.xf.map(|xf| xf.to_screen(wp)) {