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
@@ -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);@@ -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"])@@ -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"))@@ -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]@@ -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) |