Booleans: a tool wholly inside the target leaves a void (an inner shell); results are oriented outward from a mesh fine enough for every face (a sphere clipping a box's corner came out inside out); as a last resort the tool is nudged by a micron-scale screw motion (tangencies, seams on the other body); a boolean survey test over random boxes, cylinders and spheres

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 10:04:09 +00:00
1 parent 81c85b553c
commit 717b181082
3 files changed
+144 -3

No files matched your search

+14 -2
View File
@@ -255,8 +255,20 @@ impl Body {
return Err(KernelError::Failed("empty result".into()));
}
let mut solid = rephase_revolved(solid);
// Orient outward: a closed solid must have positive volume.
let v = guard("orient", || Ok(mesh_shells(&solid, 1.0).iter().map(|s| s.to_polygon().volume()).sum::<f64>()))?;
// Orient outward: a closed solid must have positive volume (meshed finely enough that
// every face comes out: a face missing from a coarse mesh can flip the sign).
let span = {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = -lo;
for v in solid.vertex_iter() {
let p = from_p3(v.point());
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
};
let tol = (span * 0.01).clamp(1e-3, 1.0);
let v = guard("orient", || Ok(mesh_shells(&solid, tol).iter().map(|s| s.to_polygon().volume()).sum::<f64>()))?;
if v < 0.0 {
solid.not();
}
+57 -1
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, p3, v3};
use crate::body::{Body, from_p3, p3, v3};
use crate::{KernelError, Result, guard};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -110,10 +110,46 @@ fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solv
BoolOp::Intersect if a_in_b > ALL => Some(Some(a.clone())),
BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())),
BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())),
// The tool wholly inside, clear of the outside: a void.
BoolOp::Cut if b_in_a > ALL && clear_inside(ma, mb, a.size().max(b.size()) * 1e-6) => void(a, b).ok(),
_ => None,
}
}
/// Does every vertex of `inner` lie inside `outer` and farther than `gap` from its surface?
fn clear_inside(outer: &solvecraft_geom::Mesh, inner: &solvecraft_geom::Mesh, gap: f64) -> bool {
if outer.triangles.len() * inner.positions.len() > 40_000_000 {
return false;
}
let tris: Vec<[Vec3; 3]> = outer.triangles.iter().filter_map(|t| outer.tri(t)).collect();
inner.positions.iter().all(|p| {
outer.contains(*p)
&& tris.iter().all(|[a, b, c]| {
// Distance to the triangle's plane bounds the distance to the triangle from below.
let n = (*b - *a).cross(*c - *a);
let Some(nn) = n.normalized() else { return true };
let h = (*p - *a).dot(nn).abs();
h > gap || {
let q = *p - nn * (*p - *a).dot(nn);
let inside = [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= 0.0);
!inside && [(*a, *b), (*b, *c), (*c, *a)].iter().all(|(u, v)| p.dist_to_segment(*u, *v) > gap)
}
})
})
}
/// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell.
fn void(a: &Body, b: &Body) -> Result<Option<Body>> {
guard("void", || {
let mut inner = b.deep_copy();
inner.not();
let mut shells = a.solid.boundaries().clone();
shells.extend(inner.boundaries().iter().cloned());
let s = crate::body::Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("void: {e}")))?;
Ok(Some(Body::new(s)?))
})
}
thread_local! {
/// Set while a boolean runs on bodies whose coincident faces were pushed apart (no
/// second round of pushing).
@@ -267,6 +303,26 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Ok(None) => return Ok(None),
Err(e) => last = format!("{last}; {e}"),
}
// Last resort: nudge the tool by a micron-scale screw motion (turned 2e-6 rad about a
// skew axis and moved 2e-6 of the size), which breaks tangencies and seams lying exactly on
// the other body. The result moves by no more than that.
if !APART.with(|c| c.get()) {
APART.with(|c| c.set(true));
let c = b.solid.vertex_iter().fold(Vec3::ZERO, |acc, v| acc + from_p3(v.point())) * (1.0 / b.solid.vertex_iter().count().max(1) as f64);
let r = transform(b, Vec3::new(0.61, -0.37, 0.71) * (size * 2e-6), c, Vec3::new(0.27, 0.83, -0.49), 2e-6).and_then(|nb| boolean(a, &nb, op));
APART.with(|c| c.set(false));
match r {
Ok(Some(body)) => {
let v = volume(&body);
if v > 0.0 && plausible(v, &body) {
return Ok(Some(body));
}
last = format!("{last}; the nudged tool gave an implausible volume {v:.4}");
}
Ok(None) => return Ok(None),
Err(e) => last = format!("{last}; nudged: {e}"),
}
}
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
}
+73
View File
@@ -1102,3 +1102,76 @@ fn fillet_an_open_curved_edge() {
let want = r * r * (1.0 - PI / 4.0) * (10.0 - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))) * PI;
assert!(rel(removed, want) < 1e-3, "{removed} vs {want}");
}
/// Boolean survey: random boxes, cylinders and spheres combined every way; prints what fails.
#[test]
#[ignore]
fn boolean_survey() {
let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
let mut rnd = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1_000_000) as f64 / 1_000_000.0
};
let mut shapes: Vec<(String, Body)> = Vec::new();
for i in 0..40 {
let kind = i % 4;
let (x, y, z) = ((rnd() - 0.5) * 20.0, (rnd() - 0.5) * 20.0, (rnd() - 0.5) * 20.0);
// Snap some coordinates to a 5 mm grid: coincident faces and tangencies.
let snap = |v: f64, s: bool| if s { (v / 5.0).round() * 5.0 } else { v };
let s = rnd() < 0.5;
let (x, y, z) = (snap(x, s), snap(y, s), snap(z, s));
let a = 5.0 + snap(rnd() * 20.0, s);
let ax = (rnd() * 3.0) as usize % 3;
let b = match kind {
0 => box_solid(Vec3::new(x, y, z), Vec3::new(x + a, y + a * 0.8, z + a * 0.6)),
1 => cylinder(Vec3::new(x, y, z), [Vec3::X, Vec3::Y, Vec3::Z][ax], a * 0.4, a),
2 => sphere(Vec3::new(x, y, z), a * 0.5),
_ => box_solid(Vec3::new(x, y, z), Vec3::new(x + a * 0.5, y + a, z + a * 0.3)),
};
if let Ok(b) = b {
shapes.push((format!("{}{i}@({x:.3},{y:.3},{z:.3}) a={a:.3} ax={ax}", ["box", "cyl", "sph", "slab"][kind]), b));
}
}
let (mut ok, mut empty, mut fail) = (0, 0, Vec::new());
for i in 0..shapes.len() {
for j in i + 1..(i + 4).min(shapes.len()) {
for op in [BoolOp::Union, BoolOp::Cut, BoolOp::Intersect] {
let (na, a) = &shapes[i];
let (nb, b) = &shapes[j];
match boolean(a, b, op) {
Ok(Some(_)) => ok += 1,
Ok(None) => empty += 1,
Err(e) => fail.push(format!("{na} {op:?} {nb}: {}", e.to_string().chars().take(140).collect::<String>())),
}
}
}
}
println!("ok {ok} empty {empty} failed {}", fail.len());
for f in &fail {
println!(" {f}");
}
}
#[test]
fn booleans_found_by_the_survey() {
// A tool wholly inside: a void (the solid keeps an inner shell).
let sp = sphere(Vec3::new(-2.894, 7.008, 8.081), 8.869).unwrap();
let slab = box_solid(Vec3::new(-5.0, 0.0, 5.0), Vec3::new(-2.5, 5.0, 6.5)).unwrap();
let c = boolean(&sp, &slab, BoolOp::Cut).unwrap().unwrap();
let want = 4.0 / 3.0 * PI * 8.869f64.powi(3) - 18.75;
assert!(rel(measure(&c).unwrap().volume, want) < 1e-3, "{}", measure(&c).unwrap().volume);
// A sphere clipping a box's corner: the result comes out outward-facing.
let sp = sphere(Vec3::new(-8.226, 9.539, -1.251), 11.8435).unwrap();
let bx = box_solid(Vec3::new(0.0, -5.0, -5.0), Vec3::new(15.0, 7.0, 4.0)).unwrap();
let c = boolean(&bx, &sp, BoolOp::Cut).unwrap().unwrap();
let v = measure(&c).unwrap().volume;
assert!(v > 1400.0 && v < 1620.0, "{v}");
// A cylinder and a box sharing an end plane, the box's side near-tangent to the cylinder.
let cy = cylinder(Vec3::new(-5.0, 0.0, 0.0), Vec3::X, 8.0, 20.0).unwrap();
let bx = box_solid(Vec3::new(0.0, -5.0, -5.0), Vec3::new(15.0, 7.0, 4.0)).unwrap();
let u = boolean(&cy, &bx, BoolOp::Union).unwrap().unwrap();
let vu = measure(&u).unwrap().volume;
assert!(vu > PI * 64.0 * 20.0 && vu < PI * 64.0 * 20.0 + 1620.0, "{vu}");
}