inspect.compare: where two versions of a part differ, without a boolean of the two (#1423)

New command inspect.compare {a, b, tolerance?}: measures of both and their differences, two-way surface deviation from a bounding-volume tree, regions beyond the tolerance clustered by side (only_in_a / only_in_b) with integrated volumes and faces, a verdict, optional face colouring as one undo step, and warnings for open meshes. Geometry in solidcraft-geom (compare.rs), command in the engine. Closes #1363.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-15 12:45:43 -04:00
1 parent 2bfec256b7
commit d0a4443939
16 files changed
+1617 -2

No files matched your search

+1
View File
@@ -555,6 +555,7 @@
"cmd.file_slicers": "Installierte Slicer",
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
"cmd.inspect_curvature_map": "Krümmungskartenanalyse",
"cmd.inspect_draft": "Auswinkelanalyse",
+1
View File
@@ -108,6 +108,7 @@
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
"cmd.inspect_curvature_map": "Curvature Map Analysis",
"cmd.inspect_draft": "Draft Analysis",
+1
View File
@@ -108,6 +108,7 @@
"cmd.file_slicers": "Slicers installés",
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
"cmd.inspect_curvature_map": "Analyse par carte de courbure",
"cmd.inspect_draft": "Analyse de dépouille",
+1
View File
@@ -108,6 +108,7 @@
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_curvature_comb": "曲率コーム解析",
"cmd.inspect_curvature_map": "曲率マップ解析",
"cmd.inspect_draft": "勾配解析",
+1
View File
@@ -555,6 +555,7 @@
"cmd.file_slicers": "설치된 슬라이서",
"cmd.inspect_accessibility": "접근성 분석",
"cmd.inspect_center_of_mass": "무게중심",
"cmd.inspect_compare": "바디 비교",
"cmd.inspect_curvature_comb": "곡률빗 분석",
"cmd.inspect_curvature_map": "곡률맵 분석",
"cmd.inspect_draft": "블록각 분석",
+1
View File
@@ -97,6 +97,7 @@
"cmd.file_slicers": "Установленные слайсеры",
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
"cmd.inspect_curvature_map": "Карта кривизны",
"cmd.inspect_draft": "Анализ уклонов",
+1
View File
@@ -98,6 +98,7 @@
"cmd.file_save_mesh": "Зберегти як сітку",
"cmd.inspect_accessibility": "Аналіз доступності",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
"cmd.inspect_curvature_map": "Аналіз карти кривизни",
"cmd.inspect_draft": "Аналіз ухилу",
+1
View File
@@ -108,6 +108,7 @@
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_curvature_comb": "曲率梳分析",
"cmd.inspect_curvature_map": "曲率图分析",
"cmd.inspect_draft": "拔模分析",
+309
View File
@@ -0,0 +1,309 @@
//! 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.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::compare::{self, CompareOptions, Side};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
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",
)];
/// Default colours: red for material only `a` has, green for material only `b` has.
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
fn side_name(s: Side) -> &'static str {
match s {
Side::OnlyInA => "only_in_a",
Side::OnlyInB => "only_in_b",
}
}
/// A number parameter in `range`, `default` when absent.
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && range.contains(x))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
}
}
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
let given = |k: &str| -> Result<Option<String>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) => Ok(Some(n.clone())),
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
}
};
let (a, b) = match (given("a")?, given("b")?) {
(Some(a), Some(b)) => (a, b),
(None, None) => {
let selected: Vec<String> =
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
match (selected.as_slice(), all.as_slice()) {
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
}
}
_ => return Err(bad(cmd, "give both `a` and `b`")),
};
for n in [&a, &b] {
if st.body(n).is_none() {
return Err(bad(cmd, format!("no body `{n}`")));
}
}
if a == b {
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
}
Ok((a, b))
}
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
match p.get("color") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
Some(Value::Object(o)) => {
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
None | Some(Value::Null) => Ok(d),
Some(v) => {
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
}
};
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
}
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
}
}
fn v3(v: &Value) -> Option<Vec3> {
crate::params::vec3(v)
}
/// b − a of the two bodies' measures.
fn difference(ma: &Value, mb: &Value) -> Value {
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
json!({
"volume_mm3": sub(va, vb),
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
"center_of_mass": com,
"center_of_mass_distance_mm": com.map(Vec3::len),
"bbox_min": corner("min"),
"bbox_max": corner("max"),
})
}
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
let o = m.bounds().center();
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
}
/// 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> {
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 };
let enclosed = enclosed_volume(mesh);
// A mesh within `chord` of the surface encloses the volume to within chord × area.
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
out.push(format!(
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
));
}
}
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
}
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})
}
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.compare";
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
let colors = colours(cmd, p)?;
let st = s.world_state();
let (na, nb) = pair(s, &st, p, cmd)?;
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
// body's size), so two meshes of one surface agree well within it.
let chord = tolerance / 4.0;
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
.into_iter()
.zip([&na, &nb]);
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
if mesh.triangles.is_empty() {
return Err(bad(cmd, format!("`{name}` has no surface")));
}
Ok((m, mesh))
};
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
let jobs: Vec<usize> = (0..n).collect();
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
};
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
let regions: Vec<Value> = c
.regions
.iter()
.enumerate()
.map(|(i, r)| {
json!({
"index": i + 1,
"side": side_name(r.side),
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
"volume_mm3": r.volume,
"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},
"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},
})
})
.collect();
let total = |side: Side| {
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).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)
};
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 mut verdict = if identical {
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
} else {
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
format!(
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
c.regions.len(),
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
)
};
if !warnings.is_empty() {
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
}
let mut out = json!({
"a": measure_a,
"b": measure_b,
"difference": difference(&measure_a, &measure_b),
"tolerance_mm": tolerance,
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
"identical": identical,
"verdict": verdict,
"regions": regions,
"only_in_a": {"regions": ca, "volume_mm3": va},
"only_in_b": {"regions": cb, "volume_mm3": vb},
"unlisted_regions": c.unlisted,
"warnings": warnings,
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
});
if let Some(colors) = colors {
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
}
Ok(out)
}
/// A face is coloured for a region when at least this share of it deviates there.
const MOSTLY: f64 = 0.5;
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
/// the one with the largest share.
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
let share = |m: usize, &(f, beyond): &(u32, f64)| {
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
};
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
if !mostly.is_empty() {
return mostly;
}
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
}
/// Give the faces holding each region the colour of its side (a face holding several takes the
/// largest one's); returns how many face appearances were set.
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
use solidcraft_doc::appearance::{FaceLook, Look};
let st = s.model.state();
// Where each face of the two bodies is, as the design file names and finds it. A body placed
// by an occurrence is coloured where it is defined, if at all.
let found: Vec<_> =
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
// Smallest region first, so the largest one's colour is the one that stays.
for r in c.regions.iter().rev() {
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
let mut look = Look::custom(colour, 1.0);
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
for (m, f) in faces_to_color(c, r) {
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
}
}
if picks.is_empty() {
return Ok(0);
}
let a = &mut s.doc_mut().appearances;
let mut n = 0;
for (body, point, name, look) in picks {
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
if a.faces.len() >= 100_000 {
return Err(bad("inspect.compare", "too many face appearances"));
}
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
n += 1;
}
Ok(n)
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+212
View File
@@ -0,0 +1,212 @@
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 regions(r: &Value) -> Vec<Value> {
r["regions"].as_array().cloned().unwrap_or_default()
}
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(f64::NAN)
}
fn close(got: f64, want: f64, rel: f64) -> bool {
(got - want).abs() <= rel * want.abs()
}
/// A 40 x 30 x 10 box `A` and its copy `A (2)`, in the same place.
fn two_boxes() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
s
}
#[test]
fn a_box_and_its_copy_are_identical() {
let mut s = two_boxes();
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
assert_eq!(r["identical"], true, "{r}");
assert_eq!(r["warnings"], json!([]), "{r}");
assert!(regions(&r).is_empty());
assert!(f(&r["deviation"]["max_mm"]) < 1e-9, "{}", r["deviation"]);
assert_eq!(f(&r["difference"]["volume_mm3"]), 0.0);
assert!(close(f(&r["a"]["volume_mm3"]), 12_000.0, 1e-9));
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("identical within 0.01 mm"), "{}", r["verdict"]);
// Without names: the design's only two bodies. Comparing changes nothing.
let before = s.doc.clone();
let r = run(&mut s, "inspect.compare", json!({}));
assert_eq!(r["identical"], true);
assert!(std::sync::Arc::ptr_eq(&before, &s.doc));
}
#[test]
fn a_pocket_is_material_only_the_plain_box_has() {
// b has a 10 x 8 x 0.5 pocket in its top: 40 mm³ that only a has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 10, "width": 8, "height": 0.5, "corner": [10, 10, 9.5], "operation": "cut", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
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}");
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}");
}
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}");
// 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_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"}));
assert_eq!(regions(&r).len(), 1);
assert_eq!(regions(&r)[0]["side"], "only_in_b");
assert_eq!(regions(&r)[0]["material_of"], "A");
}
#[test]
fn a_boss_is_material_only_the_box_with_it_has() {
// b has a 6 x 5 x 2 boss on its top: 60 mm³ that only b has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": true}));
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!((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,
// of which only the boss's footprint is. One undo step.
assert_eq!(r["colored_faces"], 5, "{r}");
let green = |s: &Session, body: &str| s.doc.appearances.faces.iter().filter(|x| x.body == body && x.look.color == [0x2f, 0x9e, 0x44]).count();
assert_eq!((green(&s, "A (2)"), green(&s, "A")), (5, 0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.appearances.faces.len(), 0);
// Own colours.
let r = run(&mut s, "inspect.compare", json!({"color": {"only_in_b": "#0000ff"}}));
assert_eq!(r["colored_faces"], 5);
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
}
/// 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")}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": format!("{name} base"), "body_name": name}));
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20, "body": name}));
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} hole")}));
run(s, "sketch.circle.center", json!({"center": [x, 40], "diameter": 5}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": format!("{name} hole"), "through_all": true, "operation": "cut", "targets": [name]}));
}
#[test]
fn a_hole_moved_2_mm_leaves_two_crescents() {
let mut s = Session::default();
bracket(&mut s, "Before", 50.0);
bracket(&mut s, "After", 52.0);
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After"}));
let rs = regions(&r);
assert_eq!(rs.len(), 2, "{r}");
// Each is a disk of 2.5 mm radius less its overlap with the other, centres 2 mm apart, through
// the 2.5 mm sheet: (π r² − 2 r² acos(d / 2r) + (d / 2) √(4 r² − d²)) · t.
let (rr, d, t) = (2.5f64, 2.0f64, 2.5);
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}");
let c = &g["center"];
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{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}"),
Some("only_in_b") => assert!(f(&c[0]) < 50.5, "{g}"),
_ => panic!("{g}"),
}
}
assert_ne!(rs[0]["side"], rs[1]["side"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 1e-3, "{}", r["difference"]);
// Coloured: the hole walls, not the plate's faces, of which only the crescents deviate.
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After", "color": true}));
let n = r["colored_faces"].as_u64().unwrap_or(0) as usize;
assert!((2..=8).contains(&n), "{r}");
let looks = &s.doc.appearances.faces;
assert_eq!(looks.len(), n);
for l in looks {
assert!(((l.point.x - 51.0).powi(2) + (l.point.y - 40.0).powi(2)).sqrt() < 4.0, "{:?}", l.point);
}
}
#[test]
fn a_part_and_its_step_round_trips_are_identical() {
// Curved faces too: a hole and a rounded edge.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 10, "base": [20, 15, 0], "operation": "cut", "targets": ["Part"]}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 3}));
let dir = std::env::temp_dir().join(format!("solidcraft-compare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.step");
run(&mut s, "file.export", json!({"path": path, "bodies": ["Part"]}));
let mut inserted = Vec::new();
for _ in 0..2 {
let r = run(&mut s, "file.insert_step", json!({"path": path}));
inserted.push(r["created_bodies"][0].as_str().unwrap().to_string());
}
let _ = std::fs::remove_dir_all(&dir);
for (a, b) in [("Part", inserted[0].as_str()), (inserted[0].as_str(), inserted[1].as_str())] {
let r = run(&mut s, "inspect.compare", json!({"a": a, "b": b}));
assert_eq!(r["identical"], true, "{a} vs {b}: {r}");
assert!(f(&r["deviation"]["max_mm"]) < 0.005, "{a} vs {b}: {}", r["deviation"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 0.05, "{a} vs {b}: {}", r["difference"]);
}
}
#[test]
fn bad_parameters_are_refused() {
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "C"}));
for p in [
json!({}),
json!({"a": "A"}),
json!({"a": "A", "b": "A"}),
json!({"a": "A", "b": "nope"}),
json!({"a": "A", "b": 3}),
json!({"a": "A", "b": "C", "tolerance": 0}),
json!({"a": "A", "b": "C", "tolerance": -1}),
json!({"a": "A", "b": "C", "tolerance": "a lot"}),
json!({"a": "A", "b": "C", "tolerance": 1e300}),
json!({"a": "A", "b": "C", "spacing": 0}),
json!({"a": "A", "b": "C", "max_regions": 0}),
json!({"a": "A", "b": "C", "max_points": 10}),
json!({"a": "A", "b": "C", "color": "blue"}),
json!({"a": "A", "b": "C", "color": {"only_in_a": "#12345"}}),
] {
assert!(s.execute("inspect.compare", &p).is_err(), "{p}");
}
// Two selected bodies are the pair.
s.selection = vec![crate::Sel::Body { name: "A".into() }, crate::Sel::Body { name: "C".into() }];
let r = run(&mut s, "inspect.compare", json!({}));
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!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
}
+2
View File
@@ -22,6 +22,7 @@ macro_rules! planned {
mod browser;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
@@ -256,6 +257,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
+927
View File
@@ -0,0 +1,927 @@
//! Comparing two closed triangle meshes without a boolean of the two: how far each surface strays
//! from the other, and the places where only one of them has material.
//!
//! Each surface is sampled: the corners of its triangles, and inside the larger ones the corners
//! and centres of pieces at most `spacing` across. Every sample measures its distance to the other
//! surface (a [`TriangleTree`] over the other's triangles). Samples further away than the
//! tolerance are the deviating surface. One that lies outside the other mesh bounds material
//! only its own mesh has; one inside it bounds material only the other mesh has. The deviating
//! samples are clustered by distance into regions, and each region's volume is integrated along
//! a grid of parallel lines through its box: on each line, the stretches inside one mesh and
//! outside the other, found from where the line crosses the two surfaces. Nothing here
//! intersects the two surfaces, so bodies that share almost every face (two versions of one
//! part) compare as well as any others.
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use crate::{Aabb3, Mesh, Vec3};
/// Runs a job for every index below `n`: the jobs are independent, so any order and any number
/// of threads will do. [`serial`] runs them one after another; a caller with threads passes one
/// that spreads them. A job that did not run (it panicked on another thread) runs again on the
/// calling thread.
pub type Each<'a> = &'a (dyn Fn(usize, &(dyn Fn(usize) + Sync)) + Sync);
/// [`Each`] on the calling thread.
pub fn serial(n: usize, job: &(dyn Fn(usize) + Sync)) {
(0..n).for_each(job);
}
/// How [`compare`] samples and measures.
#[derive(Clone, Copy, Debug)]
pub struct CompareOptions {
/// Distances up to this are agreement (mm).
pub tolerance: f64,
/// The largest size of the pieces a triangle is sampled in (mm); raised when a mesh would
/// need more than `max_points` samples.
pub spacing: f64,
/// Sample points per mesh, about (a mesh of more triangles than two thirds of this is
/// sampled once per triangle, at its centre and corners, anyway).
pub max_points: usize,
/// Lines through a region for its volume, at most.
pub max_lines: usize,
/// Regions measured and listed, largest first; the rest are counted in
/// [`Comparison::unlisted`].
pub max_regions: usize,
}
impl Default for CompareOptions {
fn default() -> Self {
CompareOptions { tolerance: 0.01, spacing: 0.5, max_points: 8_000_000, max_lines: 40_000, max_regions: 50 }
}
}
/// Which mesh has the material of a region.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
/// Material of `a` where `b` has none.
OnlyInA,
/// Material of `b` where `a` has none.
OnlyInB,
}
/// How far one mesh's surface lies from the other's, over its samples.
#[derive(Clone, Copy, Debug, Default)]
pub struct Deviation {
/// The largest distance (mm) and the sample where it was found.
pub max: f64,
pub at: Vec3,
/// The mean distance, weighted by the surface each sample stands for (mm).
pub mean: f64,
/// The surface further than the tolerance from the other mesh, and all of it (mm²).
pub beyond_area: f64,
pub area: f64,
/// Sample points measured.
pub points: usize,
}
/// The lines a region's volume was integrated along.
#[derive(Clone, Copy, Debug, Default)]
pub struct Lines {
/// The lines run along this axis (0 = X, 1 = Y, 2 = Z): the region box's shortest.
pub axis: usize,
/// Distance between neighbouring lines across the axis, along the next two axes in turn
/// (for Z: X, then Y) (mm).
pub spacing: [f64; 2],
pub count: usize,
/// Lines left out because they crossed a mesh an odd number of times (a gap in it).
pub skipped: usize,
}
/// A place where only one mesh has material.
#[derive(Clone, Debug)]
pub struct Region {
pub side: Side,
/// The volume of the material only one mesh has here, integrated along [`Region::lines`]
/// (mm³; NaN when it would take too long to measure).
pub volume: f64,
/// That volume's centre and box; the deviating surface's when the lines found none.
pub center: Vec3,
pub bbox: Aabb3,
/// The deviating surface of each mesh here (mm²).
pub area_a: f64,
pub area_b: f64,
/// The largest distance between the surfaces here (mm), and where.
pub max_deviation: f64,
pub at: Vec3,
/// The faces (the meshes' `tri_face` ids) the deviating surface lies on, each with its
/// deviating area here (mm²); [`Comparison::face_area`] has each face's whole area.
pub faces_a: Vec<(u32, f64)>,
pub faces_b: Vec<(u32, f64)>,
pub lines: Lines,
}
/// What [`compare`] found.
#[derive(Clone, Debug, Default)]
pub struct Comparison {
pub a_to_b: Deviation,
pub b_to_a: Deviation,
/// Largest volume first.
pub regions: Vec<Region>,
/// Regions found beyond [`CompareOptions::max_regions`] (not measured).
pub unlisted: usize,
/// The sample spacing used (mm).
pub spacing: f64,
/// The sampled area of each face of `a` and of `b`, by `tri_face` id (mm²).
pub face_area: [HashMap<u32, f64>; 2],
}
/// The point of triangle `abc` nearest `p` (Ericson, Real-Time Collision Detection, 5.1.5).
pub fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
let (ab, ac, ap) = (b - a, c - a, p - a);
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
if d1 <= 0.0 && d2 <= 0.0 {
return a;
}
let bp = p - b;
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
if d3 >= 0.0 && d4 <= d3 {
return b;
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
let den = d1 - d3;
return if den > 0.0 { a + ab * (d1 / den) } else { a };
}
let cp = p - c;
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
if d6 >= 0.0 && d5 <= d6 {
return c;
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
let den = d2 - d6;
return if den > 0.0 { a + ac * (d2 / den) } else { a };
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
let den = (d4 - d3) + (d5 - d6);
return if den > 0.0 { b + (c - b) * ((d4 - d3) / den) } else { b };
}
let den = va + vb + vc;
if den.abs() < 1e-300 {
return a;
}
a + ab * (vb / den) + ac * (vc / den)
}
fn comp(v: Vec3, k: usize) -> f64 {
match k {
0 => v.x,
1 => v.y,
_ => v.z,
}
}
/// The point with coordinate `ck` on axis `k`, and `cu`, `cv` on the next two axes in turn.
fn from_axes(k: usize, cu: f64, cv: f64, ck: f64) -> Vec3 {
match k {
0 => Vec3::new(ck, cu, cv),
1 => Vec3::new(cv, ck, cu),
_ => Vec3::new(cu, cv, ck),
}
}
fn box_of(t: &[Vec3; 3]) -> Aabb3 {
let mut b = Aabb3::EMPTY;
t.iter().for_each(|p| b.add(*p));
b
}
/// Squared distance from `p` to the box (0 inside it).
fn box_dist2(b: &Aabb3, p: Vec3) -> f64 {
(b.min - p).max(p - b.max).max(Vec3::ZERO).len2()
}
/// Do the boxes overlap on every axis but `skip` (none when `skip > 2`)?
fn boxes_meet(a: &Aabb3, b: &Aabb3, skip: usize) -> bool {
(0..3).filter(|&k| k != skip).all(|k| comp(a.min, k) <= comp(b.max, k) && comp(b.min, k) <= comp(a.max, k))
}
/// Triangles in a leaf of [`TriangleTree`], at most (unless they all share a centre).
const LEAF: usize = 4;
/// A bounding-volume hierarchy over a mesh's triangles: the nearest point of the surface, and
/// the triangles a line can cross.
pub struct TriangleTree {
/// The mesh's triangles with finite corners, in tree order.
tris: Vec<[Vec3; 3]>,
/// Each one's index in the mesh.
ids: Vec<u32>,
nodes: Vec<Node>,
}
#[derive(Clone, Copy)]
struct Node {
bbox: Aabb3,
/// A leaf holds `tris[a..b]`; an inner node has the children `a` and `b`.
a: u32,
b: u32,
leaf: bool,
}
impl TriangleTree {
pub fn new(mesh: &Mesh) -> TriangleTree {
let mut tris = Vec::with_capacity(mesh.triangles.len());
let mut ids = Vec::with_capacity(mesh.triangles.len());
for (i, t) in mesh.triangles.iter().enumerate() {
if let (Some(tri), Ok(i)) = (mesh.tri(t), u32::try_from(i))
&& tri.iter().all(|p| p.is_finite())
{
tris.push(tri);
ids.push(i);
}
}
let centres: Vec<Vec3> = tris.iter().map(|t| (t[0] + t[1] + t[2]) / 3.0).collect();
let mut order: Vec<u32> = (0..u32::try_from(tris.len()).unwrap_or(u32::MAX)).collect();
let n = order.len();
let empty = Node { bbox: Aabb3::EMPTY, a: 0, b: 0, leaf: true };
let mut nodes = vec![empty];
let mut work = vec![(0usize, 0usize, n)];
while let Some((ni, lo, hi)) = work.pop() {
let Some(range) = order.get_mut(lo..hi) else { continue };
let (mut bbox, mut cb) = (Aabb3::EMPTY, Aabb3::EMPTY);
for &i in range.iter() {
if let (Some(t), Some(c)) = (tris.get(i as usize), centres.get(i as usize)) {
bbox = bbox.union(&box_of(t));
cb.add(*c);
}
}
let size = cb.size();
let axis = if size.x >= size.y && size.x >= size.z {
0
} else if size.y >= size.z {
1
} else {
2
};
let node = if hi - lo <= LEAF || comp(size, axis) <= 0.0 {
Node { bbox, a: lo as u32, b: hi as u32, leaf: true }
} else {
let mid = (hi - lo) / 2;
range.select_nth_unstable_by(mid, |x, y| {
let c = |i: &u32| centres.get(*i as usize).map_or(0.0, |c| comp(*c, axis));
c(x).total_cmp(&c(y))
});
let (left, right) = (nodes.len(), nodes.len() + 1);
nodes.push(empty);
nodes.push(empty);
work.push((left, lo, lo + mid));
work.push((right, lo + mid, hi));
Node { bbox, a: left as u32, b: right as u32, leaf: false }
};
if let Some(slot) = nodes.get_mut(ni) {
*slot = node;
}
}
let tris_in_order = order.iter().filter_map(|&i| tris.get(i as usize).copied()).collect();
let ids_in_order = order.iter().filter_map(|&i| ids.get(i as usize).copied()).collect();
TriangleTree { tris: tris_in_order, ids: ids_in_order, nodes }
}
pub fn len(&self) -> usize {
self.tris.len()
}
pub fn is_empty(&self) -> bool {
self.tris.is_empty()
}
/// The distance from `p` to the nearest point of the surface, and the index in the mesh of
/// the triangle it lies on (`None` for an empty mesh or a point that isn't finite).
pub fn nearest(&self, p: Vec3) -> Option<(f64, u32)> {
if !p.is_finite() || self.tris.is_empty() {
return None;
}
let (mut best2, mut hit) = (f64::INFINITY, None);
let mut stack: Vec<u32> = Vec::with_capacity(64);
stack.push(0);
while let Some(ni) = stack.pop() {
let Some(node) = self.nodes.get(ni as usize) else { continue };
if box_dist2(&node.bbox, p) >= best2 {
continue;
}
if node.leaf {
for k in node.a..node.b {
let Some([a, b, c]) = self.tris.get(k as usize).copied() else { continue };
let d2 = (closest_on_triangle(p, a, b, c) - p).len2();
if d2 < best2 {
best2 = d2;
hit = self.ids.get(k as usize).copied();
}
}
continue;
}
let d = |i: u32| self.nodes.get(i as usize).map_or(f64::INFINITY, |n| box_dist2(&n.bbox, p));
let (da, db) = (d(node.a), d(node.b));
// The nearer child is searched first (pushed last).
let (near, far, dn, df) = if da <= db { (node.a, node.b, da, db) } else { (node.b, node.a, db, da) };
if df < best2 {
stack.push(far);
}
if dn < best2 {
stack.push(near);
}
}
hit.map(|i| (best2.sqrt(), i))
}
/// Every triangle (by its place in the tree) whose box overlaps `bx` on the two axes other
/// than `axis`: the triangles a line along `axis` through `bx` can cross.
fn column(&self, bx: &Aabb3, axis: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut stack: Vec<u32> = if self.tris.is_empty() { Vec::new() } else { vec![0] };
while let Some(ni) = stack.pop() {
let Some(node) = self.nodes.get(ni as usize) else { continue };
if !boxes_meet(&node.bbox, bx, axis) {
continue;
}
if node.leaf {
out.extend((node.a..node.b).map(|k| k as usize).filter(|&k| self.tris.get(k).is_some_and(|t| boxes_meet(&box_of(t), bx, axis))));
} else {
stack.push(node.a);
stack.push(node.b);
}
}
out
}
}
fn longest_edge(t: &[Vec3; 3]) -> f64 {
t[0].dist(t[1]).max(t[1].dist(t[2])).max(t[2].dist(t[0]))
}
/// Pieces one triangle is sampled in, at most.
const MAX_PIECES: usize = 1 << 22;
/// About how many pieces [`for_each_sample`] cuts the triangle into.
fn pieces_estimate(t: &[Vec3; 3], h: f64) -> f64 {
let l = longest_edge(t);
if !(l > h) {
return 1.0;
}
let area = (t[1] - t[0]).cross(t[2] - t[0]).len() * 0.5;
(4.0 * area / (h * h)).max(2.0 * l / h).clamp(1.0, MAX_PIECES as f64)
}
/// Calls `f` with every sample point of the triangle and the area it stands for. The triangle is
/// halved across its longest side until every piece's sides are at most `h` long; the centre of
/// each piece stands for the piece, its corners (which find what lies along the edges) for none.
fn for_each_sample(t: &[Vec3; 3], h: f64, stack: &mut Vec<[Vec3; 3]>, mut f: impl FnMut(Vec3, f64)) {
stack.clear();
stack.push(*t);
let mut pieces = 0usize;
while let Some([a, b, c]) = stack.pop() {
let (lab, lbc, lca) = (a.dist(b), b.dist(c), c.dist(a));
let l = lab.max(lbc).max(lca);
pieces += 1;
if !(l > h) || pieces + stack.len() >= MAX_PIECES {
for p in [a, b, c] {
f(p, 0.0);
}
f((a + b + c) / 3.0, (b - a).cross(c - a).len() * 0.5);
continue;
}
// Midpoints are computed from the same two corners on both sides of an inner edge, so
// the pieces either side share them exactly.
if lab >= lbc && lab >= lca {
let m = (a + b) * 0.5;
stack.push([a, m, c]);
stack.push([m, b, c]);
} else if lbc >= lca {
let m = (b + c) * 0.5;
stack.push([a, b, m]);
stack.push([a, m, c]);
} else {
let m = (c + a) * 0.5;
stack.push([a, b, m]);
stack.push([m, b, c]);
}
}
}
/// The spacing at which `mesh` needs about `max` sample points (at least `want`; a mesh whose
/// triangles alone need more gets one piece per triangle).
fn spacing_for(mesh: &Mesh, want: f64, max: usize) -> f64 {
let tris: Vec<[Vec3; 3]> = mesh.triangles.iter().filter_map(|t| mesh.tri(t)).filter(|t| t.iter().all(|p| p.is_finite())).collect();
// Every piece is sampled at its centre and its corners, each corner once: a triangulated
// surface has about half as many corners as triangles, so 1.5 samples a piece.
let fewest = 1.5 * tris.len() as f64;
let max = max.max(1) as f64;
let coarser = |h: f64, total: f64| h * (total / max).sqrt() * 1.02;
let mut h = if want.is_finite() && want > 0.0 { want } else { 1.0 };
// A spacing known to need too many, to close in on from above.
let mut over = None;
// First from the estimate, cheaply; then from the exact count, which the estimate can be
// a few times under (long thin triangles, the halving's steps).
for _ in 0..40 {
let total = tris.iter().map(|t| 1.5 * pieces_estimate(t, h)).sum::<f64>();
if total <= max || total <= fewest * 1.001 {
break;
}
over = Some(h);
h = coarser(h, total);
}
let mut stack = Vec::new();
let mut count = |h: f64| {
let mut pieces = 0usize;
for t in &tris {
for_each_sample(t, h, &mut stack, |_, w| pieces += usize::from(w > 0.0));
}
1.5 * pieces as f64
};
let mut total = count(h);
for _ in 0..12 {
if total <= max || total <= fewest * 1.001 {
break;
}
over = Some(h);
h = coarser(h, total);
total = count(h);
}
// The count falls in steps (each halving doubles a triangle's pieces), so the last step may
// have gone a step too far: the finest spacing within the budget lies between.
if let Some(mut lo) = over.filter(|_| total <= max) {
for _ in 0..10 {
let mid = (lo * h).sqrt();
if count(mid) <= max {
h = mid;
} else {
lo = mid;
}
}
}
h
}
/// Triangles one job samples.
const CHUNK: usize = 1024;
/// A sample further than the tolerance from the other surface.
#[derive(Clone, Copy)]
struct Off {
p: Vec3,
d: f64,
w: f64,
face: u32,
}
#[derive(Default)]
struct Part {
max: f64,
at: Vec3,
sum: f64,
weight: f64,
beyond: f64,
points: usize,
off: Vec<Off>,
face_area: HashMap<u32, f64>,
}
/// Runs `job` for every index below `n` through `each` and returns the results in order.
fn gather<T: Send + Sync>(n: usize, each: Each, job: impl Fn(usize) -> T + Sync) -> Vec<T> {
let slots: Vec<OnceLock<T>> = (0..n).map(|_| OnceLock::new()).collect();
each(n, &|i| {
if let Some(slot) = slots.get(i) {
let _ = slot.set(job(i));
}
});
slots.into_iter().enumerate().map(|(i, s)| s.into_inner().unwrap_or_else(|| job(i))).collect()
}
/// How far the samples of `from` lie from the surface in `to` (distances capped at `cap`).
fn deviate(from: &Mesh, to: &TriangleTree, h: f64, tol: f64, cap: f64, each: Each) -> Part {
let n = from.triangles.len();
let parts = gather(n.div_ceil(CHUNK), each, |k| {
let mut part = Part::default();
let mut seen: HashSet<[u64; 3]> = HashSet::new();
let mut stack = Vec::new();
for ti in k * CHUNK..((k + 1) * CHUNK).min(n) {
let Some(t) = from.triangles.get(ti).and_then(|t| from.tri(t)) else { continue };
if !t.iter().all(|p| p.is_finite()) {
continue;
}
let face = from.tri_face.get(ti).copied().unwrap_or(0);
for_each_sample(&t, h, &mut stack, |p, w| {
// A corner is shared by the pieces around it: measured once.
if w == 0.0 && !seen.insert([p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]) {
return;
}
let d = to.nearest(p).map_or(cap, |(d, _)| d.min(cap));
part.points += 1;
if w > 0.0 {
*part.face_area.entry(face).or_default() += w;
}
part.sum += w * d;
part.weight += w;
if d > part.max {
part.max = d;
part.at = p;
}
if d > tol {
part.beyond += w;
part.off.push(Off { p, d, w, face });
}
});
}
part
});
let mut all = Part::default();
for p in parts {
if p.max > all.max {
all.max = p.max;
all.at = p.at;
}
all.sum += p.sum;
all.weight += p.weight;
all.beyond += p.beyond;
all.points += p.points;
all.off.extend(p.off);
for (f, w) in p.face_area {
*all.face_area.entry(f).or_default() += w;
}
}
all
}
fn deviation(p: &Part) -> Deviation {
Deviation {
max: p.max,
at: p.at,
mean: if p.weight > 0.0 { p.sum / p.weight } else { 0.0 },
beyond_area: p.beyond,
area: p.weight,
points: p.points,
}
}
/// Deviating samples kept for the regions, at most (beyond it, every k-th, standing for k).
const MAX_OFF: usize = 4_000_000;
/// A deviating sample and the side of the material it bounds.
#[derive(Clone, Copy)]
struct Pt {
off: Off,
from_a: bool,
side: Side,
}
/// The deviating samples of `from` with their sides: outside the other mesh, they bound
/// material only `from` has; inside it, material only the other has.
fn sided(mut off: Vec<Off>, other: &Mesh, from_a: bool, each: Each) -> Vec<Pt> {
if off.len() > MAX_OFF {
let k = off.len().div_ceil(MAX_OFF);
off = off.into_iter().step_by(k).map(|o| Off { w: o.w * k as f64, ..o }).collect();
}
if off.is_empty() {
return Vec::new();
}
let inside = other.inside_index();
let off = &off;
let parts = gather(off.len().div_ceil(CHUNK), each, |k| {
off.iter()
.skip(k * CHUNK)
.take(CHUNK)
.map(|o| {
let within = inside.contains(o.p);
let side = if within == from_a { Side::OnlyInB } else { Side::OnlyInA };
Pt { off: *o, from_a, side }
})
.collect::<Vec<_>>()
});
parts.into_iter().flatten().collect()
}
fn find(parent: &mut [usize], mut i: usize) -> usize {
for _ in 0..parent.len() {
let Some(&p) = parent.get(i) else { return i };
if p == i {
return i;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = gp;
}
i
}
fn union(parent: &mut [usize], a: usize, b: usize) -> bool {
let (ra, rb) = (find(parent, a), find(parent, b));
if ra == rb {
return false;
}
if let Some(slot) = parent.get_mut(ra.max(rb)) {
*slot = ra.min(rb);
}
true
}
/// Groups of samples (indices into `pts`), one side each: samples in the same or neighbouring
/// cells of a grid of `cell` join, and so do groups of one side whose boxes, grown by `cell`,
/// overlap (so that no two regions measure the same material).
fn clusters(pts: &[Pt], cell: f64) -> Vec<Vec<usize>> {
let key = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
let mut cells: HashMap<(bool, i64, i64, i64), usize> = HashMap::new();
let mut of_point = Vec::with_capacity(pts.len());
for pt in pts {
let (x, y, z) = key(pt.off.p);
let next = cells.len();
of_point.push(*cells.entry((pt.side == Side::OnlyInA, x, y, z)).or_insert(next));
}
let mut parent: Vec<usize> = (0..cells.len()).collect();
for (&(s, x, y, z), &id) in &cells {
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(&o) = cells.get(&(s, x + dx, y + dy, z + dz)) {
union(&mut parent, id, o);
}
}
}
}
}
let groups_of = |parent: &mut [usize]| -> Vec<usize> { of_point.iter().map(|&c| find(parent, c)).collect() };
let grow = Vec3::new(cell, cell, cell);
// Join groups of one side whose grown boxes overlap, until none do (sort and sweep along X).
for _ in 0..16 {
let groups = groups_of(&mut parent);
let mut boxes: HashMap<usize, (Side, Aabb3)> = HashMap::new();
for (pt, g) in pts.iter().zip(&groups) {
boxes.entry(*g).or_insert((pt.side, Aabb3::EMPTY)).1.add(pt.off.p);
}
let mut list: Vec<(usize, Side, Aabb3)> =
boxes.into_iter().map(|(g, (s, b))| (g, s, Aabb3 { min: b.min - grow, max: b.max + grow })).collect();
list.sort_by(|a, b| a.2.min.x.total_cmp(&b.2.min.x).then(a.0.cmp(&b.0)));
let mut joined = false;
for (k, (ga, sa, ba)) in list.iter().enumerate() {
for (gb, sb, bb) in list.iter().skip(k + 1) {
if bb.min.x > ba.max.x {
break;
}
if sa == sb && boxes_meet(ba, bb, 3) {
joined |= union(&mut parent, *ga, *gb);
}
}
}
if !joined {
break;
}
}
let mut by: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, g) in groups_of(&mut parent).into_iter().enumerate() {
by.entry(g).or_default().push(i);
}
let mut out: Vec<Vec<usize>> = by.into_values().collect();
out.sort_by_key(|g| g.first().copied().unwrap_or(0));
out
}
/// Line and triangle pairs tested for one region, at most; beyond it, its volume is not measured.
const MAX_LINE_WORK: usize = 200_000_000;
/// Where the lines lie in their cells, across the axis (off the middle, so that lines through
/// the edges and corners of a mesh are rare).
const LINE_OFFSET: [f64; 2] = [0.513_72, 0.527_18];
/// The volume of the material only `side`'s mesh has inside `bx`, from lines along the box's
/// shortest axis: (volume, centre, box, lines), or `None` when that would take too much work.
fn region_volume(trees: [&TriangleTree; 2], bx: &Aabb3, side: Side, max_lines: usize) -> Option<(f64, Vec3, Aabb3, Lines)> {
let size = bx.size();
let k = if size.x <= size.y && size.x <= size.z {
0
} else if size.y <= size.z {
1
} else {
2
};
let (ku, kv) = ((k + 1) % 3, (k + 2) % 3);
let (eu, ev) = (comp(size, ku), comp(size, kv));
let max_lines = max_lines.max(1);
let ds = (eu * ev / max_lines as f64).sqrt();
let count = |e: f64| if ds > 0.0 && ds.is_finite() { (e / ds).ceil().clamp(1.0, max_lines as f64) as usize } else { 1 };
let (nu, nv) = (count(eu), count(ev));
let (du, dv) = (eu / nu as f64, ev / nv as f64);
let (u0, v0, t0, t1) = (comp(bx.min, ku), comp(bx.min, kv), comp(bx.min, k), comp(bx.max, k));
let line_u = |i: usize| u0 + (i as f64 + LINE_OFFSET[0]) * du;
let line_v = |j: usize| v0 + (j as f64 + LINE_OFFSET[1]) * dv;
// The lines whose positions fall in [lo, hi].
let span = |lo: f64, hi: f64, origin: f64, step: f64, offset: f64, n: usize| -> Option<(usize, usize)> {
if !(step > 0.0) {
return Some((0, 0));
}
let a = ((lo - origin) / step - offset).ceil().max(0.0);
let b = ((hi - origin) / step - offset).floor().min(n as f64 - 1.0);
(a <= b).then_some((a as usize, b as usize))
};
// Where each line crosses each mesh: (line, coordinate along the axis, +1 in / -1 out, mesh).
let mut hits: Vec<(u32, f64, i8, u8)> = Vec::new();
let mut work = 0usize;
for (m, tree) in trees.iter().enumerate() {
for ti in tree.column(bx, k) {
let Some(t) = tree.tris.get(ti) else { continue };
let nk = comp((t[1] - t[0]).cross(t[2] - t[0]), k);
if nk == 0.0 || !nk.is_finite() {
continue;
}
// Lines run towards +axis: they enter where the outward normal points against them.
let sign: i8 = if nk < 0.0 { 1 } else { -1 };
let tb = box_of(t);
let (Some((i0, i1)), Some((j0, j1))) = (
span(comp(tb.min, ku), comp(tb.max, ku), u0, du, LINE_OFFSET[0], nu),
span(comp(tb.min, kv), comp(tb.max, kv), v0, dv, LINE_OFFSET[1], nv),
) else {
continue;
};
work = work.saturating_add((i1 - i0 + 1).saturating_mul(j1 - j0 + 1));
if work > MAX_LINE_WORK {
return None;
}
for i in i0..=i1 {
let pu = line_u(i);
for j in j0..=j1 {
let pv = line_v(j);
// Watertight edge tests (Woop, Benthin & Wald, JCGT 2013) in the plane across
// the axis: two triangles get exactly opposite values on the edge they share.
let at = |p: Vec3| (comp(p, ku) - pu, comp(p, kv) - pv);
let ((ax, ay), (bx2, by), (cx, cy)) = (at(t[0]), at(t[1]), at(t[2]));
let u = cx * by - cy * bx2;
let v = ax * cy - ay * cx;
let w = bx2 * ay - by * ax;
if (u < 0.0 || v < 0.0 || w < 0.0) && (u > 0.0 || v > 0.0 || w > 0.0) {
continue;
}
let det = u + v + w;
if det == 0.0 {
continue;
}
let along = (u * comp(t[0], k) + v * comp(t[1], k) + w * comp(t[2], k)) / det;
if let (true, Ok(line)) = (along.is_finite(), u32::try_from(i * nv + j)) {
hits.push((line, along, sign, m as u8));
}
}
}
}
}
hits.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
let wanted = |w: [i32; 2]| match side {
Side::OnlyInA => w[0] > 0 && w[1] <= 0,
Side::OnlyInB => w[1] > 0 && w[0] <= 0,
};
let (mut length, mut moment, mut found, mut skipped) = (0.0, Vec3::ZERO, Aabb3::EMPTY, 0);
let mut pieces: Vec<(f64, f64)> = Vec::new();
let mut rest = hits.as_slice();
while let Some(first) = rest.first() {
let line = first.0;
let (this, after) = rest.split_at(rest.iter().take_while(|h| h.0 == line).count());
rest = after;
let (pu, pv) = (line_u(line as usize / nv), line_v(line as usize % nv));
let (mut w, mut last) = ([0i32; 2], [None::<(f64, i8)>; 2]);
let mut prev = f64::NEG_INFINITY;
pieces.clear();
for &(_, along, sign, m) in this {
let m = usize::from(m.min(1));
// A line through an edge or corner shared by several triangles crosses there once.
let eps = 1e-9 * (1.0 + along.abs());
if last.get(m).copied().flatten().is_some_and(|(t, s)| s == sign && (along - t).abs() <= eps) {
continue;
}
if wanted(w) {
let (s0, s1) = (prev.max(t0), along.min(t1));
// Where the two surfaces meet, rounding leaves pieces of no length between them.
if s1 - s0 > 1e-9 * (1.0 + s1.abs()) {
pieces.push((s0, s1));
}
}
if let Some(x) = w.get_mut(m) {
*x += i32::from(sign);
}
if let Some(x) = last.get_mut(m) {
*x = Some((along, sign));
}
prev = along;
}
// A line that leaves a mesh more or less often than it enters passed through a gap.
if w != [0, 0] {
skipped += 1;
continue;
}
for &(s0, s1) in &pieces {
let l = s1 - s0;
length += l;
moment += from_axes(k, pu, pv, (s0 + s1) * 0.5) * l;
found.add(from_axes(k, pu - du * 0.5, pv - dv * 0.5, s0));
found.add(from_axes(k, pu + du * 0.5, pv + dv * 0.5, s1));
}
}
let centre = if length > 0.0 { moment / length } else { bx.center() };
Some((length * du * dv, centre, found, Lines { axis: k, spacing: [du, dv], count: nu * nv, skipped }))
}
/// Compare the closed meshes `a` and `b` (in one frame): see the module documentation.
pub fn compare(a: &Mesh, b: &Mesh, o: &CompareOptions, each: Each) -> Comparison {
let tol = if o.tolerance.is_finite() { o.tolerance.max(0.0) } else { 0.01 };
let max_points = o.max_points.max(1);
let h = spacing_for(a, o.spacing, max_points).max(spacing_for(b, o.spacing, max_points));
let cap = a.bounds().union(&b.bounds()).diagonal().max(1.0);
let Ok([ta, tb]) = <[TriangleTree; 2]>::try_from(gather(2, each, |i| TriangleTree::new(if i == 0 { a } else { b }))) else {
return Comparison::default();
};
let mut pa = deviate(a, &tb, h, tol, cap, each);
let mut pb = deviate(b, &ta, h, tol, cap, each);
let (a_to_b, b_to_a) = (deviation(&pa), deviation(&pb));
let face_area = [std::mem::take(&mut pa.face_area), std::mem::take(&mut pb.face_area)];
let mut pts = sided(pa.off, b, true, each);
pts.extend(sided(pb.off, a, false, each));
// Samples on one surface are at most `h` apart, and the two surfaces at a region's rim come
// within the tolerance of each other: cells of that size keep a region together.
let cell = h.max(3.0 * tol).max(1e-6);
struct Found {
side: Side,
area: [f64; 2],
bbox: Aabb3,
max: f64,
at: Vec3,
faces: [Vec<(u32, f64)>; 2],
weight: f64,
}
let mut found: Vec<Found> = clusters(&pts, cell)
.iter()
.filter_map(|g| {
let first = pts.get(*g.first()?)?;
let mut f = Found {
side: first.side,
area: [0.0; 2],
bbox: Aabb3::EMPTY,
max: 0.0,
at: first.off.p,
faces: [Vec::new(), Vec::new()],
weight: 0.0,
};
let mut faces: [HashMap<u32, f64>; 2] = Default::default();
for pt in g.iter().filter_map(|&i| pts.get(i)) {
let m = usize::from(!pt.from_a);
if let Some(x) = f.area.get_mut(m) {
*x += pt.off.w;
}
if let Some(x) = faces.get_mut(m) {
*x.entry(pt.off.face).or_default() += pt.off.w;
}
f.bbox.add(pt.off.p);
f.weight += pt.off.w * pt.off.d;
if pt.off.d > f.max {
f.max = pt.off.d;
f.at = pt.off.p;
}
}
for (to, from) in f.faces.iter_mut().zip(faces) {
*to = from.into_iter().collect();
to.sort_unstable_by_key(|x| x.0);
}
Some(f)
})
.collect();
// Which to measure: largest first by the volume the surfaces suggest (area × distance).
found.sort_by(|x, y| y.weight.total_cmp(&x.weight).then(y.max.total_cmp(&x.max)));
let unlisted = found.len().saturating_sub(o.max_regions);
found.truncate(o.max_regions);
let grow = Vec3::new(cell, cell, cell);
let measured = gather(found.len(), each, |i| {
let f = found.get(i)?;
region_volume([&ta, &tb], &Aabb3 { min: f.bbox.min - grow, max: f.bbox.max + grow }, f.side, o.max_lines)
});
let mut regions: Vec<Region> = found
.into_iter()
.zip(measured)
.map(|(f, m)| {
let (volume, center, bbox, lines) = match m {
Some((v, c, b, l)) if v > 0.0 => (v, c, b, l),
Some((v, _, _, l)) => (v, f.bbox.center(), f.bbox, l),
None => (f64::NAN, f.bbox.center(), f.bbox, Lines::default()),
};
let [faces_a, faces_b] = f.faces;
Region {
side: f.side,
volume,
center,
bbox,
area_a: f.area[0],
area_b: f.area[1],
max_deviation: f.max,
at: f.at,
faces_a,
faces_b,
lines,
}
})
.collect();
let key = |r: &Region| if r.volume.is_finite() { r.volume } else { -1.0 };
regions.sort_by(|x, y| key(y).total_cmp(&key(x)).then(y.max_deviation.total_cmp(&x.max_deviation)));
Comparison { a_to_b, b_to_a, regions, unlisted, spacing: h, face_area }
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+118
View File
@@ -0,0 +1,118 @@
use super::*;
/// An axis-aligned box as a closed mesh (each side two triangles, counter-clockwise from outside).
fn cuboid(lo: Vec3, hi: Vec3) -> Mesh {
let v = |x: bool, y: bool, z: bool| Vec3::new(if x { hi.x } else { lo.x }, if y { hi.y } else { lo.y }, if z { hi.z } else { lo.z });
let quads = [
[v(false, false, false), v(false, true, false), v(true, true, false), v(true, false, false)],
[v(false, false, true), v(true, false, true), v(true, true, true), v(false, true, true)],
[v(false, false, false), v(true, false, false), v(true, false, true), v(false, false, true)],
[v(false, true, false), v(false, true, true), v(true, true, true), v(true, true, false)],
[v(false, false, false), v(false, false, true), v(false, true, true), v(false, true, false)],
[v(true, false, false), v(true, true, false), v(true, true, true), v(true, false, true)],
];
let mut m = Mesh::default();
for (f, q) in quads.iter().enumerate() {
let base = m.positions.len() as u32;
m.positions.extend_from_slice(q);
m.normals.extend_from_slice(&[Vec3::Z; 4]);
m.triangles.push([base, base + 1, base + 2]);
m.triangles.push([base, base + 2, base + 3]);
m.tri_face.extend([f as u32, f as u32]);
}
m
}
#[test]
fn the_tree_finds_the_nearest_point_as_a_search_of_every_triangle_does() {
// A crumpled sheet of triangles from a fixed pseudo-random sequence.
let mut seed = 12345u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let mut m = Mesh::default();
for _ in 0..500 {
let c = Vec3::new(rnd() * 100.0, rnd() * 50.0, rnd() * 20.0);
let base = m.positions.len() as u32;
for _ in 0..3 {
m.positions.push(c + Vec3::new(rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0, rnd() * 6.0 - 3.0));
}
m.triangles.push([base, base + 1, base + 2]);
}
let tree = TriangleTree::new(&m);
assert_eq!(tree.len(), 500);
for _ in 0..300 {
let p = Vec3::new(rnd() * 140.0 - 20.0, rnd() * 90.0 - 20.0, rnd() * 60.0 - 20.0);
let brute =
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| closest_on_triangle(p, a, b, c).dist(p)).fold(f64::INFINITY, f64::min);
let (d, i) = tree.nearest(p).unwrap();
assert!((d - brute).abs() < 1e-12, "{d} vs {brute}");
let [a, b, c] = m.tri(&m.triangles[i as usize]).unwrap();
assert!((closest_on_triangle(p, a, b, c).dist(p) - d).abs() < 1e-12);
}
assert!(TriangleTree::new(&Mesh::default()).nearest(Vec3::ZERO).is_none());
}
#[test]
fn a_mesh_and_its_copy_are_identical() {
let a = cuboid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0));
let c = compare(&a, &a.clone(), &CompareOptions::default(), &serial);
assert!(c.regions.is_empty(), "{:?}", c.regions);
// The same triangles: nothing but rounding apart.
assert!(c.a_to_b.max < 1e-9 && c.b_to_a.max < 1e-9, "{} {}", c.a_to_b.max, c.b_to_a.max);
// Sampled at the requested spacing: the whole surface, and nothing beyond the tolerance.
assert!((c.a_to_b.area - 2.0 * (1200.0 + 400.0 + 300.0)).abs() < 1e-6, "{}", c.a_to_b.area);
assert_eq!(c.a_to_b.beyond_area, 0.0);
assert_eq!(c.spacing, 0.5);
}
#[test]
fn a_box_moved_up_has_material_only_it_has_on_top_and_none_below() {
// b is a moved 0.3 up: a slab 40 x 30 x 0.3 of each side.
let a = cuboid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0));
let b = cuboid(Vec3::new(0.0, 0.0, 0.3), Vec3::new(40.0, 30.0, 10.3));
let c = compare(&a, &b, &CompareOptions::default(), &serial);
assert_eq!(c.regions.len(), 2, "{:?}", c.regions);
assert!((c.a_to_b.max - 0.3).abs() < 1e-9 && (c.b_to_a.max - 0.3).abs() < 1e-9);
for r in &c.regions {
// Integrated along lines 0.18 mm apart: the slab's rim falls between them.
assert!((r.volume - 360.0).abs() < 360.0 * 0.01, "{r:?}");
assert_eq!(r.lines.axis, 2);
assert_eq!(r.lines.skipped, 0);
let z = if r.side == Side::OnlyInA { 0.15 } else { 10.15 };
assert!(r.center.dist(Vec3::new(20.0, 15.0, z)) < 0.1, "{r:?}");
assert!((r.bbox.min.z - (z - 0.15)).abs() < 1e-9 && (r.bbox.max.z - (z + 0.15)).abs() < 1e-9, "{r:?}");
// Each slab is bounded by the bottom (top) of both meshes, and by the 0.3 mm of the sides
// of the one that reaches further, less what lies within the tolerance of the other.
let (outer, inner) = if r.side == Side::OnlyInA { (r.area_a, r.area_b) } else { (r.area_b, r.area_a) };
assert!((outer - (1200.0 + 140.0 * 0.29)).abs() < 2.0 && (inner - 39.98 * 29.98).abs() < 2.0, "{r:?}");
}
let sides: Vec<Side> = c.regions.iter().map(|r| r.side).collect();
assert!(sides.contains(&Side::OnlyInA) && sides.contains(&Side::OnlyInB));
}
#[test]
fn a_box_inside_another_is_material_only_the_outer_one_has() {
// No surface of one is near the other's: one region, the shell between them.
let a = cuboid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0));
let b = cuboid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0));
let c = compare(&a, &b, &CompareOptions::default(), &serial);
assert_eq!(c.regions.len(), 1, "{:?}", c.regions);
let r = &c.regions[0];
assert_eq!(r.side, Side::OnlyInA);
assert!((r.volume - 7000.0).abs() < 7000.0 * 0.01, "{}", r.volume);
assert!(r.center.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-2, "{:?}", r.center);
assert_eq!(r.faces_a.len(), 6);
assert_eq!(r.faces_b.len(), 6);
}
#[test]
fn a_tight_budget_coarsens_the_spacing() {
let a = cuboid(Vec3::ZERO, Vec3::new(400.0, 300.0, 100.0));
let o = CompareOptions { max_points: 20_000, ..CompareOptions::default() };
let c = compare(&a, &a, &o, &serial);
assert!(c.spacing > 0.5, "{}", c.spacing);
assert!((5_000..=24_000).contains(&c.a_to_b.points), "{}", c.a_to_b.points);
assert!(c.regions.is_empty());
}
+1
View File
@@ -11,6 +11,7 @@
mod arcfit;
pub mod brep;
pub mod bspline;
pub mod compare;
pub mod env;
pub mod fill;
mod mesh;
+38 -1
View File
@@ -10,7 +10,7 @@ it.
| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect; thin walls along curves), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror |
| Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove |
| Construct | offset plane, plane at angle, plane along path |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
| Inspect | measure, section analysis, interference, compare bodies, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
Extrude automatically selects **Join** for additions when the active component already has a
solid, including sideways elbow connections and screw bosses that meet only part of a face.
@@ -117,6 +117,43 @@ local segment. Preview does not change the design; OK creates one editable timel
The plane follows later path edits. Curved paths use their sampled polyline; absolute distance,
reference-object placement and multi-curve chains are not yet available in this dialog.
## Comparing two bodies
**Compare Bodies** (`inspect.compare`) lays two versions of one part over each other, such as a
part and the same part exported by another program, and finds the places where only one of them
has material. It works from the two surfaces, not from a boolean of the bodies, so it also
compares bodies that share almost every face, the case a boolean (Combine, Interference) handles
worst.
```json
{"command": "inspect.compare", "params": {"a": "Bracket", "b": "Bracket (2)", "tolerance": 0.01, "color": true}}
```
Without `a` and `b` it compares the two selected bodies, or the design's only two. Both bodies
are measured (volume, area, centre of mass and box, and `difference`: b − a of each). Each
surface is sampled at the corners of its triangles and at points at most `spacing` (0.5 mm)
apart on larger faces, and every sample measures its distance to the other surface: `deviation`
gives the largest and the mean distance each way. Samples further than `tolerance` (0.01 mm) form
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
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.
That is one undo step; without `color` the command changes nothing.
The bodies are meshed within a quarter of the tolerance. The kernel meshes no finer than a
millionth of a body's size, so keep the tolerance at least twice that (0.02 mm for a part 10 m
across). Large bodies are sampled more coarsely, at about `max_points` (eight million) points
each; `sampling.spacing_mm` says what was used, and a larger `max_points` (up to 50 million)
samples them finer, at the cost of time.
## Editing a feature from a script
For `solid.pattern.circular`, `axis` accepts `X`, `Y`, `Z`, or `{origin, dir}`.
+2 -1
View File
@@ -298,7 +298,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Add Recorded Feature | `timeline.add` | – |
| Export Command Script | `timeline.export_script` | – |
## Inspect (12, 1 planned)
## Inspect (13, 1 planned)
| Command | Id | Toolbar |
|---|---|---|
@@ -314,6 +314,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Continuity Analysis *(planned: [#770](https://github.com/bherbruck/solidcraft/issues/770))* | `inspect.continuity` | SURFACE › INSPECT |
| Interference | `inspect.interference` | SOLID › INSPECT |
| Measure (I) | `inspect.measure` | SOLID › INSPECT |
| Compare Bodies | `inspect.compare` | – |
| Section Analysis | `inspect.section` | SOLID › INSPECT |
## Files (8)