LINE: deferred tangent/perpendicular for the first point (fixes #69) (#168)

TAN and PER snaps need the other end of the line, so the first point of a
LINE (no base point yet) could never snap tangent: a belt line tangent to
two circles was impossible to draw.

Now, as in AutoCAD, a point prompt can accept *deferred* snaps:

- snap: with no base and the new `deferred` flag, osnap offers "Deferred
  Tangent" (circles, arcs) and "Deferred Perpendicular" (circles, arcs,
  lines, xlines, rays) where no other snap wins. The hit carries the curve
  (`SnapHit::deferred`, `Deferred`, `SnapCurve`).
- `resolve_deferred` solves the line once the other end is known: tangent
  or perpendicular from a point, tangent-to-tangent (common tangents of two
  circles, new `Circle::common_tangents` in geom) and the perpendicular
  mixes. Among several solutions the one nearest the picks wins. No
  solution (point inside the circle, concentric or nested circles,
  non-finite input) is `None`, never a panic.
- prompt: `Prompt::deferred()` opts a prompt in; `Input::Deferred` reaches
  only such prompts, everywhere else the session turns it into the point it
  was picked at, so other commands snap as before.
- LINE opts in for its first point (and the next one while the first is
  still deferred). No solution reports and re-prompts; Undo drops the
  deferred point. The rubber band shows the resolved line, also the
  tangent-to-tangent one while hovering the second curve
  (`Session::cursor_deferred`, set by the canvas).
- canvas: passes the prompt's flag to osnap and sends a deferred pick as
  `Input::Deferred`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:15:18 +02:00 committed by GitHub
parent 4d61f57eec
commit 7666c1cd68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 590 additions and 22 deletions

View File

@ -103,7 +103,7 @@ numbers):
| Command line | Prompts with keywords, history, AutoComplete, aliases, `@dx,dy`, `@d<a`, `#x,y`, direct distance entry, Enter/space/right-click to repeat, transparent commands, `.scr`-style scripts |
| Draw | LINE, PLINE (arcs, widths), CIRCLE (center/radius/diameter, 2P, 3P), ARC, RECTANG (fillet/chamfer), POLYGON, ELLIPSE (+arcs), SPLINE, POINT, XLINE, RAY, DONUT, TEXT, MTEXT |
| Modify | ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, STRETCH, OFFSET, TRIM, EXTEND, FILLET, CHAMFER, BREAK, JOIN, EXPLODE, rectangular/polar ARRAY, draw order, OVERKILL |
| Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest), polar tracking, ortho, grid snap |
| Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest; deferred tangent/perpendicular for the first point of a line, e.g. belt lines tangent to two circles), polar tracking, ortho, grid snap |
| Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour |
| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts, MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font |
| Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks |

View File

@ -245,7 +245,7 @@ fn perf(args: &[String]) -> 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)));

View File

@ -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<CommandSpec> {
vec![
@ -343,6 +343,23 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
struct LineM {
pts: Vec<Vec2>,
handles: Vec<cadcraft_doc::Handle>,
/// A first point picked as a deferred tangent/perpendicular, resolved by the next point.
first: Option<snap::Deferred>,
}
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<Step> {
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<Step> {
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<EntityKind> {
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
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()
}
}

View File

@ -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<snap::Deferred>,
/// Viewport size in pixels (from the UI; used for zoom and pick apertures).
pub viewport_px: (f64, f64),
pub clipboard: Vec<Entity>,
@ -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 {

View File

@ -37,11 +37,15 @@ pub struct Prompt {
pub accept: Accept,
/// Rubber-band origin (for direct distance entry, ortho and polar).
pub base: Option<Vec2>,
/// 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<String>, 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<String>) -> 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),

View File

@ -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<Deferred>,
}
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<Segment> {
}
/// 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<Vec2>) -> Option<SnapHit> {
///
/// 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<Vec2>, deferred: bool) -> Option<SnapHit> {
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<Vec2>,
deferred: bool,
) -> Option<SnapHit> {
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<SnapHit> {
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<Vec2> = 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<Vec<(Vec2, Vec2)>> {
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<Vec2> {
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<Item = (Vec2, Vec2)>, at1: Vec2, at2: Option<Vec2>) -> 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());
}
}

View File

@ -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());
}

View File

@ -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)));
}

View File

@ -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.

View File

@ -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)));
}

View File

@ -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() {

View File

@ -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)) {