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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 23:32:13 +02:00 committed by GitHub
parent 937dca6590
commit 45755697b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 119 additions and 8 deletions

View File

@ -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<Step> {
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)

View File

@ -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 => {}

View File

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

View File

@ -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::<Vec<_>>());
// 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:?}"),
}
}