From 45755697b64e75a87c002e6a368374031d4695b7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:13 +0200 Subject: [PATCH] Angle prompts read AUNITS, ANGBASE and ANGDIR (fixes #353) (#354) ROTATE's angle and the hatch pattern angle are rotations (AUNITS, clockwise when ANGDIR is 1). The dimension line (Rotated) and dimension text angles are directions from ANGBASE. CHAMFER's angle and the MLEADER angle constraints are amounts in AUNITS. The ROTATE and HATCH defaults are offered as they would be typed. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 6 +- crates/engine/src/cmd/hatch.rs | 7 +- crates/engine/src/cmd/modify.rs | 12 +++- crates/engine/tests/angle_prompts.rs | 102 +++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/angle_prompts.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 809d6e5..b359681 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -847,7 +847,7 @@ impl DimText { let Some(a) = self.asking else { return false }; match (a, i) { (DimAsk::Text, Input::Text(t)) => self.text = t.clone(), - (DimAsk::Angle, Input::Text(t)) => match crate::units::parse_angle(t) { + (DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) { Some(r) => self.angle = r, None => { s.echo("Requires a valid angle."); @@ -912,7 +912,7 @@ impl Interactive for LinearM { fn input(&mut self, s: &mut Session, i: Input) -> Result { if self.rotating { if let Input::Text(t) = &i { - let Some(r) = crate::units::parse_angle(t) else { + let Some(r) = s.angle_settings().direction(t) else { s.echo("Requires a valid angle."); return Ok(Step::Continue); }; @@ -1566,7 +1566,7 @@ impl MLeaderM { Some(MaxPoints) } }, - (FirstAngle | SecondAngle, Input::Text(t)) => match crate::units::parse_angle(&t).filter(|a| *a >= 0.0) { + (FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) { Some(a) => { self.angles[usize::from(o == SecondAngle)] = a; Some(Menu) diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index b016efb..8563254 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -348,7 +348,8 @@ impl HatchM { } } HatchAsk::Angle => { - if let Some(a) = crate::units::parse_angle(t).filter(|a| a.is_finite()) { + // Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1. + if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) { self.settings.angle = a; s.doc_mut()?.header.set_f64("HPANG", a); } @@ -380,7 +381,9 @@ impl Interactive for HatchM { } Some(HatchAsk::Angle) => { let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); - let d = crate::units::format_angle(self.settings.angle, au, ap); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let a = if s.angle_settings().clockwise { -self.settings.angle } else { self.settings.angle }; + let d = crate::units::format_angle(a, au, ap); return Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER).default(d); } None => {} diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 2fe21c8..ad7ec2d 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1545,9 +1545,11 @@ impl Interactive for SelectThen { // ROTATE and SCALE offer the angle/factor used last time. (Op::Rotate, _) => { let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle }; Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) .kw(&["Copy", "Reference"]) - .default(crate::units::format_angle(s.last_used.rotate_angle, au, ap)) + .default(crate::units::format_angle(a, au, ap)) .base_opt(base) } (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) @@ -1657,7 +1659,9 @@ impl Interactive for SelectThen { (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { let a = match inp { Input::Point(p) => self.pts[0].angle_to(p), - Input::Text(t) => crate::units::parse_angle(&t) + Input::Text(t) => s + .angle_settings() + .rotation(&t) .filter(|a| a.is_finite()) .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, _ => s.last_used.rotate_angle, @@ -2286,6 +2290,7 @@ impl FilletM { Err(invalid()) } }; + let au = s.angle_settings(); let d = s.doc_mut()?; self.ask = None; match ask { @@ -2310,7 +2315,8 @@ impl FilletM { } FilletAsk::Angle => { if !text.is_empty() { - let a = crate::units::parse_angle(&text) + let a = au + .amount(&text) .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; d.header.set_f64("CHAMFERD", a); diff --git a/crates/engine/tests/angle_prompts.rs b/crates/engine/tests/angle_prompts.rs new file mode 100644 index 0000000..bbe29a6 --- /dev/null +++ b/crates/engine/tests/angle_prompts.rs @@ -0,0 +1,102 @@ +//! Angle prompts read typed angles with the drawing's settings (#353): AUNITS for bare numbers, +//! rotations turn clockwise when ANGDIR is 1, directions are measured from ANGBASE. + +use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{DimKind, EntityKind}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +const QUARTER: &str = "1.5707963267948966"; +const EIGHTH: &str = "0.7853981633974483"; + +/// Radians, clockwise angles, 0 = north. +fn session() -> Session { + let mut s = Session::new(); + for (name, value) in [("AUNITS", json!(3)), ("ANGDIR", json!(1)), ("ANGBASE", json!(90))] { + s.execute("setvar", &json!({ "name": name, "value": value })).unwrap(); + } + s +} + +fn last(s: &Session) -> EntityKind { + s.doc().unwrap().model.last().unwrap().kind.clone() +} + +/// `a - b` turned into (-π, π]. +fn diff(a: f64, b: f64) -> f64 { + let d = (a - b).rem_euclid(2.0 * PI); + if d > PI { d - 2.0 * PI } else { d } +} + +#[test] +fn rotate_angle_is_a_rotation_in_aunits() { + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.script(&format!("ROTATE ALL\n\n0,0 {QUARTER}\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // A quarter turn clockwise (ANGDIR 1), not 1.57 degrees counterclockwise. + match last(&s) { + EntityKind::Line(l) => assert!(l.b.xy().near(Vec2::new(0.0, -10.0), 1e-9), "{:?}", l.b), + k => panic!("expected a line, got {k:?}"), + } +} + +#[test] +fn hatch_angle_is_a_rotation_in_aunits() { + let mut s = session(); + s.script(&format!("HATCH A {QUARTER}\n\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let a = s.doc().unwrap().header.f64("HPANG", 0.0); + assert!(diff(a, -FRAC_PI_2).abs() < 1e-9, "HPANG {a}"); +} + +#[test] +fn dimension_line_and_text_angles_are_directions() { + let mut s = session(); + // Rotated 0 points at ANGBASE (north); the text angle a quarter turn clockwise from north is east. + s.script(&format!("DIMLINEAR 0,0 10,0 R 0 A {QUARTER} 5,5\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + match last(&s) { + EntityKind::Dimension(d) => { + let DimKind::Linear { rotation } = d.kind else { panic!("expected a linear dimension, got {:?}", d.kind) }; + assert!(diff(rotation, FRAC_PI_2).abs() < 1e-9, "rotation {rotation}"); + assert!(diff(d.text_rotation, 0.0).abs() < 1e-9, "text rotation {}", d.text_rotation); + } + k => panic!("expected a dimension, got {k:?}"), + } +} + +#[test] +fn chamfer_and_mleader_angles_are_amounts_in_aunits() { + let mut s = session(); + for l in ["0,0 12,0", "10,-2 10,12"] { + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + } + // Length 2 on the first line at an eighth turn from it, whatever ANGBASE and ANGDIR say. + s.script(&format!("CHAMFER A 2 {EIGHTH} 5,0 10,5\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap().header.f64("CHAMFERD", 0.0); + assert!((d - FRAC_PI_4).abs() < 1e-9, "CHAMFERD {d}"); + let chamfer = s.doc().unwrap().model.iter().any(|e| match &e.kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + (a.near(Vec2::new(8.0, 0.0), 1e-6) && b.near(Vec2::new(10.0, 2.0), 1e-6)) + || (b.near(Vec2::new(8.0, 0.0), 1e-6) && a.near(Vec2::new(10.0, 2.0), 1e-6)) + } + _ => false, + }); + assert!(chamfer, "{:?}", s.doc().unwrap().model.iter().map(|e| &e.kind).collect::>()); + + // The first angle constraint snaps the leader to eighth-turn steps. + s.script(&format!("MLEADER O F {EIGHTH} X 0,0 10,8 note\n")).unwrap(); + match last(&s) { + EntityKind::MLeader(m) => { + let r = Vec2::new(10.0, 8.0).len(); + assert!(m.landing.xy().near(Vec2::new(r, r) / 2f64.sqrt(), 1e-6), "{:?}", m.landing); + } + k => panic!("expected a multileader, got {k:?}"), + } +}