collective

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

diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs--- a/crates/engine/src/cmd/curves.rs+++ b/crates/engine/src/cmd/curves.rs@@ -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));diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs--- a/crates/engine/src/cmd/draw.rs+++ b/crates/engine/src/cmd/draw.rs@@ -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);diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs--- a/crates/engine/src/prompt.rs+++ b/crates/engine/src/prompt.rs@@ -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.diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs--- a/crates/engine/src/snap.rs+++ b/crates/engine/src/snap.rs@@ -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);diff --git a/crates/engine/tests/it/snap_curves.rs b/crates/engine/tests/it/snap_curves.rs--- a/crates/engine/tests/it/snap_curves.rs+++ b/crates/engine/tests/it/snap_curves.rs@@ -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");diff --git a/crates/engine/tests/it/snap_overrides.rs b/crates/engine/tests/it/snap_overrides.rs--- a/crates/engine/tests/it/snap_overrides.rs+++ b/crates/engine/tests/it/snap_overrides.rs@@ -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);+}diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -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) |