collective

Browse / github.com/storytold/cadcraft / patch

MEASURE picks its starting end by the curve's parameter, as AutoCAD does

MEASURE picks its starting end by the curve's parameter, as AutoCAD does ## What was wrong MEASURE started from whichever end of an open curve was nearer the pick, measured in a straight line, and a closed curve always ran forwards from its first vertex. AutoCAD 2024 decides by the **curve parameter** of the pick instead: the segment index plus the fraction along that segment. I measured this black-box: synthetic objects in `accoreconsole`, `(command "_.MEASURE" (list ename point) …)`, every POINT read back with `entget`. Its rules: - **Open curve:** measured from its end once the pick is at half the parameter range or further. - An L-shaped polyline (0,0)→(10,

Status
open
Score
0.00
Adopters
0 other operators
Author
2ez4gcx (imported from pull request #2207, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+218 −6 in 3 files
Review
pending
Shared
2026-10-28
Id
0d6a20703ebfa248e7d4f76e

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/curves.rs crates/engine/src/cmd/draw2/divide.rs crates/engine/src/cmd/draw2/tests.rs

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@@ -479,6 +479,19 @@ impl Chain {         }         best.1     }+    /// The curve parameter of the point on the chain nearest `p`: the index of its segment plus+    /// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc).+    pub fn param_of(&self, p: Vec2) -> f64 {+        let mut best = (f64::INFINITY, 0.0);+        for (i, s) in self.segs.iter().enumerate() {+            let c = s.closest(p);+            let dd = c.dist(p);+            if dd < best.0 {+                best = (dd, i as f64 + seg_param(s, c));+            }+        }+        best.1+    }     /// Reversed copy.     pub fn reversed(&self) -> Chain {         Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }diff --git a/crates/engine/src/cmd/draw2/divide.rs b/crates/engine/src/cmd/draw2/divide.rs--- a/crates/engine/src/cmd/draw2/divide.rs+++ b/crates/engine/src/cmd/draw2/divide.rs@@ -71,12 +71,24 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {     }     let block = str_param(p, "block").map(str::to_string);     check_block(s, block.as_deref())?;-    if let Some(from) = point_param(p, "from")-        && !c.closed-        && let (Some(a), Some(b)) = (c.start(), c.end())-        && from.dist(b) < from.dist(a)-    {-        c = c.reversed();+    // AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index+    // plus the fraction along it), not by distance: an open curve from its end once the pick is+    // halfway or further, a closed one from its first vertex, backwards from halfway on. A+    // circle always runs counterclockwise from angle 0.+    if let Some(from) = point_param(p, "from") {+        let past_half = match curves::entity(s, h)?.kind {+            EntityKind::Circle(_) => false,+            // A spline by its own parameter: its tessellation drops zero-length pieces, so the+            // segment count is not the spline's parameter range.+            EntityKind::Spline(sp) => {+                let (lo, hi) = sp.domain();+                curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0+            }+            _ => c.param_of(from) >= c.segs.len() as f64 / 2.0,+        };+        if past_half {+            c = c.reversed();+        }     }     let pts = measure_points(&c, step);     let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs--- a/crates/engine/src/cmd/draw2/tests.rs+++ b/crates/engine/src/cmd/draw2/tests.rs@@ -302,6 +302,193 @@ fn divide_and_measure_json() {     assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err()); } +/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick+/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`,+/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not+/// by distance; a closed polyline runs from its first vertex, a circle from angle 0.+#[test]+fn divide_and_measure_match_autocad() {+    type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]);+    // ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points)+    let cases: Vec<Case> = vec![+        // DIVIDE-001: (list ename point).+        (+            ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})),+            "divide",+            5.0,+            [-2.090569265353071, 19.890437907365467],+            4,+            &[+                [-19.021130325903, 6.180339887499],+                [-17.975880925983, -8.767422935782],+                [-9.389431255718, 17.658951857179],+                [5.51274711634, 19.225233918766],+            ],+        ),+        // DIVIDE-002: (list ename point).+        (+            ("circle", json!({"center": [0.0, 0.0], "radius": 15.0})),+            "divide",+            5.0,+            [-4.63525491562421, 14.265847744427305],+            5,+            &[+                [-12.135254915624, -8.816778784387],+                [-12.135254915624, 8.816778784387],+                [4.635254915624, -14.265847744427],+                [4.635254915624, 14.265847744427],+                [15.0, 0.0],+            ],+        ),+        // MEASURE-016: (list ename point).+        (+            ("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})),+            "measure",+            7.688652713279647,+            [25.9, 9.1],+            5,+            &[+                [0.730313187001, 0.256596525162],+                [7.984250549601, 2.80527722013],+                [15.2381879122, 5.353957915097],+                [22.4921252748, 7.902638610065],+                [29.7460626374, 10.451319305032],+            ],+        ),+        // MEASURE-024: (list ename point).+        (+            (+                "pline",+                json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}),+            ),+            "measure",+            7.0,+            [90.0, 0.0],+            29,+            &[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]],+        ),+        // MEASURE-026: (list ename point).+        (+            (+                "pline",+                json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}),+            ),+            "measure",+            7.0,+            [10.0, 30.0],+            15,+            &[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]],+        ),+        // MEASURE-030: (list ename point).+        (+            (+                "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": [10.0, 35.0], "bulge": 0.0}], "closed": true}),+            ),+            "measure",+            7.5,+            [5.0, 17.5],+            17,+            &[+                [2.06040845923, 7.211429607306],+                [4.120816918461, 14.422859214612],+                [5.957390538273, 0.0],+                [41.854257779673, 5.445746712402],+                [45.137412221497, 19.497902817843],+                [45.215233654661, 12.07010340037],+            ],+        ),+        // DIVIDE-028: (list ename point).+        (+            (+                "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": [10.0, 35.0], "bulge": 0.0}], "closed": true}),+            ),+            "divide",+            5.0,+            [37.5, 10.0],+            5,+            &[+                [0.0, 0.0],+                [7.332712970983, 25.664495398441],+                [25.609294779385, 28.310302237406],+                [26.691478107655, 0.0],+                [45.58190593662, 14.465330916891],+            ],+        ),+        // MEASURE-041: (list ename point).+        (+            (+                "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": [10.0, 35.0], "bulge": 0.0}], "closed": true}),+            ),+            "measure",+            7.5,+            [45.54951565955276, 17.15901034980981],+            17,+            &[+                [1.636621047999, 5.728173667998],+                [3.69702950723, 12.939603275304],+                [5.75743796646, 20.15103288261],+                [42.799868254703, 6.663750217618],+                [44.060611317761, 20.402595149531],+                [45.491536690495, 13.587129550966],+            ],+        ),+        // MEASURE-042: (list ename point).+        (+            (+                "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": [10.0, 35.0], "bulge": 0.0}], "closed": true}),+            ),+            "measure",+            7.5,+            [44.0, 20.5],+            17,+            &[+                [2.06040845923, 7.211429607306],+                [4.120816918461, 14.422859214612],+                [5.957390538273, 0.0],+                [41.854257779673, 5.445746712402],+                [45.137412221497, 19.497902817843],+                [45.215233654661, 12.07010340037],+            ],+        ),+    ];+    for ((seed, params), what, value, pick, count, want) in cases {+        let mut s = Session::new();+        let r = s.execute(seed, &params).unwrap();+        let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string();+        // AutoLISP's (list ename point): the object and where it was picked.+        let params = if what == "divide" {+            json!({"handle": handle, "segments": value as u64, "from": pick})+        } else {+            json!({"handle": handle, "length": value, "from": pick})+        };+        s.execute(what, &params).unwrap();+        let pts: Vec<Vec2> =+            s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();+        assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}");+        for w in want {+            let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0);+            assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}");+        }+    }+}++/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a+/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and+/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length.+#[test]+fn measure_spline_by_its_own_parameter() {+    let mut s = Session::new();+    let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap());+    s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap();+    let pts: Vec<Vec2> =+        s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();+    assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}");+}+ #[test] fn divide_and_measure_command_line() {     let mut s = Session::new();