Booleans about twice as fast: point-in-mesh tests through a per-direction grid (sampling checks), one mesh of the result for both its volume and membership checks, operand volumes from their meshes, and the intersection tried at the coarser tolerance first (oracle run 10 → 5 minutes); free-form faces merge in the topology counts when their normals agree within 2.5°

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 10:46:13 +00:00
1 parent 940c425316
commit b2d611bafe
4 files changed
+164 -33

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ mod plane;
mod profile; mod profile;
mod vec; mod vec;
pub use mesh::{Aabb3, Mesh, MeshMeasure}; pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
pub use plane::Plane; pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier}; pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier};
pub use vec::{Vec2, Vec3}; pub use vec::{Vec2, Vec3};
+119
View File
@@ -227,3 +227,122 @@ impl Mesh {
best best
} }
} }
/// Point-in-mesh queries in bulk: for each of `Mesh::contains`'s three ray directions, the
/// triangles binned on a grid in the plane square to it, so a ray tests only the triangles
/// whose shadow covers its cell. Answers exactly as `Mesh::contains` does.
pub struct InsideIndex<'a> {
mesh: &'a Mesh,
rays: Vec<RayGrid>,
}
struct RayGrid {
dir: Vec3,
u: Vec3,
v: Vec3,
min: (f64, f64),
cell: f64,
n: (usize, usize),
cells: Vec<Vec<u32>>,
}
const RAY_DIRS: [Vec3; 3] = [Vec3::new(0.5773, 0.5774, 0.5776), Vec3::new(-0.6123, 0.3141, 0.7254), Vec3::new(0.2718, -0.8414, 0.4673)];
impl Mesh {
/// An index for many `contains` queries on this mesh.
pub fn inside_index(&self) -> InsideIndex<'_> {
let rays = RAY_DIRS
.iter()
.map(|d| {
let u = d.any_perp();
let v = d.cross(u);
let tris: Vec<[(f64, f64); 3]> = self
.triangles
.iter()
.map(|t| match self.tri(t) {
Some([a, b, c]) => [(a.dot(u), a.dot(v)), (b.dot(u), b.dot(v)), (c.dot(u), c.dot(v))],
None => [(0.0, 0.0); 3],
})
.collect();
let (mut lo, mut hi) = ((f64::INFINITY, f64::INFINITY), (f64::NEG_INFINITY, f64::NEG_INFINITY));
for t in &tris {
for p in t {
lo = (lo.0.min(p.0), lo.1.min(p.1));
hi = (hi.0.max(p.0), hi.1.max(p.1));
}
}
let span = (hi.0 - lo.0).max(hi.1 - lo.1).max(1e-9);
let side = ((tris.len() as f64).sqrt().ceil() as usize).clamp(1, 256);
let cell = span / side as f64 * (1.0 + 1e-9);
let n = (side + 1, side + 1);
let mut cells = vec![Vec::new(); n.0 * n.1];
if lo.0.is_finite() {
for (i, t) in tris.iter().enumerate() {
let (x0, x1) = (t.iter().map(|p| p.0).fold(f64::INFINITY, f64::min), t.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max));
let (y0, y1) = (t.iter().map(|p| p.1).fold(f64::INFINITY, f64::min), t.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max));
let c = |x: f64, o: f64, m: usize| (((x - o) / cell).floor().max(0.0) as usize).min(m - 1);
for gx in c(x0, lo.0, n.0)..=c(x1, lo.0, n.0) {
for gy in c(y0, lo.1, n.1)..=c(y1, lo.1, n.1) {
if let Some(slot) = cells.get_mut(gx * n.1 + gy) {
slot.push(i as u32);
}
}
}
}
}
RayGrid { dir: *d, u, v, min: lo, cell, n, cells }
})
.collect();
InsideIndex { mesh: self, rays }
}
}
impl InsideIndex<'_> {
fn crossings(&self, g: &RayGrid, o: Vec3) -> usize {
let (x, y) = (o.dot(g.u), o.dot(g.v));
if !(g.min.0.is_finite()) {
return 0;
}
let (fx, fy) = ((x - g.min.0) / g.cell, (y - g.min.1) / g.cell);
if fx < 0.0 || fy < 0.0 {
return 0;
}
let (gx, gy) = (fx.floor() as usize, fy.floor() as usize);
if gx >= g.n.0 || gy >= g.n.1 {
return 0;
}
let Some(list) = g.cells.get(gx * g.n.1 + gy) else { return 0 };
let dir = g.dir;
let mut n = 0;
for &i in list {
let Some(t) = self.mesh.triangles.get(i as usize) else { continue };
let Some([a, b, c]) = self.mesh.tri(t) else { continue };
let (e1, e2) = (b - a, c - a);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-14 {
continue;
}
let inv = 1.0 / det;
let s = o - a;
let u = s.dot(p) * inv;
if !(0.0..1.0).contains(&u) {
continue;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v >= 1.0 {
continue;
}
if e2.dot(q) * inv > 0.0 {
n += 1;
}
}
n
}
/// Same answer as `Mesh::contains`.
pub fn contains(&self, p: Vec3) -> bool {
self.rays.iter().filter(|g| self.crossings(g, p) % 2 == 1).count() >= 2
}
}
+43 -31
View File
@@ -49,19 +49,20 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z { if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900)); boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
} }
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
let (mut n, mut bad) = (0usize, 0usize); let (mut n, mut bad) = (0usize, 0usize);
for (bx, count) in boxes { for (bx, count) in boxes {
let s = bx.size(); let s = bx.size();
for i in 1..=count { for i in 1..=count {
let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5)); let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5));
let (ia, ib) = (a.contains(p), b.contains(p)); let (ia, ib) = (ia_idx.contains(p), ib_idx.contains(p));
let want = match op { let want = match op {
BoolOp::Union => ia || ib, BoolOp::Union => ia || ib,
BoolOp::Cut => ia && !ib, BoolOp::Cut => ia && !ib,
BoolOp::Intersect => ia && ib, BoolOp::Intersect => ia && ib,
}; };
n += 1; n += 1;
if result.contains(p) != want { if ir_idx.contains(p) != want {
bad += 1; bad += 1;
} }
} }
@@ -75,23 +76,24 @@ fn overlap(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh) -> Option<(f64,
if a.triangles.len() + b.triangles.len() > 80_000 { if a.triangles.len() + b.triangles.len() > 80_000 {
return None; return None;
} }
let frac = |inner: &solvecraft_geom::Mesh, outer: &solvecraft_geom::Mesh| -> Option<f64> { let (ia, ib) = (a.inside_index(), b.inside_index());
let frac = |inner: &solvecraft_geom::Mesh, inner_i: &solvecraft_geom::InsideIndex, outer_i: &solvecraft_geom::InsideIndex| -> Option<f64> {
let bx = inner.bounds(); let bx = inner.bounds();
let s = bx.size(); let s = bx.size();
let (mut n, mut hit) = (0usize, 0usize); let (mut n, mut hit) = (0usize, 0usize);
for i in 1..=2000 { for i in 1..=2000 {
let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5)); let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5));
if !inner.contains(p) { if !inner_i.contains(p) {
continue; continue;
} }
n += 1; n += 1;
if outer.contains(p) { if outer_i.contains(p) {
hit += 1; hit += 1;
} }
} }
(n >= 50).then(|| hit as f64 / n as f64) (n >= 50).then(|| hit as f64 / n as f64)
}; };
Some((frac(b, a)?, frac(a, b)?)) Some((frac(b, &ib, &ia)?, frac(a, &ia, &ib)?))
} }
/// Booleans that need no intersection: one body clear of the other, or inside it. These are /// Booleans that need no intersection: one body clear of the other, or inside it. These are
@@ -163,22 +165,32 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?; a.require_brep("a boolean")?;
b.require_brep("a boolean")?; b.require_brep("a boolean")?;
let size = a.size().max(b.size()); let size = a.size().max(b.size());
let (va, vb) = (volume(a), volume(b));
let slack = 2e-3 * (va + vb) + 1e-9;
let tol_m = size * 5e-4; let tol_m = size * 5e-4;
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok()); let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
let plausible = |v: f64, r: &Body| { let (va, vb) = match &meshes {
let bounds = match op { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack, _ => (volume(a), volume(b)),
BoolOp::Cut => v >= va - vb - slack && v <= va + slack, };
BoolOp::Intersect => v <= va.min(vb) + slack, let slack = 2e-3 * (va + vb) + 1e-9;
}; // Volume bounds, then membership against the operands (catches misclassified pieces), on
// Membership check against the operands (catches misclassified pieces). // one mesh of the result. Returns (plausible, volume).
let consistent = match (&meshes, r.tessellate(tol_m)) { let check = |r: &Body| -> (bool, f64) {
((Some(ma), Some(mb)), Ok(mr)) => mismatch(ma, mb, &mr, op).is_none_or(|f| f < 0.004), let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) };
let v = mr.measure().volume;
let bounds = v > 0.0
&& match op {
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
BoolOp::Intersect => v <= va.min(vb) + slack,
};
if !bounds {
return (false, v);
}
let consistent = match &meshes {
(Some(ma), Some(mb)) => mismatch(ma, mb, &mr, op).is_none_or(|f| f < 0.004),
_ => true, _ => true,
}; };
bounds && consistent (consistent, v)
}; };
// One body clear of or inside the other: no intersection needed. // One body clear of or inside the other: no intersection needed.
if let (Some(ma), Some(mb)) = &meshes if let (Some(ma), Some(mb)) = &meshes
@@ -212,8 +224,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
&& b.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1
&& let Some(Ok(body)) = crate::coplanar::glue(a, b) && let Some(Ok(body)) = crate::coplanar::glue(a, b)
{ {
let v = volume(&body); let (ok, _) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
} }
@@ -222,8 +234,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let r = crate::coplanar::boolean_apart(a, b, op); let r = crate::coplanar::boolean_apart(a, b, op);
APART.with(|c| c.set(false)); APART.with(|c| c.set(false));
if let Some(Ok(Some(body))) = r { if let Some(Ok(Some(body))) = r {
let v = volume(&body); let (ok, _) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
} }
@@ -231,7 +243,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let mut empty_votes = 0; let mut empty_votes = 0;
for (attempt, j) in JITTER.iter().enumerate() { for (attempt, j) in JITTER.iter().enumerate() {
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01); let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
for k in [5e-4, 2e-3] { for k in [2e-3, 5e-4] {
let tol = (size * k).max(1e-5); let tol = (size * k).max(1e-5);
let r = guard("boolean", || { let r = guard("boolean", || {
let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy()); let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy());
@@ -257,8 +269,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
} }
Ok(s) => match Body::new(crate::heal::heal(s, size)) { Ok(s) => match Body::new(crate::heal::heal(s, size)) {
Ok(body) => { Ok(body) => {
let v = volume(&body); let (ok, v) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
last = format!("implausible result volume {v:.4}"); last = format!("implausible result volume {v:.4}");
@@ -279,8 +291,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
APART.with(|c| c.set(false)); APART.with(|c| c.set(false));
match r { match r {
Some(Ok(Some(body))) => { Some(Ok(Some(body))) => {
let v = volume(&body); let (ok, v) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
last = format!("{last}; faces pushed apart gave an implausible volume {v:.4}"); last = format!("{last}; faces pushed apart gave an implausible volume {v:.4}");
@@ -294,8 +306,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// polygon fallback. // polygon fallback.
match crate::polybool::planar_boolean(a, b, op) { match crate::polybool::planar_boolean(a, b, op) {
Ok(Some(body)) => { Ok(Some(body)) => {
let v = volume(&body); let (ok, v) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
last = format!("planar fallback gave an implausible volume {v:.4}"); last = format!("planar fallback gave an implausible volume {v:.4}");
@@ -313,8 +325,8 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
APART.with(|c| c.set(false)); APART.with(|c| c.set(false));
match r { match r {
Ok(Some(body)) => { Ok(Some(body)) => {
let v = volume(&body); let (ok, v) = check(&body);
if v > 0.0 && plausible(v, &body) { if ok {
return Ok(Some(body)); return Ok(Some(body));
} }
last = format!("{last}; the nudged tool gave an implausible volume {v:.4}"); last = format!("{last}; the nudged tool gave an implausible volume {v:.4}");
+1 -1
View File
@@ -411,7 +411,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
match (sa.search_nearest_parameter(q, None, 50), sb.search_nearest_parameter(q, None, 50)) { match (sa.search_nearest_parameter(q, None, 50), sb.search_nearest_parameter(q, None, 50)) {
(Some((u, v)), Some((s, t))) => { (Some((u, v)), Some((s, t))) => {
let (x, y) = (sa.normal(u, v), sb.normal(s, t)); let (x, y) = (sa.normal(u, v), sb.normal(s, t));
x.x * y.x + x.y * y.y + x.z * y.z > 1.0 - 1e-4 x.x * y.x + x.y * y.y + x.z * y.z > 1.0 - 1e-3
} }
_ => false, _ => false,
} }