diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 8927abb74d..c1afa3e3f6 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -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 { + 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 { let cmd = "inspect.measure"; @@ -509,9 +523,8 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { 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) diff --git a/crates/engine/src/measure_centre_tests.rs b/crates/engine/src/measure_centre_tests.rs index 3903512cbd..f52209186c 100644 --- a/crates/engine/src/measure_centre_tests.rs +++ b/crates/engine/src/measure_centre_tests.rs @@ -74,6 +74,64 @@ fn measure(s: &mut Session, items: Vec) -> 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(); diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs index 7d907cefd9..e4918d661a 100644 --- a/crates/ui-egui/src/measurement_ui_tests.rs +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -41,6 +41,22 @@ fn component_rects() -> Vec { ["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();