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
@@ -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 }@@ -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))?;@@ -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, ¶ms).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, ¶ms).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();