Browse / github.com/storytold/cadcraft / patch
CIRCLE: tangent snaps for the radius and for 3P points (fixes #1512)
Cadcraft - circles cannot snap to tangents if I am for example, tracing a curved object, in Autocacd I would start by creating a 3point circle then I would create another where the radius changes using snap to tangent for one of the 3 points to give a smooth blend between the two lines Circle does not allow you to snap to tangent when setting the diameter
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ZachAR3 (imported from pull request #2103, open)
- Base
fcfdf33001cf2462bdafb6e1237becd8ef0ea1cb- Size
- +200 −17 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8d28952a263fef51db6935fb
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/curves.rs crates/engine/src/cmd/draw.rs crates/engine/src/prompt.rs crates/engine/src/snap.rs crates/engine/tests/it/snap_curves.rs crates/engine/tests/it/snap_overrides.rs docs/ui-parity.md
@@ -344,6 +344,11 @@ fn eq_variants(o: &TanObj) -> Vec<Eq3> { /// Circle tangent to three objects, nearest the pick points (Newton on all sign combinations). pub fn ttt(objs: [(TanObj, Vec2); 3]) -> Option<Circle> {+ ttt_where(objs, |_| true)+}++/// [`ttt`] among the circles `ok` accepts.+pub fn ttt_where(objs: [(TanObj, Vec2); 3], ok: impl Fn(&Circle) -> bool) -> Option<Circle> { let [(o1, p1), (o2, p2), (o3, p3)] = objs; let mut starts: Vec<[f64; 3]> = Vec::new(); let cen = (p1 + p2 + p3) / 3.0;@@ -380,6 +385,9 @@ pub fn ttt(objs: [(TanObj, Vec2); 3]) -> Option<Circle> { continue; } let c = Circle::new(Vec2::new(x[0], x[1]), x[2]);+ if !ok(&c) {+ continue;+ } let sc = score(&c, &objs); if best.is_none_or(|(b, _)| sc < b - 1e-12) { best = Some((sc, c));@@ -740,6 +740,8 @@ impl Interactive for PlineM { struct CircleM { mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR pts: Vec<Vec2>,+ /// 3P: the circle or arc each point was picked on with a tangent snap, by point.+ tans: Vec<Option<snap::SnapCurve>>, diameter: bool, /// The `Ttr` option hands over to the tangent-circle machine. delegate: Option<Box<dyn Interactive>>,@@ -758,18 +760,22 @@ impl Interactive for CircleM { match (self.mode, n) { (0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]), (0, _) if self.diameter => {- Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()+ Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance().deferred() } (0, _) => {- let p =- Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();+ let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER)+ .kw(&["Diameter"])+ .base_opt(self.pts.first().copied())+ .distance()+ .deferred(); if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p } } (1, k) => Prompt::new( ["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""), Accept::POINT, )- .base_opt(self.pts.last().copied()),+ .base_opt(self.pts.last().copied())+ .deferred(), (2, k) => Prompt::new( if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" }, Accept::POINT,@@ -809,7 +815,14 @@ impl Interactive for CircleM { let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) }; Some(Circle::new(*c, r)) }- 1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]),+ 1 if self.pts.len() == 3 => match self.three_points() {+ Some(c) => Some(c),+ None => {+ self.pts.pop();+ self.tans.truncate(self.pts.len());+ return Err(crate::EngineError::Other("Circle does not exist.".into()));+ }+ }, 2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])), _ => None, };@@ -819,6 +832,29 @@ impl Interactive for CircleM { } Ok(Step::Continue) }+ // 3P: a point picked with a tangent snap makes the circle tangent to that curve (#1512).+ Input::Deferred(d) if self.mode == 1 => {+ let tan = (d.mode == snap::mode::TAN && !matches!(d.curve, snap::SnapCurve::Line { .. })).then_some(d.curve);+ self.tans.resize(self.pts.len(), None);+ self.tans.push(tan);+ self.input(s, Input::Point(d.at))+ }+ // A tangent picked for the radius: the circle around the center that touches the+ // curve, on the side it was picked (#1512).+ Input::Deferred(d) if self.mode == 0 && self.pts.len() == 1 => {+ let Some(&c) = self.pts.first() else { return Ok(Step::Continue) };+ // Around the picked curve's own center the circle would lie on it, not touch it.+ let concentric =+ matches!(d.curve, snap::SnapCurve::Circle { center, .. } | snap::SnapCurve::Arc { center, .. } if center.near(c, 1e-9));+ let touch = d.curve.perpendiculars(c, d.at).into_iter().filter(|_| !concentric).min_by(|a, b| a.dist(d.at).total_cmp(&b.dist(d.at)));+ match touch.map(|t| c.dist(t)).filter(|r| *r > 1e-12 && r.is_finite()) {+ Some(r) => {+ finish_circle(s, Circle::new(c, r))?;+ Ok(Step::Done)+ }+ None => Err(crate::EngineError::Other("No circle around the center is tangent there.".into())),+ }+ } Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => { let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?; let r = if self.diameter { v / 2.0 } else { v };@@ -863,6 +899,38 @@ impl Interactive for CircleM { } } +impl CircleM {+ /// The 3P circle: through the three points, or tangent to the curves picked with TAN.+ fn three_points(&self) -> Option<Circle> {+ let [a, b, c] = <[Vec2; 3]>::try_from(self.pts.as_slice()).ok()?;+ if self.tans.iter().all(Option::is_none) {+ return Circle::from_3_points(a, b, c);+ }+ let tan = |i: usize| self.tans.get(i).copied().flatten();+ // A plain point is a circle of radius zero to pass through; an arc is solved as its circle.+ let target = |i: usize, p: Vec2| {+ let circle = match tan(i) {+ Some(snap::SnapCurve::Circle { center, radius } | snap::SnapCurve::Arc { center, radius, .. }) => Circle::new(center, radius),+ _ => Circle::new(p, 0.0),+ };+ super::curves::TanObj::Circle(circle)+ };+ let pts = [a, b, c];+ // The circle must touch an arc within its sweep, not elsewhere on the arc's circle, and+ // not be the picked circle itself (which lies on it rather than touching it).+ let touches = |found: &Circle| {+ pts.iter().enumerate().all(|(i, p)| {+ tan(i).is_none_or(|t| {+ let same = matches!(target(i, *p), super::curves::TanObj::Circle(o)+ if o.center.dist(found.center) + (o.radius - found.radius).abs() <= 1e-9 * o.radius);+ !same && t.contains(target(i, *p).touch(found))+ })+ })+ };+ super::curves::ttt_where([(target(0, a), a), (target(1, b), b), (target(2, c), c)], touches)+ }+}+ fn finish_circle(s: &mut Session, c: Circle) -> Result<()> { s.add_entity(circle(c.center, c.radius))?; s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);@@ -38,8 +38,9 @@ pub struct Prompt { /// 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.+ /// prompt (a point with no base yet, e.g. the first point of a line; with a base, only+ /// tangents, e.g. a circle's radius). Elsewhere a deferred pick arrives as the plain point it+ /// was picked at. pub deferred: bool, /// The base is a rectangle's first corner (ZOOM Window, RECTANG, ARRAYRECT's unit cell): ortho /// does not bend the opposite corner, so the rectangle keeps its area.@@ -110,7 +110,7 @@ impl SnapCurve { } /// Points on the curve whose perpendicular passes through `p`; `picked` is where the curve /// was picked, kept when `p` is the center of a circle or arc (every radius is perpendicular).- fn perpendiculars(&self, p: Vec2, picked: Vec2) -> Vec<Vec2> {+ pub(crate) fn perpendiculars(&self, p: Vec2, picked: Vec2) -> Vec<Vec2> { match self { SnapCurve::Circle { center, radius } | SnapCurve::Arc { center, radius, .. } => { let u = (p - *center).normalized();@@ -202,7 +202,9 @@ fn prim_segments(p: &Prim) -> Vec<Segment> { /// 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).+/// known. With a base and `deferred` set, tangents to circles and arcs are deferred too (CIRCLE's+/// radius: the circle around the base that touches the curve, #1512). 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;@@ -491,6 +493,8 @@ fn finish( } if osmode & mode::TAN != 0 { let pts = match p {+ // A deferred prompt resolves tangents to circles and arcs itself.+ Prim::Seg(Segment::Arc { .. }) | Prim::Circle(_) if deferred => Vec::new(), // Only the tangent points that lie on the arc itself (not its circle). Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius) .tangent_points(bp)@@ -516,10 +520,9 @@ fn finish( if let Some((m, p)) = best { return Some(SnapHit { point: *p, mode: *m, name: name_of(*m), viewport: None, deferred: None }); }- if base.is_none()- && deferred- && let Some(h) = deferred_hit(near_prims, cursor, aperture, osmode, clip)- {+ // With a base, perpendiculars are resolved from it above; only tangents are left to defer.+ let deferrable = if base.is_some() { osmode & mode::TAN } else { osmode };+ if deferred && let Some(h) = deferred_hit(near_prims, cursor, aperture, deferrable, clip) { return Some(h); } if osmode & mode::NEA != 0 {@@ -1171,9 +1174,14 @@ mod tests { 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();+ // With a base it is an ordinary tangent, unless the prompt resolves it (CIRCLE's radius).+ let t = osnap(&d, &Space::Model, Vec2::new(19.9, 1.9), 1.0, mode::TAN, Some(Vec2::new(10.0, 0.0)), false).unwrap(); assert!(t.deferred.is_none() && t.name == "Tangent");+ let t = osnap(&d, &Space::Model, at, 0.5, mode::TAN, Some(Vec2::new(10.0, 0.0)), true).unwrap();+ assert_eq!(t.name, "Deferred Tangent");+ // A perpendicular from the base is still resolved from it.+ let p = osnap(&d, &Space::Model, Vec2::new(2.0, 0.2), 0.5, mode::PER, Some(Vec2::new(2.0, 5.0)), true).unwrap();+ assert!(p.deferred.is_none() && p.point.near(Vec2::new(2.0, 0.0), 1e-9)); // 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);@@ -70,6 +70,9 @@ fn elliptical_arc_perpendicular_and_tangent_from_a_base_point() { let (q, m) = along_quarter(&s, mode::TAN, Some(base)).expect("a tangent point on the arc"); assert_eq!(m, mode::TAN); assert!(on_quarter(q) && Vec2::new(q.x / 16.0, q.y / 4.0).dot(q - base).abs() < 1e-9, "{q:?}");+ // The same tangent at a prompt that resolves deferred snaps (CIRCLE's 3P points, #1512).+ let h = snap::osnap(s.doc().unwrap(), &Space::Model, q, 0.3, mode::TAN, Some(base), true).expect("tangent at a deferred prompt");+ assert!(h.mode == mode::TAN && h.deferred.is_none() && h.point.near(q, 1e-9), "{h:?}"); // Perpendicular from (10,10): the line from the base runs along the normal. let base = Vec2::new(10.0, 10.0); let (q, m) = along_quarter(&s, mode::PER, Some(base)).expect("a perpendicular foot on the arc");@@ -21,6 +21,17 @@ fn last_line(s: &Session) -> (Vec2, Vec2) { s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a.xy(), l.b.xy())) } else { None }).last().unwrap() } +/// The last circle drawn as (center, radius).+fn last_circle(s: &Session) -> (Vec2, f64) {+ s.doc()+ .unwrap()+ .model+ .iter()+ .filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some((c.center.xy(), c.radius)) } else { None })+ .last()+ .unwrap()+}+ fn near(p: Vec2, x: f64, y: f64) -> bool { p.near(Vec2::new(x, y), 1e-9) }@@ -116,3 +127,86 @@ fn a_command_keyword_wins_over_a_snap_word() { s.cmdline("arc _cen").unwrap(); assert!(s.prompt_text().ends_with("Center of:"), "{}", s.prompt_text()); }++#[test]+fn tan_for_a_circle_radius_touches_the_picked_circle() {+ // #1512: the circle around the center that touches the circle at 20,20 radius 3, on the side+ // it was picked: the near side (radius 7), the far side (13), and through Diameter.+ for (script, center, r) in [+ ("CIRCLE 30,20 TAN 23.02,20.03\n", (30.0, 20.0), 7.0),+ ("CIRCLE 30,20 TAN 16.98,19.97\n", (30.0, 20.0), 13.0),+ ("CIRCLE 20,10 D TAN 20.03,17.02\n", (20.0, 10.0), 7.0),+ ] {+ let mut s = drawing();+ s.script(script).unwrap();+ assert!(s.running.is_none(), "{script}: {}", s.prompt_text());+ let (c, got) = last_circle(&s);+ assert!(near(c, center.0, center.1) && (got - r).abs() < 1e-9, "{script}: {c:?} r={got}");+ }+}++#[test]+fn tan_for_a_3p_circle_touches_the_picked_circles() {+ // #1512: through two points and tangent to the circle at 20,20 radius 3, the tangent pick+ // first or last.+ for script in ["CIRCLE 3P 26,20 26,26 TAN 23.02,20.03\n", "CIRCLE 3P TAN 23.02,20.03 26,20 26,26\n"] {+ let mut s = drawing();+ s.script(script).unwrap();+ assert!(s.running.is_none(), "{script}: {}", s.prompt_text());+ let (c, r) = last_circle(&s);+ assert!((c.dist(Vec2::new(26.0, 20.0)) - r).abs() < 1e-9 && (c.dist(Vec2::new(26.0, 26.0)) - r).abs() < 1e-9, "{script}");+ assert!((c.dist(Vec2::new(20.0, 20.0)) - (3.0 + r)).abs() < 1e-9, "{script}: {c:?} r={r}");+ }+ // Tan, Tan, Tan: the circle between three circles of radius 1, picked on their inner sides.+ let mut s = Session::new();+ s.script("CIRCLE 0,0 1\nCIRCLE 6,0 1\nCIRCLE 3,5 1\nCIRCLE 3P TAN 0.98,0.2 TAN 5.02,0.2 TAN 3.01,3.98\n").unwrap();+ assert!(s.running.is_none(), "{}", s.prompt_text());+ let (c, r) = last_circle(&s);+ for k in [(0.0, 0.0), (6.0, 0.0), (3.0, 5.0)] {+ assert!((c.dist(Vec2::new(k.0, k.1)) - (1.0 + r)).abs() < 1e-9, "{c:?} r={r}");+ }+}++#[test]+fn tan_for_a_3p_circle_touches_an_arc_within_its_sweep() {+ // #1512: through 26,20 and 26,26, the circles tangent to the circle at 20,20 radius 3 touch it+ // on the right or the far left, both off an arc swept from 90° to 100°: no circle.+ let mut s = Session::new();+ s.execute("arc", &serde_json::json!({"center": [20, 20], "radius": 3, "start": 90, "end": 100})).unwrap();+ s.script("CIRCLE 3P 26,20 26,26 TAN 19.74,22.99\n").unwrap();+ assert!(s.prompt_text().contains("third point"), "{}", s.prompt_text());+ assert!(!s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Circle(_))));+}++#[test]+fn tan_for_a_3p_circle_takes_the_solution_that_touches_the_arc() {+ // #1512: through 26,20 and 26,26, the circles tangent to the circle at 20,20 radius 3 touch it+ // at about 29.6° and -103.3°. Picked at 20° on an arc swept from 200° to 25°, the 29.6°+ // circle is nearer the pick but off the arc: the -103.3° one (radius 6.0826) is drawn.+ let mut s = Session::new();+ s.execute("arc", &serde_json::json!({"center": [20, 20], "radius": 3, "start": 200, "end": 25})).unwrap();+ s.script("CIRCLE 3P 26,20 26,26 TAN 22.82,21.03\n").unwrap();+ assert!(s.running.is_none(), "{}", s.prompt_text());+ let (c, r) = last_circle(&s);+ assert!((c.dist(Vec2::new(20.0, 20.0)) - (r - 3.0)).abs() < 1e-9 && (r - 6.082575694955838).abs() < 1e-9, "{c:?} r={r}");+}++#[test]+fn tan_for_a_circle_radius_refuses_the_picked_circles_own_center() {+ // #1512: a circle around 20,20 would lie on the circle there, not touch it.+ let mut s = drawing();+ s.script("CIRCLE 20,20 TAN 23.02,20.03\n").unwrap();+ assert!(s.prompt_text().contains("radius of circle"), "{}", s.prompt_text());+ assert!(s.log.iter().any(|l| l.contains("No circle around the center is tangent there.")), "{:?}", s.log);+ assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count(), 1);+}++#[test]+fn tan_for_a_3p_circle_refuses_the_picked_circle_itself() {+ // #1512: tangent twice to the circle and through a point on it is only the circle itself.+ let mut s = drawing();+ s.script("CIRCLE 3P TAN 23.02,20.03 TAN 16.98,20.03 20,23\n").unwrap();+ assert!(s.prompt_text().contains("third point"), "{}", s.prompt_text());+ assert!(s.log.iter().any(|l| l.contains("Circle does not exist.")), "{:?}", s.log);+ assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count(), 1);+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (CIRCLE tangent snaps) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -69,7 +69,7 @@ ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours** | Extension | yes, with dashed extension path | yes: rest on a line or arc end, then the line runs on (a ray) and the arc round its circle; dotted path and "Extension: d < a°" (`snap/tracking.rs`); not from ellipse, spline or polyline-arc ends | | Insertion | text, mtext, blocks, attributes, attdefs | TEXT, MTEXT, INSERT; no ATTDEF or MLEADER | | Perpendicular | every curve, deferred | lines, arcs, circles, xlines; deferred for the first point (#168) |-| Tangent | circles, arcs, ellipses, splines, deferred | arcs and circles; deferred for the first point; **no ellipse or spline** |+| Tangent | circles, arcs, ellipses, splines, deferred | arcs and circles; deferred for the first point and for CIRCLE's radius and 3P points (#1512); **no ellipse or spline** | | Nearest | every curve | yes | | Apparent intersection | projected intersection | yes in 2D: where objects near the cursor would meet if extended; an object rested on sends its whole extension, so its crossing with another object snaps too (`snap.rs` `osnap_with`, `snap/tracking.rs`) | | Parallel | yes, with parallel path | yes: rest on a line, then a path parallel to it runs through the base point (`snap/tracking.rs`) |@@ -203,6 +203,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | CIRCLE takes tangent snaps on circles and arcs (#1512): for the radius or diameter, the circle around the center touches the picked curve on the side it was picked; for 3P points, the circle passes through the plain points and touches the picked curves (Tan, Tan, Tan from three tangent picks) | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |