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:?}"), + } +}