collective

Browse / github.com/storytold/cadcraft / patch

STRETCH: arcs and polyline arc segments keep their height; spline control points move

STRETCH: arcs and polyline arc segments keep their height; spline control points move ## What was wrong I checked STRETCH against AutoCAD 2024 as a black box: 36 crossing-window cases run in `accoreconsole`, each object read back with `entget`. The cases cover lines, open and closed polylines, arcs, circles, text, full and partial ellipses, splines, points and block references, with picked base/second points, `_D` and Enter-as-displacement, at ordinary, survey (VN-2000) and sub-millimetre coordinates. These already matched: lines, straight polylines, circles (move when the centre is inside), text and block references (move when the insertion point is inside), full ellipses an

Status
open
Score
0.00
Adopters
0 other operators
Author
2ez4gcx (imported from pull request #2209, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+148 −4 in 2 files
Review
pending
Shared
2026-10-28
Id
f7904f66d7ae97248491aa0a

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/modify/stretch.rs crates/engine/src/cmd/modify2/tests.rs

diff --git a/crates/engine/src/cmd/modify/stretch.rs b/crates/engine/src/cmd/modify/stretch.rs--- a/crates/engine/src/cmd/modify/stretch.rs+++ b/crates/engine/src/cmd/modify/stretch.rs@@ -34,6 +34,18 @@ fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space,     } } +/// The bulge that keeps an arc's height (bulge × chord / 2) when its chord changes length; none+/// once the chord collapses to a point, which has no arc left to keep.+fn keep_height(bulge: f64, old_chord: f64, new_chord: f64) -> f64 {+    if new_chord.is_nan() || new_chord <= 1e-12 {+        0.0+    } else if old_chord.is_finite() {+        bulge * old_chord / new_chord+    } else {+        bulge+    }+}+ /// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH). fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {     let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));@@ -49,20 +61,43 @@ fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {             mv(&mut l.b);         }         EntityKind::LwPolyline(pl) => {+            let old: Vec<Vec2> = pl.vertices.iter().map(|v| v.p).collect();             for v in &mut pl.vertices {                 if inside(v.p) {                     v.p += d;                 }             }+            // An arc segment keeps its height (bulge × chord / 2) as its ends move, as in AutoCAD.+            let n = pl.vertices.len();+            let segs = if pl.closed { n } else { n.saturating_sub(1) };+            for i in 0..segs {+                let j = (i + 1) % n;+                let (Some(a0), Some(b0), Some(b1)) = (old.get(i), old.get(j), pl.vertices.get(j).map(|v| v.p)) else { continue };+                if let Some(v) = pl.vertices.get_mut(i)+                    && v.bulge != 0.0+                {+                    v.bulge = keep_height(v.bulge, a0.dist(*b0), v.p.dist(b1));+                }+            }         }         EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)),         EntityKind::Arc(a) => {-            // Arcs keep their shape: move when the centre is inside.-            if inside(a.center.xy()) {-                a.center.x += d.x;-                a.center.y += d.y;+            // AutoCAD moves the ends inside the area and keeps the arc's height (bulge × chord / 2);+            // the centre alone moves nothing.+            let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);+            let (s0, e0) = (g.start_point(), g.end_point());+            let (s1, e1) = (if inside(s0) { s0 + d } else { s0 }, if inside(e0) { e0 + d } else { e0 });+            if (s1 != s0 || e1 != e0)+                && let Some((r, _)) =+                    cadcraft_geom::bulge_to_arc(s1, e1, keep_height(cadcraft_geom::arc_to_bulge(g.sweep()), s0.dist(e0), s1.dist(e1)))+            {+                (a.center.x, a.center.y) = (r.center.x, r.center.y);+                (a.radius, a.start, a.end) = (r.radius, r.start, r.end);             }         }+        // Control points inside the area move (AutoCAD); a spline built from fit points moves+        // whole by its first grip, as before.+        EntityKind::Spline(sp) if sp.fit.is_empty() => sp.control.iter_mut().filter(|p| inside(**p)).for_each(|p| *p += d),         EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv),         EntityKind::Dimension(dm) => {             for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] {diff --git a/crates/engine/src/cmd/modify2/tests.rs b/crates/engine/src/cmd/modify2/tests.rs--- a/crates/engine/src/cmd/modify2/tests.rs+++ b/crates/engine/src/cmd/modify2/tests.rs@@ -769,3 +769,112 @@ fn offset_elliptical_arc_follows_picked_side() {     let outer = max_x(json!([20, 20]));     assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}"); }++/// STRETCH against what AutoCAD 2024 stores for the same crossing window and displacement (5, 3)+/// (black-box: synthetic objects in accoreconsole, read back with `entget`): the ends of an arc+/// that are inside move and the arc keeps its height (bulge × chord / 2), as does a polyline arc+/// segment; the centre alone moves nothing; a spline's control points inside move.+#[test]+fn stretch_arcs_bulges_and_splines_match_autocad() {+    enum Want {+        Arc([f64; 2], f64, f64, f64),+        Bulges(&'static [f64]),+        Control(&'static [[f64; 2]]),+    }+    // ((seed command, params), crossing window, what AutoCAD stores)+    let cases: Vec<((&str, Value), [[f64; 2]; 2], Want)> = vec![+        // SX-ARC-1END: one end of the arc inside.+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 0.0, "end": 90.0})),+            [[15.0, -5.0], [30.0, 5.0]],+            Want::Arc([3.17984478246544, -2.20611061402141], 22.43262716319083, 0.2342130724507349, 1.713026264953702),+        ),+        // SX2-ARC-ENDPT: the other end inside.+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 0.0, "end": 90.0})),+            [[-5.0, 15.0], [5.0, 25.0]],+            Want::Arc([1.476584509301813, 4.310815984327269], 19.01841360081773, 6.054532724492258, 1.384456456611818),+        ),+        // SX2-ARC-BIG-END: an arc over 180 degrees, its end inside.+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 300.0})),+            [[5.0, -25.0], [15.0, -12.0]],+            Want::Arc([1.341732093243006, -0.7463649967436295], 19.25631435504786, 0.5920576993601167, 5.500876277544912),+        ),+        // SX2-ARC-CEN: only the centre inside: unchanged.+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 0.0, "end": 90.0})),+            [[-3.0, -3.0], [16.0, 16.0]],+            Want::Arc([0.0, 0.0], 20.0, 0.0, std::f64::consts::FRAC_PI_2),+        ),+        // SX-ARC-ALL: the whole arc inside: moved.+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 0.0, "end": 90.0})),+            [[-5.0, -5.0], [30.0, 30.0]],+            Want::Arc([5.0, 3.0], 20.0, 0.0, std::f64::consts::FRAC_PI_2),+        ),+        // SX-PLA-V1: the vertex starting an arc segment.+        (+            (+                "pline",+                json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [60.0, 20.0], "bulge": 0.0}], "closed": false}),+            ),+            [[25.0, -5.0], [35.0, 5.0]],+            Want::Bulges(&[0.0, 0.6337751582042422, 0.0, 0.0]),+        ),+        // SX-PLA-V2: the vertex ending an arc segment.+        (+            (+                "pline",+                json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [60.0, 20.0], "bulge": 0.0}], "closed": false}),+            ),+            [[40.0, 15.0], [50.0, 25.0]],+            Want::Bulges(&[0.0, 0.4101116968279916, 0.0, 0.0]),+        ),+        // SX2-PLA-NEG: a negative bulge over 180 degrees.+        (+            (+                "pline",+                json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": -1.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [60.0, 20.0], "bulge": 0.0}], "closed": false}),+            ),+            [[40.0, 15.0], [50.0, 25.0]],+            Want::Bulges(&[0.0, -1.230335090483974, 0.0, 0.0]),+        ),+        // SX-SPL: two control points inside.+        (+            ("spline", json!({"control": [[0.0, 0.0], [10.0, 10.0], [20.0, 0.0], [30.0, 10.0]], "degree": 3})),+            [[15.0, -5.0], [35.0, 15.0]],+            Want::Control(&[[0.0, 0.0], [10.0, 10.0], [25.0, 3.0], [35.0, 13.0]]),+        ),+    ];+    let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);+    let same_angle = |a: f64, b: f64| {+        let d = (a - b).rem_euclid(std::f64::consts::TAU);+        d < 1e-9 || std::f64::consts::TAU - d < 1e-9+    };+    for ((seed, params), window, want) in cases {+        let mut s = Session::new();+        let hd = h(&s.execute(seed, &params).unwrap());+        s.execute("stretch", &json!({"window": window, "delta": [5.0, 3.0]})).unwrap();+        match (kind(&s, &hd), want) {+            (EntityKind::Arc(a), Want::Arc(c, r, start, end)) => assert!(+                same(a.center.x, c[0]) && same(a.center.y, c[1]) && same(a.radius, r) && same_angle(a.start, start) && same_angle(a.end, end),+                "{window:?}: {a:?}"+            ),+            (EntityKind::LwPolyline(p), Want::Bulges(want)) => {+                let got: Vec<f64> = p.vertices.iter().map(|v| v.bulge).collect();+                assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| same(*a, *b)), "{window:?}: {got:?} / {want:?}");+            }+            (EntityKind::Spline(sp), Want::Control(want)) => {+                let ok = sp.control.len() == want.len() && sp.control.iter().zip(want).all(|(p, w)| same(p.x, w[0]) && same(p.y, w[1]));+                assert!(ok, "{window:?}: {:?} / {want:?}", sp.control);+            }+            (k, _) => panic!("{window:?}: {k:?}"),+        }+    }+    // A stretch that brings an arc segment's ends together leaves no arc to keep: the bulge goes.+    let mut s = Session::new();+    let p = pline(&mut s, json!([{"p": [0, 0], "bulge": 0.5}, [10, 0], [20, 0]]), false);+    s.execute("stretch", &json!({"window": [[9, -1], [11, 1]], "delta": [-10, 0]})).unwrap();+    assert_eq!(poly(&kind(&s, &p)).vertices.iter().map(|v| v.bulge).collect::<Vec<_>>(), [0.0, 0.0, 0.0]);+}