fix(kernel): a small overlap is not taken for bodies apart or inside (#484)

When sample points say two bodies are apart or one is inside the other, the facetings are now checked for surfaces passing through each other; if they do, the boolean is computed instead of shortcut. If it still can't be intersected, the sampled answer is kept as a flagged, unverified result, and Interference lists such pairs as "could not be checked" with the reason. Fixes #442 and #443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Scott BowdenandClaude Opus 5.5 authored and GitHub committed 2026-10-13 00:13:16 -04:00
1 parent 340ac0313a
commit 3159fc0dd0
8 files changed
+406 -25

No files matched your search

+13
View File
@@ -17,6 +17,9 @@ pub enum BooleanStrategy {
PlanarFallback,
ToolNudge,
Piecewise,
/// No retry gave a result, and the answer from sample points was kept although the bodies'
/// facetings disagree with it.
UnverifiedShortcut,
}
/// Numerical adjustments used by a successful retry, in millimetres/radians.
@@ -47,6 +50,10 @@ impl BooleanFallback {
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
BooleanStrategy::UnverifiedShortcut => format!(
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
self.tolerance_mm
),
}
}
}
@@ -66,6 +73,12 @@ impl BooleanDiagnostics {
}
}
/// Why the result is not verified, when it ignores a place where the bodies seem to pass
/// through each other ([`BooleanStrategy::UnverifiedShortcut`]).
pub fn unverified(&self) -> Option<String> {
self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description)
}
pub fn warning(&self) -> Option<String> {
if self.events.is_empty() && !self.truncated {
return None;
+115 -16
View File
@@ -89,11 +89,12 @@ fn mismatch(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh, result: &solid
/// How one body sits relative to another, from sample points: (fraction of `b`'s samples
/// inside `a`, fraction of `a`'s samples inside `b`).
fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64, f64)> {
if a.triangles.len() + b.triangles.len() > 80_000 {
return None;
}
let (ia, ib) = (a.inside_index(), b.inside_index());
fn overlap(
a: &solidcraft_geom::Mesh,
b: &solidcraft_geom::Mesh,
ia: &solidcraft_geom::InsideIndex,
ib: &solidcraft_geom::InsideIndex,
) -> Option<(f64, f64)> {
let frac = |inner: &solidcraft_geom::Mesh, inner_i: &solidcraft_geom::InsideIndex, outer_i: &solidcraft_geom::InsideIndex| -> Option<f64> {
let bx = inner.bounds();
let s = bx.size();
@@ -110,12 +111,59 @@ fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64,
}
(n >= 50).then(|| hit as f64 / n as f64)
};
Some((frac(b, &ib, &ia)?, frac(a, &ia, &ib)?))
Some((frac(b, ib, ia)?, frac(a, ia, ib)?))
}
/// Whether some facet of `m` has material just under it (`depth` in from its middle) that is
/// inside the other body (`inside`) or outside it. With `within`, only facets in that box.
fn under_surface(
m: &solidcraft_geom::Mesh,
own: &solidcraft_geom::InsideIndex,
other: &solidcraft_geom::InsideIndex,
depth: f64,
inside: bool,
within: Option<&solidcraft_geom::Aabb3>,
) -> bool {
m.triangles.iter().filter_map(|t| m.tri(t)).any(|[p, q, r]| {
let Some(n) = (q - p).cross(r - p).normalized() else { return false };
let c = (p + q + r) * (1.0 / 3.0) - n * depth;
let near = within.is_none_or(|b| c.x >= b.min.x && c.x <= b.max.x && c.y >= b.min.y && c.y <= b.max.y && c.z >= b.min.z && c.z <= b.max.z);
// A wall thinner than `depth` puts the point outside its own body: no evidence.
near && other.contains(c) == inside && own.contains(c)
})
}
/// Whether the bodies' facetings show that the samples' answer (see [`trivial`]) is wrong:
/// their surfaces pass through each other, or a facet of one has material under it on the
/// wrong side of the other. Overlaps and gaps shallower than `tol` are not seen.
fn contradicted(
(ma, ia): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex),
(mb, ib): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex),
(disjoint, b_in_a, a_in_b): (bool, bool, bool),
tol: f64,
) -> bool {
if !(disjoint || b_in_a || a_in_b) {
return false;
}
if ma.crosses(mb, tol) {
return true;
}
if disjoint {
let (ba, bb) = (ma.bounds(), mb.bounds());
let shared = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
return !shared.is_empty() && (under_surface(ma, ia, ib, tol, true, Some(&shared)) || under_surface(mb, ib, ia, tol, true, Some(&shared)));
}
(a_in_b && under_surface(ma, ia, ib, tol, false, None)) || (b_in_a && !a_in_b && under_surface(mb, ib, ia, tol, false, None))
}
/// Booleans that need no intersection: one body clear of the other, or inside it. These are
/// common with coincident curved faces (a hole drilled where one already is, a body joined
/// with itself), which the intersection can't handle. `counts`: the operands' faces and edges.
/// `tol`: how far the meshes' surfaces may pass through each other and still count as touching
/// (they are facetings of the bodies, so a few times their tolerance).
///
/// The second value is false when the facetings contradict the samples ([`contradicted`]):
/// the answer is then only what to fall back on if the bodies can't be intersected.
fn trivial(
a: &Body,
b: &Body,
@@ -123,12 +171,21 @@ fn trivial(
ma: &solidcraft_geom::Mesh,
mb: &solidcraft_geom::Mesh,
counts: &[(usize, usize)],
) -> Option<Option<Body>> {
let (b_in_a, a_in_b) = overlap(ma, mb)?;
tol: f64,
) -> Option<(Option<Body>, bool)> {
if ma.triangles.len() + mb.triangles.len() > 80_000 {
return None;
}
let (ia, ib) = (ma.inside_index(), mb.inside_index());
let (b_in_a, a_in_b) = overlap(ma, mb, &ia, &ib)?;
const NONE: f64 = 0.003;
const ALL: f64 = 0.997;
let disjoint = b_in_a < NONE && a_in_b < NONE;
match op {
// The samples only say that nearly all of one body is clear of, or inside, the other. A
// small part can still be on the wrong side (a peg overlapping a rib, a chamfer taken off
// one edge), and then the bodies have to be intersected.
let sure = !contradicted((ma, &ia), (mb, &ib), (disjoint, b_in_a > ALL, a_in_b > ALL), tol);
let answer = match op {
// Sampling only selects this candidate. Validate every shell boundary and the
// complete material/void nesting before preserving a disconnected union.
BoolOp::Union if disjoint => {
@@ -151,7 +208,8 @@ fn trivial(
Some(Some(candidate))
}
_ => None,
}
};
answer.map(|r| (r, sure))
}
#[cfg(test)]
@@ -167,13 +225,40 @@ mod shortcut_tests {
let other = crate::sphere(direction * distance, 10.0).unwrap();
let other_mesh = other.tessellate(0.03).unwrap();
let counts = record::counts(&[&outer, &other]);
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts).is_none());
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts).is_none());
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts, 0.09).is_none());
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts, 0.09).is_none());
}
let protruding = crate::sphere(direction * 9.001, 1.0).unwrap();
let protruding_mesh = protruding.tessellate(0.03).unwrap();
let counts = record::counts(&[&outer, &protruding]);
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts).is_none());
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts, 0.09).is_none());
}
/// Bodies sharing less than the samples can see still meet: no shortcut, and the boolean
/// gives the shared part.
#[test]
fn a_small_overlap_is_not_taken_for_apart_or_inside() {
let cube = |x: f64, y: f64| crate::box_solid(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0)).unwrap();
let a = cube(0.0, 0.0);
// A 0.01 slab between parallel faces (0.1 % of each cube), and a 0.02 x 0.02 corner.
for (b, want) in [(cube(9.99, 0.0), 1.0), (cube(9.98, 9.98), 0.004)] {
let (ma, mb) = (a.tessellate(0.001).unwrap(), b.tessellate(0.001).unwrap());
let counts = record::counts(&[&a, &b]);
for op in [BoolOp::Intersect, BoolOp::Cut, BoolOp::Union] {
assert!(trivial(&a, &b, op, &ma, &mb, &counts, 0.003).is_none_or(|(_, sure)| !sure), "{op:?} {want}");
}
let shared = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap();
assert!((crate::measure(&shared).unwrap().volume - want).abs() < 1e-9, "{want}");
let cut = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
assert!((crate::measure(&cut).unwrap().volume - (1000.0 - want)).abs() < 1e-9, "{want}");
}
// Touching on a face, and one cube well inside the other, still take the shortcut.
let (touching, inner) = (cube(10.0, 0.0), crate::box_solid(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0)).unwrap());
let ma = a.tessellate(0.001).unwrap();
for (b, op) in [(&touching, BoolOp::Intersect), (&inner, BoolOp::Intersect), (&inner, BoolOp::Union)] {
let counts = record::counts(&[&a, b]);
assert!(trivial(&a, b, op, &ma, &b.tessellate(0.001).unwrap(), &counts, 0.003).is_some_and(|(_, sure)| sure), "{op:?}");
}
}
}
@@ -338,10 +423,16 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
(consistent, v)
};
// One body clear of or inside the other: no intersection needed.
// Kept for the end when the facetings disagree with it: bodies that touch along curved
// faces can pass through each other slightly, and often can't be intersected.
let mut doubted = None;
if let (Some(ma), Some(mb)) = &meshes
&& let Some(r) = trivial(a, b, op, ma, mb, &counts)
&& let Some((r, sure)) = trivial(a, b, op, ma, mb, &counts, 3.0 * tol_m)
{
return Ok(r);
if sure {
return Ok(r);
}
doubted = Some(r);
}
let mut last = String::new();
// Coincident faces make the intersection fail after many retries; push them apart first.
@@ -522,7 +613,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Err(e) => last = format!("{last}; nudged: {e}"),
}
}
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
match doubted {
Some(r) => {
let mut event = BooleanFallback::new(BooleanStrategy::UnverifiedShortcut);
event.tolerance_mm = 3.0 * tol_m;
record(event);
Ok(r)
}
None => Err(KernelError::Failed(format!("boolean {op:?}: {last}"))),
}
}
/// `r`, a boolean of the target and `nb` (the tool nudged), with its history over the target and