Measure: report obtuse angles at a shared corner (#4625) (#4634)

* claim: #4625

* fix: measure obtuse corners independently of edge direction (#4625)
This commit is contained in:
Reefwalker
2026-10-23 13:45:04 +00:00
committed by GitHub
parent f422bb1263
commit eec6da060f
3 changed files with 90 additions and 3 deletions
+16 -3
View File
@@ -410,6 +410,20 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
}
}
/// Two straight edges sharing exactly one end define rays away from that corner. Their
/// stored directions are arbitrary, so use the rays to preserve obtuse (and straight) angles.
/// Separate edges and coincident copies have no unique corner and keep the undirected angle.
fn corner_cos(a: Shape, b: Shape) -> Option<f64> {
let (Shape::Line { a, b }, Shape::Line { a: c, b: d }) = (a, b) else { return None };
let tol = a.dist(b).min(c.dist(d)).max(1.0) * 1e-6;
let mut corners = [(a, b, c, d), (a, b, d, c), (b, a, c, d), (b, a, d, c)].into_iter().filter(|(p, _, q, _)| p.dist(*q) <= tol);
let (p, end_a, q, end_b) = corners.next()?;
if corners.next().is_some() {
return None;
}
Some((end_a - p).normalized()?.dot((end_b - q).normalized()?))
}
/// `inspect.measure` with `items` (one or two selections).
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
@@ -509,9 +523,8 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
if let (Some(da), Some(db)) = (a.dir, b.dir) {
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
let cos = da.dot(db).clamp(-1.0, 1.0);
let cos = if both_faces { cos } else { cos.abs() };
out["angle_deg"] = json!(cos.acos().to_degrees());
let cos = if both_faces { da.dot(db) } else { corner_cos(a.shape, b.shape).unwrap_or_else(|| da.dot(db).abs()) };
out["angle_deg"] = json!(cos.clamp(-1.0, 1.0).acos().to_degrees());
}
}
Ok(out)
+58
View File
@@ -74,6 +74,64 @@ fn measure(s: &mut Session, items: Vec<Value>) -> Value {
run(s, "inspect.measure", json!({ "items": items }))
}
#[test]
fn connected_lines_measure_the_corner_regardless_of_endpoint_or_selection_order() {
// cos(angle) = -3/5: the corner is 126.869898°, not its 53.130102° supplement.
for (end, want) in
[([-24.0, 32.0], (-0.6f64).acos().to_degrees()), ([24.0, 32.0], 0.6f64.acos().to_degrees()), ([0.0, 40.0], 90.0), ([-40.0, 0.0], 180.0)]
{
for reverse_a in [false, true] {
for reverse_b in [false, true] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let mut a = [[0.0, 0.0], [40.0, 0.0]];
let mut b = [[0.0, 0.0], end];
if reverse_a {
a.reverse();
}
if reverse_b {
b.reverse();
}
run(&mut s, "sketch.line", json!({"points": a}));
run(&mut s, "sketch.line", json!({"points": b}));
let (a, b) = (json!({"type": "sketch_curve", "id": "l1"}), json!({"type": "sketch_curve", "id": "l2"}));
for items in [vec![a.clone(), b.clone()], vec![b, a]] {
let r = measure(&mut s, items);
assert!(close(&r["angle_deg"], want, 1e-8), "{reverse_a} {reverse_b}: {r}");
}
}
}
}
}
#[test]
fn solid_edges_measure_the_obtuse_corner() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [0, 0], [-24, 32]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
let a = edge(&s, |p| p.z.abs() < 1e-8 && p.y.abs() < 1e-8);
let b = edge(&s, |p| p.z.abs() < 1e-8 && (4.0 * p.x + 3.0 * p.y).abs() < 1e-8);
for items in [vec![a.clone(), b.clone()], vec![b, a]] {
let r = measure(&mut s, items);
assert!(close(&r["angle_deg"], (-0.6f64).acos().to_degrees(), 1e-8), "{r}");
}
}
#[test]
fn separate_and_duplicate_lines_keep_the_undirected_angle() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[0, 10], [-24, 42]]}));
let (a, b) = (json!({"type": "sketch_curve", "id": "l1"}), json!({"type": "sketch_curve", "id": "l2"}));
let r = measure(&mut s, vec![a.clone(), b]);
assert!(close(&r["angle_deg"], 0.6f64.acos().to_degrees(), 1e-8), "{r}");
let r = measure(&mut s, vec![a.clone(), a]);
assert!(close(&r["angle_deg"], 0.0, 1e-8), "{r}");
}
#[test]
fn two_holes_measure_centre_to_centre_with_min_and_max() {
let mut s = Session::default();
@@ -41,6 +41,22 @@ fn component_rects() -> Vec<egui::Rect> {
["measure:label", "measure:label:x", "measure:label:y", "measure:label:z"].into_iter().map(|key| crate::scenario::rect_of(key).unwrap()).collect()
}
#[test]
fn measure_dialog_shows_the_obtuse_corner_of_selected_lines() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.app.run("sketch.line", json!({"points": [[40, 0], [0, 0], [-24, 32]]})).unwrap();
h.app.session.selection = ["l1", "l2"].into_iter().map(|id| Sel::SketchCurve { id: id.into() }).collect();
h.app.start("inspect.measure");
for layout in ["floating", "compact"] {
h.call("ui.set", json!({"layout": layout}));
let texts = painted_text(&mut h);
assert!(texts.contains(&"126.870°".into()), "{layout}: {texts:?}");
assert!(!texts.contains(&"53.130°".into()), "the supplementary angle must not replace the corner: {texts:?}");
}
}
#[test]
fn measurement_axis_distances_follow_the_upstream_centre_min_and_max_choices() {
let mut h = Harness::new();