From 90ce5261b3c63b40d82cf532665783623cf0c05d Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:40:07 +0200 Subject: [PATCH] =?UTF-8?q?An=20angle=20dimension=20between=20two=20lines?= =?UTF-8?q?=20takes=20the=20reading=20nearest=20its=20value=20and=20report?= =?UTF-8?q?s=20degrees:=20without=20text=5Fat=20the=20angle=20was=20measur?= =?UTF-8?q?ed=20between=20the=20lines'=20directions,=20so=20a=20riser=20po?= =?UTF-8?q?inting=20up=20and=20an=20outlet=20pointing=20down-left=20(meeti?= =?UTF-8?q?ng=20at=2030=C2=B0,=20directions=20150=C2=B0=20apart)=20asked?= =?UTF-8?q?=20for=2030=C2=B0=20swung=20the=20outlet=20round=20by=20120?= =?UTF-8?q?=C2=B0,=20which=20the=20fillets=20between=20them=20could=20not?= =?UTF-8?q?=20follow,=20and=20the=20dimension=20was=20refused=20as=20over-?= =?UTF-8?q?constraining=20with=20a=20degree=20of=20freedom=20left.=20Two?= =?UTF-8?q?=20lines=20make=20two=20angles,=20=CE=B8=20and=20180=C2=B0=20?= =?UTF-8?q?=E2=88=92=20=CE=B8;=20the=20dimension=20now=20takes=20the=20one?= =?UTF-8?q?=20nearer=20the=20value=20(the=20acute=20one=20without=20a=20va?= =?UTF-8?q?lue),=20and=20text=5Fat=20still=20picks=20a=20sector.=20sketch.?= =?UTF-8?q?dimension=20reports=20measured=20in=20the=20dimension's=20unit?= =?UTF-8?q?=20(deg=20for=20angles,=20mm=20for=20lengths)=20with=20a=20unit?= =?UTF-8?q?=20field,=20instead=20of=20radians=20for=20angles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cmd/sketch.rs | 37 ++++++++--- .../src/cmd/sketch_constraints_tests.rs | 62 +++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index b054b667..865ea5b3 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -1,7 +1,7 @@ //! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions. use serde_json::{Value, json}; -use solvecraft_doc::{FeatureKind, PlaneRef}; +use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind}; use solvecraft_geom::{Plane, Vec2, Vec3}; use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding}; @@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: overall ends, width", connect_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( - "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)", + "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -1332,7 +1332,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result { }; let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })); let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. })); - let (k, cur): (ConstraintKind, f64) = match (e0, e1) { + // The other reading of an angle between two lines (see below). + let mut alt: Option<(ConstraintKind, f64)> = None; + let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) { ((None, Some(c)), None) if is_line(c) => { let (a, b) = line_pts(sk, c).unwrap_or_default(); match ty.as_str() { @@ -1409,7 +1411,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result { ang = -ang; std::mem::swap(&mut ka, &mut kb); } - (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang) + // Two lines make two angles, θ and 180° − θ, whichever way they were + // drawn: the acute one unless the value asks for the other. + let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang); + let supplement = + (ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang); + let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) }; + alt = Some(obtuse); + acute } } } @@ -1426,7 +1435,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result { c.driven = true; } before = None; - return Ok((String::new(), name, cur)); + return Ok((String::new(), name, measured(is_angle, cur))); } let (unit, default_expr) = if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) }; @@ -1437,20 +1446,30 @@ fn dimension(s: &mut Session, p: &Value) -> Result { if let Some(err) = errs.get(&pname) { return Err(bad(cmd, format!("value: {err}"))); } - let _ = vals; + // An angle between two lines dimensions whichever of its two readings is nearer the value. + if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok())) + && (want - ca).abs() < (want - cur).abs() + { + (k, cur) = (ka, ca); + } let name = k.name(); let id = sk.add_constraint(k, Some(pname.clone()))?; place_text(sk, &id, text_at); - Ok((pname, name, cur)) + Ok((pname, name, measured(is_angle, cur))) })?; if driven { - return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info})); + return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info})); } if reject_redundant(before, &info, cmd).is_err() { return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true")); } let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); - Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info})) + Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info})) +} + +/// A dimension's measured value as reported, in the unit its expression is written in. +fn measured(is_angle: bool, v: f64) -> (f64, &'static str) { + if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") } } /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 0c5edd7e..8623eb77 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -201,3 +201,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() { let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap(); assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}"); } + +/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined +/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart +/// while the lines meet at 30°. +fn spout() -> Session { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50})); + run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068})); + run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134})); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020})); + assert_eq!(d["sketch"]["dof"], 1, "{d}"); + s +} + +/// The angle (degrees, 0..=90) between two lines of the active sketch. +fn line_angle(s: &Session, a: &str, b: &str) -> f64 { + let sk = sketch(s); + let dir = |l: &str| { + let c = sk.curve_index(l).unwrap(); + let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap(); + q - p + }; + let (u, v) = (dir(a), dir(b)); + let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees(); + t.min(180.0 - t) +} + +#[test] +fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() { + // The riser and the outlet are not adjacent and meet at 30°; their directions are 150° + // apart. Asking for 30° used to swing the outlet round and was refused as over-constraining. + for ents in [["l1", "l3"], ["l3", "l1"]] { + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"})); + assert_eq!(d["sketch"]["dof"], 0, "{d}"); + assert_eq!(d["unit"], "deg", "{d}"); + assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}"); + assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); + } + // The other reading still works, and the adjacent top line measures 60° the same way. + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"})); + assert_eq!(d["sketch"]["dof"], 0, "{d}"); + assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6); + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"})); + assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}"); + // Without a value the acute angle is kept as it is. + let mut s = spout(); + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]})); + let e = d["expression"].as_str().unwrap(); + assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::().unwrap() - 30.3).abs() < 0.1, "{d}"); + // Lengths report millimetres. + let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true})); + assert_eq!(d["unit"], "mm", "{d}"); +}