Compare Bodies: each region measured exactly, on the cut of the two bodies

inspect.compare integrated a region's volume along sampling lines: the
40 x 30 x 5 slab between a box and one 5 mm taller came out at 5976.49 mm3
against 6000. The regions are still found from the two surfaces; each one's
volume, centre and box now come from the pieces of the exact cut a - b (or
b - a) that lie there, measured as inspect.measure measures a body
(measured_by: boolean). Where a cut cannot be made the region keeps the line
integral, marked measured_by: lines, with a warning. The rig case
cov-compare-taller-box passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 20:23:53 -04:00
committed by Irakli Kourtis
parent 97935eed86
commit d094d43f23
4 changed files with 185 additions and 34 deletions
+144 -19
View File
@@ -1,12 +1,13 @@
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
//! surface's distance to the other is sampled, and the places where only one of them has
//! material are found, measured and, on request, coloured. It works from the surfaces
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
//! almost every face compare as well as any.
//! material are found, measured and, on request, coloured. The places are found from the
//! surfaces ([`solidcraft_geom::compare`]), so bodies that share almost every face compare as
//! well as any; each place's volume, centre and box are then measured exactly, on the pieces of
//! the two cuts a − b and b − a that lie there, as `inspect.measure` measures a body.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::compare::{self, CompareOptions, Side};
use solidcraft_geom::{Aabb3, Vec3};
use super::CommandSpec;
use crate::params::bad;
@@ -18,9 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Com
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3, center and bbox (measured_by: \"boolean\": exact, the pieces of a − b or \
b − a there, measured as inspect.measure does; \"lines\": integrated along lines through the region, see resolution, when the cut could not be \
made, with a warning), area_mm2, max_deviation_mm, at, faces_a, faces_b (face indices, as model.faces lists them); identical; verdict; warnings \
(a body whose surface does not enclose what it measures, lines left out, a cut that could not be made). The regions are found from the two \
surfaces, so near-identical bodies compare too",
)];
/// Default colours: red for material only `a` has, green for material only `b` has.
@@ -124,8 +127,9 @@ fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
}
/// What makes the comparison less certain: a body whose surface does not enclose what it
/// measures (open, or faces that failed), lines left out of region volumes.
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
/// measures (open, or faces that failed), `skipped` lines left out of region volumes integrated
/// along lines.
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, skipped: usize) -> Vec<String> {
let mut out = Vec::new();
for (name, measure, mesh) in bodies {
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
@@ -137,13 +141,112 @@ fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c:
));
}
}
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
if skipped > 0 {
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
}
out
}
/// A region's volume, centre and box, measured exactly.
#[derive(Clone, Copy, Debug)]
struct Measured {
volume: f64,
center: Vec3,
bbox: Aabb3,
}
/// The pieces of `from` − `tool`, the material only `from` has, each measured as
/// `inspect.measure` measures a body (none when `tool` takes all of it).
fn cut_pieces(from: &solidcraft_kernel::Body, tool: &solidcraft_kernel::Body) -> std::result::Result<Vec<Measured>, String> {
let rest = solidcraft_kernel::boolean(from, tool, solidcraft_kernel::BoolOp::Cut).map_err(|e| e.to_string())?;
let Some(rest) = rest else { return Ok(Vec::new()) };
rest.lumps()
.map_err(|e| e.to_string())?
.iter()
.map(|piece| {
let m = solidcraft_kernel::measure_mass(piece).map_err(|e| e.to_string())?;
Ok(Measured { volume: m.volume, center: m.centroid, bbox: m.bbox })
})
.collect()
}
/// How much of `piece` lies by `region`: the volume their boxes, each grown by `pad`, share
/// (0 when they do not meet). Grown, so a region found as a flat patch of surface still holds
/// the piece on it.
fn overlap(piece: &Aabb3, region: &Aabb3, pad: f64) -> f64 {
let shared = piece.inflated(pad).intersection(&region.inflated(pad));
if shared.is_empty() {
return 0.0;
}
let d = shared.size();
d.x * d.y * d.z
}
/// Each region's exact measure: the pieces of the cut on its side (a − b for material only `a`
/// has, b − a for material only `b` has), each given to the region of that side whose box holds
/// most of it, summed. A piece by no region is thinner than the tolerance or in a region not
/// listed. `None` for a region whose cut could not be made or that no piece fell to; the second
/// value says why, once per side.
fn exact_regions(
a: &solidcraft_kernel::Body,
b: &solidcraft_kernel::Body,
c: &compare::Comparison,
pad: f64,
) -> (Vec<Option<Measured>>, Vec<String>) {
let sides = [Side::OnlyInA, Side::OnlyInB];
let wanted: Vec<Side> = sides.into_iter().filter(|side| c.regions.iter().any(|r| r.side == *side)).collect();
let cuts = solidcraft_kernel::map_on_threads(&wanted, 2, |side| match side {
Side::OnlyInA => cut_pieces(a, b),
Side::OnlyInB => cut_pieces(b, a),
});
// (volume, volume × centre, box) of the pieces each region holds.
let mut sums: Vec<Option<(f64, Vec3, Aabb3)>> = vec![None; c.regions.len()];
let mut failed = Vec::new();
for (side, cut) in wanted.iter().zip(cuts) {
let pieces = match cut {
Some(Ok(pieces)) => pieces,
Some(Err(e)) => {
failed.push((*side, e));
continue;
}
None => {
failed.push((*side, "the cut failed".to_string()));
continue;
}
};
for p in pieces.iter().filter(|p| p.volume.is_finite() && p.center.is_finite() && p.volume > 0.0) {
let best = c
.regions
.iter()
.enumerate()
.filter(|(_, r)| r.side == *side)
.map(|(i, r)| (i, overlap(&p.bbox, &r.bbox, pad)))
.filter(|(_, o)| *o > 0.0)
.max_by(|x, y| x.1.total_cmp(&y.1));
let Some((i, _)) = best else { continue };
if let Some(slot) = sums.get_mut(i) {
let (v, m, bb) = slot.get_or_insert((0.0, Vec3::ZERO, Aabb3::EMPTY));
*v += p.volume;
*m += p.center * p.volume;
*bb = bb.union(&p.bbox);
}
}
}
let measured = sums.into_iter().map(|x| x.map(|(v, m, bbox)| Measured { volume: v, center: m / v, bbox })).collect::<Vec<_>>();
let mut notes: Vec<String> = failed
.iter()
.map(|(side, e)| {
let (from, tool) = if *side == Side::OnlyInA { ("a", "b") } else { ("b", "a") };
format!("the cut {from} − {tool} could not be made ({e}): its regions' volumes are integrated along lines, not exact")
})
.collect();
let unmatched = c.regions.iter().zip(&measured).filter(|(r, m)| m.is_none() && !failed.iter().any(|(side, _)| *side == r.side)).count();
if unmatched > 0 {
notes.push(format!("{unmatched} region(s) held no piece of the cut: their volumes are integrated along lines, not exact"));
}
(measured, notes)
}
fn deviation_json(d: &compare::Deviation) -> Value {
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
}
@@ -182,24 +285,44 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
};
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
// The regions' exact volumes, from the cuts of two bodies that enclose volumes.
let (exact, exact_notes) = if c.regions.is_empty() {
(Vec::new(), Vec::new())
} else if super::inspect::encloses_volume(&ba.body) && super::inspect::encloses_volume(&bb.body) {
exact_regions(&ba.body, &bb.body, &c, c.spacing.max(tolerance))
} else {
(vec![None; c.regions.len()], vec!["a body encloses no volume, so the regions' volumes are integrated along lines, not exact".to_string()])
};
let measured = |i: usize| -> (f64, Vec3, Aabb3, &'static str) {
match (exact.get(i).copied().flatten(), c.regions.get(i)) {
(Some(m), _) => (m.volume, m.center, m.bbox, "boolean"),
(None, Some(r)) => (r.volume, r.center, r.bbox, "lines"),
(None, None) => (f64::NAN, Vec3::ZERO, Aabb3::EMPTY, "lines"),
}
};
// Largest first, by the volumes as measured.
let mut order: Vec<usize> = (0..c.regions.len()).collect();
order.sort_by(|&x, &y| measured(y).0.total_cmp(&measured(x).0));
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
let regions: Vec<Value> = c
.regions
let regions: Vec<Value> = order
.iter()
.enumerate()
.map(|(i, r)| {
.filter_map(|(i, &k)| c.regions.get(k).map(|r| (i, k, r)))
.map(|(i, k, r)| {
let (volume, center, bbox, measured_by) = measured(k);
json!({
"index": i + 1,
"side": side_name(r.side),
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
"volume_mm3": r.volume,
"volume_mm3": volume,
"measured_by": measured_by,
"area_mm2": r.area_a + r.area_b,
"area_a_mm2": r.area_a,
"area_b_mm2": r.area_b,
"max_deviation_mm": r.max_deviation,
"at": r.at,
"center": r.center,
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
"center": center,
"bbox": {"min": bbox.min, "max": bbox.max},
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
@@ -207,14 +330,16 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let total = |side: Side| {
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
let rs: Vec<f64> = (0..c.regions.len()).filter(|&i| c.regions.get(i).is_some_and(|r| r.side == side)).map(|i| measured(i).0).collect();
// `+ 0.0`: an empty sum is -0.0.
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
(rs.len(), rs.iter().filter(|v| v.is_finite()).sum::<f64>() + 0.0)
};
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
let worst = c.a_to_b.max.max(c.b_to_a.max);
let identical = c.regions.is_empty() && c.unlisted == 0;
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
let skipped = (0..c.regions.len()).filter(|&i| measured(i).3 == "lines").filter_map(|i| c.regions.get(i)).map(|r| r.lines.skipped).sum();
let mut warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, skipped);
warnings.extend(exact_notes);
let mut verdict = if identical {
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
} else {
+32 -8
View File
@@ -58,19 +58,21 @@ fn a_pocket_is_material_only_the_plain_box_has() {
let g = &rs[0];
assert_eq!(g["side"], "only_in_a");
assert_eq!(g["material_of"], "A");
assert!(close(f(&g["volume_mm3"]), 40.0, 0.01), "{g}");
// Measured exactly, on the cut a − b: the pocket's own volume, box and centre.
assert_eq!(g["measured_by"], "boolean", "{g}");
assert!(close(f(&g["volume_mm3"]), 40.0, 1e-9), "{g}");
assert!((f(&g["max_deviation_mm"]) - 0.5).abs() < 1e-6, "{g}");
let (lo, hi) = (&g["bbox"]["min"], &g["bbox"]["max"]);
for (k, (want_lo, want_hi)) in [(10.0, 20.0), (10.0, 18.0), (9.5, 10.0)].into_iter().enumerate() {
assert!((f(&lo[k]) - want_lo).abs() < 0.05 && (f(&hi[k]) - want_hi).abs() < 0.05, "{g}");
assert!((f(&lo[k]) - want_lo).abs() < 1e-6 && (f(&hi[k]) - want_hi).abs() < 1e-6, "{g}");
}
let c = &g["center"];
assert!((f(&c[0]) - 15.0).abs() < 0.02 && (f(&c[1]) - 14.0).abs() < 0.02 && (f(&c[2]) - 9.75).abs() < 1e-6, "{g}");
assert!((f(&c[0]) - 15.0).abs() < 1e-9 && (f(&c[1]) - 14.0).abs() < 1e-9 && (f(&c[2]) - 9.75).abs() < 1e-9, "{g}");
// The pocket's floor and walls, and a's top.
assert_eq!(g["faces_b"].as_array().map(Vec::len), Some(5), "{g}");
assert_eq!(g["faces_a"].as_array().map(Vec::len), Some(1), "{g}");
assert!(close(f(&r["difference"]["volume_mm3"]), -40.0, 1e-6), "{}", r["difference"]);
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 0.01));
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 1e-9));
assert_eq!(r["only_in_b"]["regions"], 0);
// The other way round, the same material is only in b.
let r = run(&mut s, "inspect.compare", json!({"a": "A (2)", "b": "A"}));
@@ -88,7 +90,7 @@ fn a_boss_is_material_only_the_box_with_it_has() {
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
assert_eq!(rs[0]["side"], "only_in_b");
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 0.01), "{}", rs[0]);
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 1e-9), "{}", rs[0]);
assert!((f(&rs[0]["max_deviation_mm"]) - 2.0).abs() < 1e-6);
assert!(close(f(&r["difference"]["volume_mm3"]), 60.0, 1e-6));
// Coloured green: the boss's top and four sides, all of them beyond the tolerance; not a's top,
@@ -104,6 +106,27 @@ fn a_boss_is_material_only_the_box_with_it_has() {
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
}
#[test]
fn a_taller_box_has_a_slab_only_it_has() {
// b is 5 mm taller on the same footprint: b − a is the slab z 20..25, 40 x 30 x 5 = 6000 mm³
// at (20, 15, 22.5), sharing four side faces and the bottom with a. Exact, from the cut.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 25, "body_name": "B"}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "B"}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
let g = &rs[0];
assert_eq!((g["side"].as_str(), g["measured_by"].as_str()), (Some("only_in_b"), Some("boolean")), "{g}");
assert!(close(f(&g["volume_mm3"]), 6000.0, 1e-9), "{g}");
let c = &g["center"];
assert!((f(&c[0]) - 20.0).abs() < 1e-9 && (f(&c[1]) - 15.0).abs() < 1e-9 && (f(&c[2]) - 22.5).abs() < 1e-9, "{g}");
assert!((f(&g["bbox"]["min"][2]) - 20.0).abs() < 1e-6 && (f(&g["bbox"]["max"][2]) - 25.0).abs() < 1e-6, "{g}");
assert!(close(f(&r["only_in_b"]["volume_mm3"]), 6000.0, 1e-9), "{r}");
assert_eq!(f(&r["only_in_a"]["volume_mm3"]), 0.0);
assert_eq!(r["warnings"], json!([]), "{r}");
}
/// A 100 x 60 sheet-metal plate with a 20 mm flange along y = 0 and a 5 mm hole at (x, 40).
fn bracket(s: &mut Session, name: &str, x: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} base")}));
@@ -131,9 +154,10 @@ fn a_hole_moved_2_mm_leaves_two_crescents() {
let crescent = (std::f64::consts::PI * rr * rr - 2.0 * rr * rr * (d / (2.0 * rr)).acos() + d / 2.0 * (4.0 * rr * rr - d * d).sqrt()) * t;
assert!((crescent - 24.316_34).abs() < 1e-4);
for g in &rs {
assert!(close(f(&g["volume_mm3"]), crescent, 0.02), "{g}");
assert_eq!(g["measured_by"], "boolean", "{g}");
assert!(close(f(&g["volume_mm3"]), crescent, 1e-6), "{g}");
let c = &g["center"];
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{g}");
assert!((f(&c[1]) - 40.0).abs() < 1e-6 && (f(&c[2]) - 1.25).abs() < 1e-6, "{g}");
// Material only Before has is where After's hole is (beyond x = 51), and the reverse.
match g["side"].as_str() {
Some("only_in_a") => assert!(f(&c[0]) > 51.5, "{g}"),
@@ -207,6 +231,6 @@ fn bad_parameters_are_refused() {
assert_eq!(r["a"]["name"], "A");
assert_eq!(r["b"]["name"], "C");
assert_eq!(r["only_in_a"]["regions"], 1, "{r}");
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 0.01), "{r}");
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 1e-9), "{r}");
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
}
+9 -6
View File
@@ -201,12 +201,15 @@ gives the largest and the mean distance each way. Samples further than `toleranc
the **regions**: `only_in_a` where `a` has material and `b` has none, `only_in_b` the other way
round. Each region gives its volume, the deviating surface area, the largest deviation and where
it is, its centre and box, and the faces of each body it lies on (`faces_a`, `faces_b`, numbered
as `model.faces` lists them), largest volume first. The volume is integrated along a grid of
parallel lines through the region (`resolution` gives their spacing), so a thin sliver is
measured as well as a block. `identical` is true when no region is found, and `verdict` says it in
one line. `warnings` lists what makes the result less certain: a body whose surface does not
enclose the volume it measures (open, or with faces that failed to import), and lines left out of
a region's volume because they crossed a gap. With `color`, the faces that hold a region turn
as `model.faces` lists them), largest volume first. The volume, centre and box are exact
(`measured_by: "boolean"`): the pieces of the cut a − b (or b − a) that lie in the region,
measured as `inspect.measure` measures a body. When the cut cannot be made, the volume is
integrated along a grid of parallel lines through the region instead (`measured_by: "lines"`,
`resolution` gives their spacing), with a warning. `identical` is true when no region is found,
and `verdict` says it in one line. `warnings` lists what makes the result less certain: a body
whose surface does not enclose the volume it measures (open, or with faces that failed to
import), a cut that could not be made, and lines left out of a region's volume because they
crossed a gap. With `color`, the faces that hold a region turn
red where only `a` has material and green where only `b` has, on both bodies: the faces mostly
beyond the tolerance (a small change on a large face leaves the face alone), or, for a region
with none, the face with the largest share.
-1
View File
@@ -2,4 +2,3 @@
# with --known-failing fails on a case that fails and is not listed here, and on a listed case
# that passes: a fix removes its line in the same pull request.
# Baseline: rig-cases-construction-inspect 45f69c0e5 with SMLib 60b1c6a, aarch64 Linux.
cov-compare-taller-box inspect.compare integrates a region's volume along sampling lines: the 40 x 30 x 5 slab only B has comes out 5976.49, not 6000 (0.4% short)