Files
solidcraft-upstream/crates/engine/src/measure_centre_tests.rs
T
bherbruckandClaude Opus 5.5 1744fbb262 Measure: centre-to-centre for holes, diameters for circles and arcs, slot sizes (#1392) (#1419)
Measure now reads the picked item's exact shape (cylindrical faces, circular and arc edges,
sketch circles, arcs and lines, profiles, planes, points):

- A circle, hole or arc shows Ø, R and its length (circumference, or arc length and angle).
- A slot (a sketch profile, or a half-cylinder end of a slot in a solid) shows its width,
  centre-to-centre length and overall length.
- Two items where one is round measure from the centre or axis by default (axis to axis,
  axis to plane, edge or point), with the closest and farthest distances listed too; the
  panel switches between centre, min and max, and the canvas line follows.
- inspect.measure and MCP `measure` (new `items` argument) add diameter, radius,
  center_distance, min and max; existing fields are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:27:13 -04:00

219 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Measure on holes, circles, arcs and slots (#1392): centre-to-centre and axis-to-edge
//! distances with the closest and farthest listed too, diameters and radii, slot sizes. Every
//! expected value is worked out by hand from the part's dimensions.
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn close(v: &Value, want: f64, tol: f64) -> bool {
v.as_f64().is_some_and(|x| (x - want).abs() <= tol)
}
/// A 60 × 40 × 10 plate with a Ø6 hole at (15, 20) and a Ø8 hole at (45, 20).
fn plate(s: &mut Session) {
run(s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
run(s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"}));
run(s, "solid.cylinder", json!({"radius": 4, "height": 12, "base": [45, 20, -1], "operation": "cut"}));
}
fn body(s: &Session) -> String {
s.model.state().bodies.first().unwrap().name.clone()
}
/// The cylindrical face of radius `r`, as a pick.
fn hole(s: &Session, r: f64) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n)
.find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-6))
.unwrap_or_else(|| panic!("no hole of radius {r}"));
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
/// The planar face all of whose points satisfy `on`, as a pick.
fn flat(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n)
.find(|&f| {
let tris: Vec<_> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == f).filter_map(|(t, _)| m.tri(t)).collect();
!tris.is_empty() && tris.iter().all(|t| t.iter().all(|p| on(*p)))
})
.unwrap();
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
/// The mesh edge all of whose points satisfy `on`, as a pick.
fn edge(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge");
let e = &m.edges[i];
json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]})
}
fn measure(s: &mut Session, items: Vec<Value>) -> Value {
run(s, "inspect.measure", json!({ "items": items }))
}
#[test]
fn two_holes_measure_centre_to_centre_with_min_and_max() {
let mut s = Session::default();
plate(&mut s);
let (a, b) = (hole(&s, 3.0), hole(&s, 4.0));
let r = measure(&mut s, vec![a.clone(), b.clone()]);
assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}");
assert!(close(&r["min"], 30.0 - 3.0 - 4.0, 1e-9), "{r}");
assert!(close(&r["max"], (37.0f64).hypot(10.0), 1e-9), "{r}");
assert_eq!(r["measure"], "center");
// Backward compatible: the closest distance is still `distance_mm`, with its points.
assert!(close(&r["distance_mm"], 23.0, 1e-9) && r["from"].is_array() && r["to"].is_array(), "{r}");
// The centre line runs between the two axes, square to them.
let (f, t) = (&r["center_from"], &r["center_to"]);
assert!(close(&f[0], 15.0, 1e-9) && close(&f[1], 20.0, 1e-9) && close(&t[0], 45.0, 1e-9) && close(&t[1], 20.0, 1e-9), "{r}");
assert!(close(&f[2], t[2].as_f64().unwrap(), 1e-9), "{r}");
// Each hole: Ø, R and circumference.
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-9) && close(&r["items"][0]["radius"], 3.0, 1e-9), "{r}");
assert!(close(&r["items"][1]["circumference"], 8.0 * PI, 1e-9), "{r}");
// The two rims on the top face: the same centres, the rims' gap and span.
let rim = |c: f64, rr: f64| move |p: Vec3| (p.z - 10.0).abs() < 1e-6 && ((p - Vec3::new(c, 20.0, 10.0)).len() - rr).abs() < 1e-6;
let (ea, eb) = (edge(&s, rim(15.0, 3.0)), edge(&s, rim(45.0, 4.0)));
let r = measure(&mut s, vec![ea.clone(), eb]);
assert!(close(&r["center_distance"], 30.0, 1e-9) && close(&r["min"], 23.0, 1e-9) && close(&r["max"], 37.0, 1e-9), "{r}");
// One rim: Ø, R and circumference first-class.
let r = measure(&mut s, vec![ea]);
assert!(close(&r["diameter"], 6.0, 1e-9) && close(&r["radius"], 3.0, 1e-9) && close(&r["circumference"], TAU * 3.0, 1e-9), "{r}");
assert_eq!(r["items"][0]["round"], "circle", "{r}");
}
#[test]
fn a_hole_to_the_plate_edge_and_side_measures_from_its_axis() {
let mut s = Session::default();
plate(&mut s);
let a = hole(&s, 3.0);
// The top front edge (y = 0, z = 10).
let e = edge(&s, |p| p.y.abs() < 1e-9 && (p.z - 10.0).abs() < 1e-9);
let r = measure(&mut s, vec![a.clone(), e]);
assert!(close(&r["center_distance"], 20.0, 1e-9), "{r}");
assert!(close(&r["min"], 17.0, 1e-9), "{r}");
assert!(r["max"].as_f64().unwrap() > 20.0, "{r}");
// The front face (y = 0): axis to plane, the rim's nearest and farthest.
let f = flat(&s, |p| p.y.abs() < 1e-9);
let r = measure(&mut s, vec![f, a.clone()]);
assert!(close(&r["center_distance"], 20.0, 1e-9) && close(&r["min"], 17.0, 1e-9), "{r}");
// The face reaches past the hole: its far corners are farther than the rim's far side.
assert!(r["max"].as_f64().unwrap() > 23.0, "{r}");
// Picked plane first: the centre line still starts at the plane.
assert!(close(&r["center_from"][1], 0.0, 1e-9) && close(&r["center_to"][1], 20.0, 1e-9), "{r}");
// Two planes keep today's measure: no centre.
let top = flat(&s, |p| (p.z - 10.0).abs() < 1e-9);
let bottom = flat(&s, |p| p.z.abs() < 1e-9);
let r = measure(&mut s, vec![top, bottom]);
assert!(r.get("center_distance").is_none() && close(&r["distance_mm"], 10.0, 1e-9) && r["measure"] == "min", "{r}");
}
#[test]
fn sketch_circle_arc_and_slot() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let c = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}))["curves"][0].as_str().unwrap().to_string();
let a =
run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": [34, 0], "sweep": 90}))["curves"][0].as_str().unwrap().to_string();
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c})]);
assert!(close(&r["diameter"], 10.0, 1e-9) && close(&r["radius"], 5.0, 1e-9) && close(&r["circumference"], 10.0 * PI, 1e-9), "{r}");
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": a})]);
assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["diameter"], 8.0, 1e-9), "{r}");
assert!(close(&r["angle_deg"], 90.0, 1e-9) && close(&r["arc_length"], 2.0 * PI, 1e-9), "{r}");
assert_eq!(r["items"][0]["round"], "arc");
// Circle to arc: centre to centre.
let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c}), json!({"type": "sketch_curve", "id": a})]);
assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}");
run(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 40], "p1": [20, 40], "width": 6}));
let sid = s.active_sketch.unwrap();
run(&mut s, "sketch.finish", json!({}));
let st = s.model.state();
let ss = st.sketch(sid).unwrap();
let slot =
ss.profiles.iter().position(|p| p.region.outer.segs.len() >= 4 && (p.area - (20.0 * 6.0 + PI * 9.0)).abs() < 1e-6).expect("slot profile");
let circle = ss.profiles.iter().position(|p| (p.area - PI * 25.0).abs() < 1e-6).expect("circle profile");
let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": slot})]);
let sl = &r["slot"];
assert!(close(&sl["width"], 6.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 26.0, 1e-9), "{r}");
let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": circle})]);
assert!(close(&r["diameter"], 10.0, 1e-9), "{r}");
}
#[test]
fn a_slot_cut_in_a_plate_reports_its_sizes_from_an_end() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.slot.center_to_center", json!({"p0": [20, 20], "p1": [40, 20], "width": 8}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "cut", "profiles": "all"}));
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let end = (0..n)
.find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 4.0).abs() < 1e-6))
.expect("slot end");
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == end).and_then(|(t, _)| m.tri(t)).unwrap()[0];
let name = body(&s);
let r = measure(&mut s, vec![json!({"type": "face", "body": name, "index": end, "point": p})]);
let sl = &r["slot"];
assert!(close(&sl["width"], 8.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 28.0, 1e-9), "{r}");
// The end is a half cylinder: R 4, half a turn.
assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["angle_deg"], 180.0, 1.0), "{r}");
}
#[test]
fn a_fillet_edge_is_an_arc() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 30, 10]], "radius": 5}));
// The fillet's quarter circle on the top face: centre (35, 25, 20).
let e = edge(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p - Vec3::new(35.0, 25.0, 20.0)).len() - 5.0).abs() < 1e-6);
let r = measure(&mut s, vec![e]);
assert!(close(&r["radius"], 5.0, 1e-9) && close(&r["diameter"], 10.0, 1e-9), "{r}");
assert!(close(&r["angle_deg"], 90.0, 1e-6) && close(&r["arc_length"], 2.5 * PI, 1e-6), "{r}");
assert!(close(&r["center"][0], 35.0, 1e-9) && close(&r["center"][1], 25.0, 1e-9), "{r}");
}
/// A plate with holes through STEP: the holes come back as cylinders, and measure the same.
#[test]
fn step_round_trip_of_a_plate_with_holes() {
let dir = std::env::temp_dir().join(format!("solidcraft-measure-1392-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Plate.step");
let mut s = Session::default();
plate(&mut s);
run(&mut s, "file.export", json!({"path": path.to_string_lossy()}));
let mut t = Session::default();
run(&mut t, "doc.open", json!({"path": path.to_string_lossy()}));
let (a, b) = (hole(&t, 3.0), hole(&t, 4.0));
let r = measure(&mut t, vec![a, b]);
assert!(close(&r["center_distance"], 30.0, 1e-6), "{r}");
assert!(close(&r["min"], 23.0, 1e-6), "{r}");
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}