diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index e6e771c9..b089e58e 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -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::()))?; + // 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::()))?; if v < 0.0 { solid.not(); } diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 97684525..3e34a144 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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> { + 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> { 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}"))) } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 9b98ce61..a4e9ae59 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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::())), + } + } + } + } + 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}"); +}