collective

Browse / github.com/storytold/cadcraft / patch

ELLIPSE from axis endpoints matches what AutoCAD stores

ELLIPSE from axis endpoints matches what AutoCAD stores ## What was wrong I checked typed ELLIPSE input against AutoCAD 2024 as a black box: synthetic input run in `accoreconsole`, results read back with `entget`. There were 11 measured cases. On `main` CADCraft matched **1 of 8** axis/arc cases and **0 of 3** cases where the other axis is given as a point. With this PR it matches **11 of 11**. ARC and CIRCLE 3P cases measured the same way, used as controls, already matched. Three rules differ: 1. **The major axis points at the first axis endpoint.** CADCraft pointed it at the second. A full ellipse looks the same either way, but its stored majo

Status
open
Score
0.00
Adopters
0 other operators
Author
2ez4gcx (imported from pull request #2197, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+164 −20 in 5 files
Review
pending
Shared
2026-10-28
Id
deb16b1f5167038a19f9ae97

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/draw/ellipse.rs crates/engine/src/cmd/draw2.rs crates/engine/src/cmd/draw2/ellipse.rs crates/engine/src/cmd/draw2/tests.rs docs/geometry-parity.md

diff --git a/crates/engine/src/cmd/draw/ellipse.rs b/crates/engine/src/cmd/draw/ellipse.rs--- a/crates/engine/src/cmd/draw/ellipse.rs+++ b/crates/engine/src/cmd/draw/ellipse.rs@@ -31,19 +31,24 @@ pub(super) struct EllipseM {     /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.     param: bool,     start: Option<f64>,+    /// The `Included angle` option: the next value is the arc's angle from its start angle.+    included: bool,     /// The Isocircle option (isometric snap style) in progress.     iso: Option<crate::cmd::settings::iso::IsoCircle>, }  impl EllipseM {     fn axis(&self) -> Option<(Vec2, Vec2)> {-        // (center, major vector)+        // (center, major vector): towards the first axis endpoint, as AutoCAD stores it.         match (self.center_mode, self.pts.as_slice()) {             (true, [c, e, ..]) => Some((*c, *e - *c)),-            (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),+            (false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))),             _ => None,         }     }+    /// The other half axis reaches as far as `other` is from the center (AutoCAD measures the+    /// picked point's distance from the center, not from the first axis). Longer than the+    /// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise.     fn build(&self, other: Vec2) -> Option<Ellipse> {         let (c, m) = self.axis()?;         let ml = m.len();@@ -51,7 +56,7 @@ impl EllipseM {             return None;         }         let u = m / ml;-        let minor = ((other - c).dot(u.perp())).abs();+        let minor = (other - c).len();         let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };         (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))     }@@ -85,13 +90,16 @@ impl Interactive for EllipseM {     }     fn prompt(&self, s: &Session) -> Prompt {         if let Some(e) = &self.ellipse {-            let msg = match (self.start.is_none(), self.param) {-                (true, false) => "Specify start angle",-                (false, false) => "Specify end angle",-                (true, true) => "Specify start parameter",-                (false, true) => "Specify end parameter",+            if self.included {+                return Prompt::new("Specify included angle for arc", Accept::POINT_OR_NUMBER).default("180").base(e.center);+            }+            let (msg, kw): (&str, &[&str]) = match (self.start.is_none(), self.param) {+                (true, false) => ("Specify start angle", &["Parameter"]),+                (false, false) => ("Specify end angle", &["Parameter", "Included angle"]),+                (true, true) => ("Specify start parameter", &["Angle"]),+                (false, true) => ("Specify end parameter", &["Angle"]),             };-            return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center);+            return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(kw).base(e.center);         }         if let Some(iso) = &self.iso {             return iso.prompt();@@ -109,11 +117,32 @@ impl Interactive for EllipseM {     fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {         if let Some(e) = self.ellipse {             let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };+            if self.included {+                let incl = match i {+                    Input::Point(p) => (p - e.center).angle(),+                    Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?,+                    Input::Enter => std::f64::consts::PI,+                    _ => return Ok(Step::Continue),+                };+                // A true angle added to the start angle (AutoCAD: start 214.659°, included+                // 232.716° ends the arc at 87.375° from the major axis).+                let st = self.start.unwrap_or(0.0);+                let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl));+                s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {+                    center: v3(e.center),+                    major: v3(e.major),+                    ratio: e.ratio,+                    start: st,+                    end,+                }))?;+                return Ok(Step::Done);+            }             let ang = match i {                 Input::Keyword(k) => {                     match k.as_str() {                         "Parameter" => self.param = true,                         "Angle" => self.param = false,+                        "Included angle" if self.start.is_some() && !self.param => self.included = true,                         _ => {}                     }                     return Ok(Step::Continue);diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs--- a/crates/engine/src/cmd/draw2.rs+++ b/crates/engine/src/cmd/draw2.rs@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {             .interactive(|_| Ok(Box::new(TanCircleM::new(false)))),         CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis)             .menu(&["Draw", "Ellipse", "Axis, End"])-            .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)")+            .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)")             .interactive(|_| Ok(super::draw::ellipse_machine(false, false))),         CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc)             .menu(&["Draw", "Ellipse", "Arc"])diff --git a/crates/engine/src/cmd/draw2/ellipse.rs b/crates/engine/src/cmd/draw2/ellipse.rs--- a/crates/engine/src/cmd/draw2/ellipse.rs+++ b/crates/engine/src/cmd/draw2/ellipse.rs@@ -4,7 +4,8 @@ use super::*;  fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {     let c = p1.mid(p2);-    let m = p2 - c;+    // The major axis points at the first endpoint, as AutoCAD stores it.+    let m = p1 - c;     let ml = m.len();     let half = half.abs();     if ml < 1e-12 || half < 1e-12 || !half.is_finite() {@@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {     let p1 = point_req(cmd, p, "p1")?;     let p2 = point_req(cmd, p, "p2")?;     let half = match point_param(p, "p3") {-        Some(p3) => {-            let ax = Line::new(p1, p2);-            ax.project(p3).dist(p3)-        }+        // As at the prompt: the point's distance from the center, not from the first axis.+        Some(p3) => p3.dist(p1.mid(p2)),         None => f64_req(cmd, p, "distance")?,     };     ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))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@@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() {     } } +/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic+/// input in accoreconsole, read back with `entget`). The major axis points at the first axis+/// endpoint; a point at the distance prompt gives its distance from the center; an other axis+/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the+/// Included angle is added to the start angle.+#[test]+fn ellipse_axis_endpoints_match_autocad() {+    type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>);+    // (input after ELLIPSE, center, major axis, ratio, arc start/end parameters)+    let cases: [Case; 10] = [+        // The other axis (27.02) is longer than the first half axis (26.42).+        (+            "16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268",+            [5.712514913615239, 3.985817249587547],+            [-24.6850622596004, 10.99222700689644],+            0.9776866627982613,+            None,+        ),+        (+            "33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949",+            [17.27018583472502, -18.93707805189049],+            [16.1799716942092, -9.060587202842658],+            0.8052876303433125,+            None,+        ),+        (+            "0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256",+            [0.5074363325764704, 0.2533737894427027],+            [0.00868846668462886, 0.02043019027014691],+            0.1200276615332929,+            None,+        ),+        // Arcs: the angles are taken from the major axis, so a reversed axis draws the arc+        // on the opposite side of the center.+        (+            "_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204",+            [612359.8301610444, 2398774.152511537],+            [-5.160876934416592, 22.44292051764205],+            0.1535548572193783,+            Some([4.683678586016272, 2.014340486820176]),+        ),+        (+            "_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654",+            [-8.068629780716847, 9.577702534161936],+            [-24.42926629106581, 16.24859713260318],+            0.317871961092373,+            Some([4.107654908771007, 5.952182383669234]),+        ),+        (+            "_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933",+            [15.89513193856356, -16.13523652037361],+            [2.53204749973717, -14.23341561039267],+            0.9785367111972643,+            Some([3.756694588158375, 1.5259701966906878]),+        ),+        (+            "_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075",+            [1.882264537326193, -7.920292708078534],+            [19.15838502833398, 33.68853210335513],+            0.8017236937148501,+            Some([2.267136066480607, 4.657903136501946]),+        ),+        // The other axis given by a point.+        (+            "-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656",+            [-15.97844303459893, 15.52013568457562],+            [-0.3990375230131046, 39.92686201604709],+            0.7411292927991227,+            None,+        ),+        (+            "8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266",+            [2.843616795535609, 19.31530728820197],+            [4.602217644463162, 7.450176331143755],+            0.7105482899194384,+            None,+        ),+        (+            "_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446",+            [15.41818958649589, -18.74113376930122],+            [27.87548765369622, 24.07822440243505],+            0.3302549104883693,+            None,+        ),+    ];+    use std::f64::consts::{PI, TAU};+    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(TAU);+        d < 1e-9 || TAU - d < 1e-9+    };+    for (input, center, major, ratio, arc) in cases {+        let mut s = Session::new();+        s.cmdline(&format!("ellipse {input}")).unwrap();+        assert!(s.running.is_none(), "{input}: {}", s.prompt_text());+        let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") };+        assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center);+        assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major);+        assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio);+        let [start, end] = arc.unwrap_or([0.0, TAU]);+        assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end);+    }+    // Enter takes the default included angle, 180°: half the ellipse.+    let mut s = Session::new();+    s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap();+    assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text());+    s.input(Input::Enter).unwrap();+    assert!(s.running.is_none(), "{}", s.prompt_text());+    let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") };+    assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end);+}+ // ---------------- DIVIDE / MEASURE ----------------  #[test]diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md--- a/docs/geometry-parity.md+++ b/docs/geometry-parity.md@@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ELLIPSE axis endpoints, other-axis point and Included angle as AutoCAD stores them) · **Target:** Autodesk AutoCAD 2027  CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document@@ -18,10 +18,13 @@ drawings behave differently from mid-sized ones. 3D geometry doesn't exist. Rema  ## How this was measured -Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. No-numeric comparison against AutoCAD has been run yet: the plan is a set of synthetic drawings whose-exact answers are known analytically, measured in both apps black-box (LIST, ID, AREA results)-and in CADCraft headless. Until then the percentages are judgements.+Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. The+one numeric comparison against AutoCAD so far is ELLIPSE from axis endpoints (2026-10-26): 11+typed inputs measured black-box in AutoCAD 2024 (accoreconsole, read back with `entget`) and+pinned by `ellipse_axis_endpoints_match_autocad`. Everything else is still to be measured: the+plan is a set of synthetic drawings whose exact answers are known analytically, measured in both+apps black-box (LIST, ID, AREA results) and in CADCraft headless. Until then the percentages are+judgements.  Weights (how much AutoCAD users lean on each area in 2D drafting): numeric robustness 15%, curve representation 10%, intersections 15%, offsets 15%, trim/extend/fillet/chamfer/break/join@@ -157,6 +160,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | ELLIPSE from axis endpoints matches AutoCAD's stored geometry: the major axis points at the first endpoint (arcs drew on the opposite side of the center), a point at the other-axis prompt gives its distance from the center, and the end-angle prompt offers Included angle | | 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) |