From 15b5395c06a35fca0cbbf5f157604febe7fd6bbb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:30:05 +0200 Subject: [PATCH] Angular constraint between two lines holds the angle it draws, whichever way the lines run (part of #1134) (#1140) --- crates/constraints/src/residual.rs | 28 +++++++++++++++------ crates/engine/src/cmd/constraints_tests.rs | 29 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs index b1157c3c3..478d03aaf 100644 --- a/crates/constraints/src/residual.rs +++ b/crates/constraints/src/residual.rs @@ -40,6 +40,11 @@ pub(crate) enum Form { C1, /// Arc sweep (angular on an arc). ArcSweep, + /// Two lines measured along the rays from their intersection to each line's far end, the + /// angle the dimension draws; `rev` marks a line whose far end is its start point. + Rays { + rev: [bool; 2], + }, /// Fix of a derived point (residual to an anchor). Anchor(Vec2), /// Fix handled by locking parameters (no rows). @@ -92,6 +97,10 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 { if l > 1e-12 { d * (1.0 / l) } else { d } } +fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 { + if rev { dir_n(b, a) } else { dir_n(a, b) } +} + fn sgn(v: f64) -> f64 { if v < 0.0 { -1.0 } else { 1.0 } } @@ -284,10 +293,15 @@ impl Built { } K::Angular => { b.rows = 1; - if n == 2 && ln(0).is_some() && ln(1).is_some() { - b.form = Form::LL; - let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") }; - let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2)); + if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) { + // Start-to-end directions would measure the supplement when a line is drawn + // towards the vertex, so use the rays the dimension draws. + let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) { + Some((_, t, u)) => [t > 0.5, u > 0.5], + None => [false, false], + }; + b.form = Form::Rays { rev }; + let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1])); b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2))); } else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() { b.form = Form::ArcSweep; @@ -297,7 +311,7 @@ impl Built { b.target = target.to_radians(); // Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger // target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn. - let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU }; + let max = if matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU }; if !b.target.is_finite() || b.target.abs() > max { return Err("angle out of range".into()); } @@ -461,9 +475,9 @@ impl Built { (K::Angular, Form::ArcSweep) => { out.push(m.arc_sweep(x, r(0)?)? - self.target); } - (K::Angular, _) => { + (K::Angular, Form::Rays { rev }) => { let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2)); + let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1])); out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target)); } (K::Radius, _) => out.push(ci(0)?.1 - self.target), diff --git a/crates/engine/src/cmd/constraints_tests.rs b/crates/engine/src/cmd/constraints_tests.rs index 6bb547e2f..547e37543 100644 --- a/crates/engine/src/cmd/constraints_tests.rs +++ b/crates/engine/src/cmd/constraints_tests.rs @@ -231,3 +231,32 @@ fn delconstraint_rejects_referenced_dimension() { s.execute("delconstraint", &json!({ "ids": ids })).unwrap(); assert!(s.doc().unwrap().constraints.is_empty()); } + +/// The angle between the two rays from the vertex to the far end of each line: the angle +/// DCANGULAR draws. +fn drawn_angle(s: &Session, a: &str, b: &str) -> f64 { + let ((a0, a1), (b0, b1)) = (ends(s, a), ends(s, b)); + let (o, _, _) = cadcraft_geom::line_line_infinite(a0, a1, b0, b1).unwrap(); + let far = |p: Vec2, q: Vec2| if p.dist(o) > q.dist(o) { p } else { q }; + let (u, v) = (far(a0, a1) - o, far(b0, b1) - o); + u.cross(v).atan2(u.dot(v)).abs().to_degrees() +} + +#[test] +fn dcangular_constrains_the_drawn_angle_whichever_way_the_lines_run() { + // #1134: the second line drawn towards the shared vertex got the supplement of the value. + for b_pts in [[[2.0, 10.0], [0.0, 0.0]], [[0.0, 0.0], [2.0, 10.0]]] { + let mut s = Session::new(); + s.viewport_px = (1000.0, 1000.0); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let b = line(&mut s, b_pts[0], b_pts[1]); + s.cmdline("dcangular").unwrap(); + s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap(); + s.input(Input::Point(Vec2::new(0.6, 3.0))).unwrap(); + assert!(s.prompt_text().contains("78.69"), "{b_pts:?}: {}", s.prompt_text()); + s.cmdline("100").unwrap(); + assert!(s.running.is_none()); + let got = drawn_angle(&s, &a, &b); + assert!((got - 100.0).abs() < 1e-6, "{b_pts:?}: drawn angle {got}"); + } +}