Files
OneCAD/crates/engine/src/cmd/clearance_tests.rs
T

254 lines
14 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.
use serde_json::{Value, json};
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 cube(s: &mut Session, name: &str, size: f64, corner: [f64; 3]) {
run(s, "solid.box", json!({"length": size, "width": size, "height": size, "corner": corner, "operation": "new", "body_name": name}));
}
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(f64::NAN)
}
/// The pair `{a, b}` of a list entry, sorted.
fn pair(v: &Value) -> [String; 2] {
let mut p = [v["a"].as_str().unwrap_or("").to_string(), v["b"].as_str().unwrap_or("").to_string()];
p.sort();
p
}
/// Two 10 mm cubes sharing a 1 mm corner cube overlap by 1 mm³; a third, far off, is pruned.
#[test]
fn an_overlap_of_one_cubic_millimetre_is_reported() {
let mut s = Session::default();
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
cube(&mut s, "B", 10.0, [9.0, 9.0, 9.0]);
cube(&mut s, "Far", 10.0, [100.0, 0.0, 0.0]);
let r = run(&mut s, "inspect.clearance", json!({}));
let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default();
assert_eq!(overlaps.len(), 1, "{r}");
assert_eq!(pair(&overlaps[0]), ["A", "B"]);
assert!((f(&overlaps[0]["volume_mm3"]) - 1.0).abs() < 1e-6, "{r}");
// Three faces of 1 x 1 from each cube: 6 mm².
assert!((f(&overlaps[0]["area_mm2"]) - 6.0).abs() < 1e-6, "{r}");
assert!((f(&overlaps[0]["center"][0]) - 9.5).abs() < 1e-6, "{r}");
assert_eq!(r["pairs_checked"], 1, "{r}");
assert_eq!(r["pairs_pruned"], 2, "{r}");
assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}");
// An overlap under min_volume is contact, not an overlap: with a clearance it is near, at 0.
let r = run(&mut s, "inspect.clearance", json!({"min_volume": 2, "clearance": 0.1}));
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(f(&r["near"][0]["distance_mm"]), 0.0, "{r}");
assert!((f(&r["near"][0]["overlap_mm3"]) - 1.0).abs() < 1e-6, "{r}");
}
/// Two cubes 0.5 mm apart are near with a clearance of 1 mm; 2 mm apart they are not.
#[test]
fn pairs_closer_than_the_clearance_are_near() {
let mut s = Session::default();
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
cube(&mut s, "B", 10.0, [10.5, 2.0, 3.0]);
cube(&mut s, "C", 10.0, [-12.0, 0.0, 0.0]);
let r = run(&mut s, "inspect.clearance", json!({"clearance": 1}));
let near = r["near"].as_array().cloned().unwrap_or_default();
assert_eq!(near.len(), 1, "{r}");
assert_eq!(pair(&near[0]), ["A", "B"]);
assert!((f(&near[0]["distance_mm"]) - 0.5).abs() < 1e-9, "{r}");
assert!(near[0].get("exact").is_none(), "measured on the exact faces: {r}");
// The points lie on the facing faces x = 10 and x = 10.5.
let xs = [f(&near[0]["from"][0]), f(&near[0]["to"][0])];
assert!(xs.iter().any(|x| (x - 10.0).abs() < 1e-9) && xs.iter().any(|x| (x - 10.5).abs() < 1e-9), "{r}");
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
// With no clearance nothing is near; with 2.5 mm C (2 mm from A) is near too.
let r = run(&mut s, "inspect.clearance", json!({}));
assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}");
let r = run(&mut s, "inspect.clearance", json!({"clearance": 2.5}));
let near = r["near"].as_array().cloned().unwrap_or_default();
assert_eq!(near.len(), 2, "{r}");
assert_eq!(pair(&near[1]), ["A", "C"]);
assert!((f(&near[1]["distance_mm"]) - 2.0).abs() < 1e-9, "{r}");
}
/// Distances where the nearest points are on edges, and on curved faces whose meshes are far
/// coarser than the answer: cube edge to cube edge, a cylinder's side to a cube's face, and a
/// large sphere (mesh chord about 1 mm) to a cube.
#[test]
fn distances_are_exact_on_edges_and_curved_faces() {
let mut s = Session::default();
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
// Its vertical edge at (12, 13) faces A's at (10, 10): √(2² + 3²).
cube(&mut s, "B", 10.0, [12.0, 13.0, 0.0]);
run(&mut s, "solid.cylinder", json!({"base": [-30, 5, 0], "radius": 5, "height": 10, "operation": "new", "body_name": "Pin"}));
run(&mut s, "solid.sphere", json!({"radius": 500, "center": [5, 5, 510.7], "operation": "new", "body_name": "Ball"}));
let r = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"], "bodies": ["B", "Pin", "Ball"]}));
let near = r["near"].as_array().cloned().unwrap_or_default();
let d = |other: &str| near.iter().find(|n| pair(n).contains(&other.to_string())).map(|n| (f(&n["distance_mm"]), n.get("exact").is_none()));
assert_eq!(near.len(), 3, "{r}");
let (edge, exact) = d("B").unwrap_or((f64::NAN, false));
assert!((edge - 13f64.sqrt()).abs() < 1e-6 && exact, "{r}");
// The pin's side (x = -25) is 25 from A's face x = 0.
let (side, exact) = d("Pin").unwrap_or((f64::NAN, false));
assert!((side - 25.0).abs() < 1e-6 && exact, "{r}");
// The ball's lowest point, (5, 5, 10.7), is 0.7 above A's top.
let (ball, exact) = d("Ball").unwrap_or((f64::NAN, false));
assert!((ball - 0.7).abs() < 1e-6 && exact, "{r}");
// `against` leaves out the pairs among `bodies`, which are by default every other body.
assert!(near.iter().all(|n| pair(n).contains(&"A".to_string())), "{r}");
let all = run(&mut s, "inspect.clearance", json!({"clearance": 50, "against": ["A"]}));
assert_eq!(all["near"].as_array().map(Vec::len), Some(3), "{all}");
}
/// A pin in a hole 0.2 mm wider all round, in a plate whose mesh is coarser than that gap: the
/// plate's flat faces reach over the hole on their surfaces but not on the faces, so the
/// distance is the gap to the hole's wall.
#[test]
fn a_pin_in_a_hole_is_as_far_as_the_holes_wall() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "operation": "new", "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"radius": 3.2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]}));
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [10, 10, -5], "operation": "new", "body_name": "Pin"}));
let r = run(&mut s, "inspect.clearance", json!({"clearance": 1}));
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(0), "{r}");
assert!((f(&r["near"][0]["distance_mm"]) - 0.2).abs() < 1e-6, "{r}");
let r = run(&mut s, "inspect.clearance", json!({"clearance": 0.15}));
assert_eq!(r["near"].as_array().map(Vec::len), Some(0), "{r}");
}
/// Three spheres' nearest neighbours against their distances worked out by hand.
#[test]
fn nearest_distances_of_three_spheres() {
let mut s = Session::default();
for (name, r, c) in [("S1", 5.0, [0.0, 0.0, 0.0]), ("S2", 3.0, [12.0, 0.0, 0.0]), ("S3", 2.0, [3.0, 20.0, 4.0])] {
run(&mut s, "solid.sphere", json!({"radius": r, "center": c, "operation": "new", "body_name": name}));
}
let r = run(&mut s, "inspect.clearance", json!({"nearest": 2}));
let d12 = 12.0 - 8.0;
let d13 = (9.0f64 + 400.0 + 16.0).sqrt() - 7.0;
let d23 = (81.0f64 + 400.0 + 16.0).sqrt() - 5.0;
let want = [("S1", [("S2", d12), ("S3", d13)]), ("S2", [("S1", d12), ("S3", d23)]), ("S3", [("S1", d13), ("S2", d23)])];
for (body, list) in want {
let got = r["nearest"][body].as_array().cloned().unwrap_or_default();
assert_eq!(got.len(), 2, "{body}: {r}");
for (g, (other, d)) in got.iter().zip(list) {
assert_eq!(g["body"], other, "{body}: {r}");
assert!((f(&g["distance_mm"]) - d).abs() < 1e-6, "{body} to {other}: {} against {d}", g["distance_mm"]);
}
}
// One neighbour each.
let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"]}));
assert_eq!(r["nearest"]["S3"].as_array().map(Vec::len), Some(0), "only S3 is in `bodies`, so it has no partner: {r}");
let r = run(&mut s, "inspect.clearance", json!({"nearest": 1, "bodies": ["S3"], "against": ["S1", "S2"]}));
assert_eq!(r["nearest"]["S3"][0]["body"], "S1", "{r}");
}
/// `skip_within` leaves out the pairs inside one component (two bodies made to touch, as the
/// parts of a bought component do) and keeps those between components.
#[test]
fn skip_within_leaves_out_a_components_own_pairs() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Bought"}));
cube(&mut s, "Housing", 10.0, [0.0, 0.0, 0.0]);
cube(&mut s, "Insert", 4.0, [8.0, 3.0, 3.0]);
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Bracket"}));
cube(&mut s, "Plate", 10.0, [-9.5, 0.0, 0.0]);
let all = run(&mut s, "inspect.clearance", json!({}));
let pairs: Vec<[String; 2]> = all["overlaps"].as_array().into_iter().flatten().map(pair).collect();
assert!(pairs.contains(&["Housing".into(), "Insert".into()]) && pairs.contains(&["Housing".into(), "Plate".into()]), "{all}");
for mode in ["component", "occurrence"] {
let r = run(&mut s, "inspect.clearance", json!({"skip_within": mode}));
let pairs: Vec<[String; 2]> = r["overlaps"].as_array().into_iter().flatten().map(pair).collect();
assert_eq!(pairs, vec![["Housing".to_string(), "Plate".to_string()]], "{mode}: {r}");
assert!((f(&r["overlaps"][0]["volume_mm3"]) - 50.0).abs() < 1e-6, "{r}");
}
}
/// Parameters out of range are refused, not guessed at.
#[test]
fn bad_parameters_are_refused() {
let mut s = Session::default();
cube(&mut s, "A", 10.0, [0.0, 0.0, 0.0]);
for p in [
json!({"clearance": -1}),
json!({"clearance": "wide"}),
json!({"min_volume": -0.1}),
json!({"nearest": 1e9}),
json!({"tolerance": 5}),
json!({"skip_within": "body"}),
json!({"bodies": ["Nope"]}),
json!({"against": ["Nope"]}),
] {
assert!(s.execute("inspect.clearance", &p).is_err(), "{p}");
}
let r = run(&mut s, "inspect.clearance", json!({"nearest": 3, "skip_within": null}));
assert_eq!(r["pairs_checked"], 0, "{r}");
assert_eq!(r["nearest"]["A"].as_array().map(Vec::len), Some(0), "{r}");
}
/// A pin sunk 0.01 mm into a plate 1.7 m long: the shared ring holds π (4.43² − 2.5²) × 0.01 mm³
/// wherever the plate stands, as on a plate of 100 mm (the boolean of the whole plate gave 0.31).
#[test]
fn a_thin_overlap_with_a_large_plate_is_measured_in_full() {
let ring = std::f64::consts::PI * (4.43f64.powi(2) - 2.5f64.powi(2)) * 0.01;
for (length, far) in [(100.0, false), (1700.0, false), (1700.0, true)] {
let mut s = Session::default();
let corner = [-length / 2.0, -length / 4.0, 0.0];
run(&mut s, "solid.box", json!({"length": length, "width": length / 2.0, "height": 5, "corner": corner, "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"radius": 2.5, "height": 9, "base": [0, 0, -2], "operation": "cut", "targets": ["Plate"]}));
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 7, "base": [0, 0, -1.99], "operation": "new", "body_name": "Pin"}));
run(&mut s, "solid.cylinder", json!({"radius": 4.43, "height": 2, "base": [0, 0, 4.99], "operation": "join", "targets": ["Pin"]}));
let shift = if far { [-326.3, 13.5, 1278.3] } else { [0.0; 3] };
if far {
run(&mut s, "solid.move", json!({"bodies": ["Plate", "Pin"], "translate": shift}));
}
let r = run(&mut s, "inspect.clearance", json!({"min_volume": 1e-4}));
let o = &r["overlaps"][0];
assert!((f(&o["volume_mm3"]) - ring).abs() < 1e-3 * ring, "{length} {far}: {r}");
// The ring's centre is on the axis, 0.005 below the plate's face.
assert!((f(&o["center"][2]) - (4.995 + shift[2])).abs() < 1e-4, "{length} {far}: {r}");
assert!((f(&o["center"][0]) - shift[0]).abs() < 1e-4, "{length} {far}: {r}");
}
}
/// A shared part whose centre lies outside its own box, or which holds more than its box, is
/// malformed: the pair is reported unchecked rather than with that volume.
#[test]
fn a_malformed_shared_part_is_not_measured() {
use solidcraft_geom::{Aabb3, Vec3};
let bbox = Aabb3 { min: Vec3::new(0.0, 0.0, 0.0), max: Vec3::new(10.0, 10.0, 1.0) };
assert!(super::plausible(50.0, Vec3::new(5.0, 5.0, 0.5), bbox));
assert!(!super::plausible(50.0, Vec3::new(5.0, 5.0, 3.0), bbox));
assert!(!super::plausible(1239.0, Vec3::new(5.0, 5.0, 0.5), bbox));
assert!(!super::plausible(f64::NAN, Vec3::new(5.0, 5.0, 0.5), bbox));
}
/// Two plates 0.2 mm apart, the upper one set 10 mm in from the lower one's corner, and two set
/// side by side with a 0.2 mm gap: 0.2, not 0 (a point past a corner, on the line of one of its
/// edges, was taken for a point of the face). Gaps under twice the meshes' chord went wrong.
#[test]
fn a_small_gap_past_a_corner_is_not_contact() {
let mut s = Session::default();
let plate = |s: &mut Session, name: &str, size: [f64; 3], corner: [f64; 3]| {
run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner, "operation": "new", "body_name": name}));
};
plate(&mut s, "A", [100.0, 100.0, 2.0], [0.0, 0.0, 0.0]);
plate(&mut s, "B", [100.0, 100.0, 2.0], [10.0, 10.0, 2.2]);
plate(&mut s, "C", [100.0, 100.0, 2.0], [0.0, 0.0, 10.0]);
plate(&mut s, "D", [100.0, 100.0, 2.0], [100.2, 0.0, 10.0]);
// 3 mm apart in x and 0.2 in z, along their long sides: √(3² + 0.2²).
plate(&mut s, "E", [1000.0, 100.0, 2.0], [0.0, 300.0, 0.0]);
plate(&mut s, "F", [1000.0, 100.0, 2.0], [1003.0, 300.0, 2.2]);
for (a, b, want) in [("A", "B", 0.2), ("C", "D", 0.2), ("E", "F", (9.0f64 + 0.04).sqrt())] {
let r = run(&mut s, "inspect.clearance", json!({"bodies": [a], "against": [b], "clearance": 5, "nearest": 4}));
assert!((f(&r["near"][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
}
}