Files
OneCAD/crates/geom/src/compare.rs
T
NOWN-DEV d0a4443939 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.
2026-10-15 12:45:43 -04:00

928 lines
35 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.
//! 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;