fix: coincident faces are pushed apart only where that changes nothing (#817)

A coincident face is pushed apart only when the space it moves through holds nothing that changes the result; otherwise the boolean falls through to the exact strategies.

Co-authored-by: NOWN-DEV <[email protected]>
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 15:10:31 -04:00
1 parent d2676da789
commit 9184994721
7 files changed
+491 -4

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
//! Has a face room to move?
//!
//! Coincident faces are pushed apart before a boolean (see [`crate::coplanar`]). That leaves
//! the result as it was only where the space a face moves through holds nothing of the other
//! body (a cut), or nothing but its material (a join). A [`Clearance`] decides that on the
//! other body's faces: every triangle of its mesh is clipped against that space, so a wall, a
//! rib or a hole anywhere in it is found. Sample points miss what lies between them.
use std::collections::HashMap;
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use truck_modeling as mt;
use crate::body::Body;
use crate::offset::{Surf, surf_of};
/// How a body's material sits in a region of space.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Occupancy {
/// The region holds none of it.
Empty,
/// The region lies wholly in it.
Full,
/// A face reaches into the region, or may: no answer.
Mixed,
}
/// A convex region: the points `p` with `n · p <= d` for every side `(n, d)`, `n` a unit vector
/// pointing out of it.
struct Convex(Vec<(Vec3, f64)>);
impl Convex {
/// The prism a triangle sweeps when it moves along the unit vector `along` (not in its
/// plane) from the distance `lo` to `hi`, its sides moved out by `margin` (in, when
/// negative). `None` for a triangle without area or an empty interval.
fn swept(tri: [Vec3; 3], along: Vec3, margin: f64, lo: f64, hi: f64) -> Option<Convex> {
let [a, b, c] = tri;
let centre = (a + b + c) / 3.0;
if hi <= lo || !(margin + lo + hi).is_finite() || (b - a).cross(c - a).dot(along).abs() < 1e-300 {
return None;
}
let mut sides = Vec::with_capacity(5);
for (p, q) in [(a, b), (b, c), (c, a)] {
let mut out = (q - p).cross(along).normalized()?;
if out.dot(centre - p) > 0.0 {
out = -out;
}
sides.push((out, out.dot(p) + margin));
}
let base = along.dot(a);
sides.push((along, base + hi));
sides.push((-along, -(base + lo)));
Some(Convex(sides))
}
fn contains(&self, p: Vec3) -> bool {
self.0.iter().all(|(n, d)| n.dot(p) <= *d)
}
/// Does the triangle reach into the region? Touching its boundary counts.
fn meets(&self, tri: [Vec3; 3]) -> bool {
// A side with the whole triangle beyond it settles most triangles at once.
if self.0.iter().any(|(n, d)| tri.iter().all(|p| n.dot(*p) > *d)) {
return false;
}
// What the sides leave of the triangle, one side after the other.
let mut left: Vec<Vec3> = tri.to_vec();
let mut next: Vec<Vec3> = Vec::with_capacity(tri.len() + self.0.len());
for (n, d) in &self.0 {
let Some(&last) = left.last() else { return false };
next.clear();
let (mut p, mut sp) = (last, n.dot(last) - d);
for &q in &left {
let sq = n.dot(q) - d;
if (sp < 0.0 && sq > 0.0) || (sp > 0.0 && sq < 0.0) {
next.push(p + (q - p) * (sp / (sp - sq)));
}
if sq <= 0.0 {
next.push(q);
}
(p, sp) = (q, sq);
}
std::mem::swap(&mut left, &mut next);
}
!left.is_empty()
}
}
/// How far a face can stand off its triangles, seen from a region along one direction.
#[derive(Clone, Copy)]
enum Reach {
/// Not at all: a planar face, a facet of a mesh body.
Exact,
/// Sideways only: a wall of straight lines along the direction (a hole drilled along it).
Sideways,
/// By the chord error, any way.
Around,
/// By the chord error, to one side: a face of a cylinder, a cone or a sphere lies beyond its
/// triangles seen from the material when it is `convex`, seen from outside when it is not.
Beyond { convex: bool },
/// It ends before the region begins or begins past its end (a fillet running into the
/// plane the region starts from).
Never,
}
/// A body's faces, for telling how its material sits in the space a face moves through.
pub(crate) struct Clearance<'a> {
body: &'a Body,
mesh: Mesh,
/// How far a curved face may stand off its triangles.
sag: f64,
tol: f64,
}
/// Pairs of triangles one question may compare; beyond it there is no answer.
const MAX_PAIRS: usize = 2_000_000;
impl<'a> Clearance<'a> {
/// `chord`: the chord error of the mesh the body is asked on.
pub(crate) fn new(body: &'a Body, chord: f64) -> Option<Clearance<'a>> {
let mesh = body.tessellate_with(chord, true).ok()?;
(!mesh.triangles.is_empty()).then(|| Clearance { body, mesh, sag: 2.0 * chord, tol: (body.size() * 1e-7).max(1e-9) })
}
/// How the body's material sits in the space that `faces` move through when they go along
/// the unit vector `along` from the distance `lo` to `hi`. `faces`: the triangles of
/// planar faces square to `along`, each with how far its face reaches past it sideways
/// (negative: the triangle's sides are drawn in, so that faces lying against them don't
/// count). `Mixed` as soon as a face of the body reaches into that space.
pub(crate) fn swept(&self, faces: &[([Vec3; 3], f64)], along: Vec3, lo: f64, hi: f64) -> Occupancy {
let Some((first, _)) = faces.first() else { return Occupancy::Mixed };
let base = along.dot(first[0]);
// The body's triangles near that space.
let mut space = Aabb3::EMPTY;
let mut pad = self.sag;
for (tri, margin) in faces {
pad = pad.max(self.sag + margin);
tri.iter().for_each(|p| [lo - self.sag, hi + self.sag].iter().for_each(|s| space.add(*p + along * *s)));
}
let (min, max) = (space.min - Vec3::new(pad, pad, pad), space.max + Vec3::new(pad, pad, pad));
let outside = |tri: &[Vec3; 3]| {
let all = |f: &dyn Fn(Vec3) -> bool| tri.iter().all(|p| f(*p));
all(&|p| p.x < min.x)
|| all(&|p| p.x > max.x)
|| all(&|p| p.y < min.y)
|| all(&|p| p.y > max.y)
|| all(&|p| p.z < min.z)
|| all(&|p| p.z > max.z)
};
let mut near: Vec<(u32, [Vec3; 3])> = Vec::new();
for (t, face) in self.mesh.triangles.iter().zip(&self.mesh.tri_face) {
let Some(tri) = self.mesh.tri(t) else { return Occupancy::Mixed };
if !outside(&tri) {
near.push((*face, tri));
}
}
if faces.len().saturating_mul(near.len()) > MAX_PAIRS {
return Occupancy::Mixed;
}
let inside = self.mesh.inside_index();
let mut reach: HashMap<u32, Reach> = HashMap::new();
let mut all = None;
for (tri, margin) in faces {
let prism = |side: f64, end: f64| Convex::swept(*tri, along, margin + side, lo - end, hi + end);
let probe = (tri[0] + tri[1] + tri[2]) / 3.0 + along * ((lo + hi) * 0.5);
// A sliver thinner than its margins covers nothing that its neighbours don't.
let Some(tight) = prism(0.0, 0.0).filter(|p| p.contains(probe)) else { continue };
let (Some(sideways), Some(around)) = (prism(self.sag, 0.0), prism(self.sag, self.sag)) else { return Occupancy::Mixed };
// With no face in it, the prism is all material or all empty.
let here = if inside.contains(probe) { Occupancy::Full } else { Occupancy::Empty };
let reaches = |(face, t): &(u32, [Vec3; 3])| {
around.meets(*t)
&& match *reach.entry(*face).or_insert_with(|| self.reach(*face, along, base + lo, base + hi)) {
Reach::Exact => tight.meets(*t),
Reach::Sideways => sideways.meets(*t),
// Its triangles lie between the prism and the face.
Reach::Beyond { convex } if convex == (here == Occupancy::Full) => tight.meets(*t),
Reach::Around | Reach::Beyond { .. } => true,
Reach::Never => false,
}
};
if near.iter().any(reaches) {
return Occupancy::Mixed;
}
if all.is_some_and(|seen| seen != here) {
return Occupancy::Mixed;
}
all = Some(here);
}
all.unwrap_or(Occupancy::Mixed)
}
/// Has the body nothing between the heights `lo` and `hi` above the plane `n · p = d` (`n`
/// a unit vector), anywhere?
pub(crate) fn clear_of_slab(&self, n: Vec3, d: f64, lo: f64, hi: f64) -> bool {
let mut reach: HashMap<u32, Reach> = HashMap::new();
self.mesh.triangles.iter().zip(&self.mesh.tri_face).all(|(t, face)| {
let Some(tri) = self.mesh.tri(t) else { return false };
let pad = match *reach.entry(*face).or_insert_with(|| self.reach(*face, n, d + lo, d + hi)) {
Reach::Never => return true,
Reach::Around | Reach::Beyond { .. } => self.sag,
Reach::Exact | Reach::Sideways => 0.0,
};
let heights = tri.map(|p| n.dot(p) - d);
heights.iter().all(|s| s + pad < lo) || heights.iter().all(|s| s - pad > hi)
})
}
/// How the face's triangles stand for it in a region between the heights `lo` and `hi`
/// along `along` (measured as `along · p`).
fn reach(&self, face: u32, along: Vec3, lo: f64, hi: f64) -> Reach {
if self.body.is_mesh() {
return Reach::Exact;
}
crate::guard("face reach", || {
let Some(f) = self.body.solid.face_iter().nth(face as usize) else { return Ok(Reach::Around) };
let surface = f.oriented_surface();
if matches!(surface, mt::Surface::Plane(_)) {
return Ok(Reach::Exact);
}
let round = surf_of(f, self.tol * 100.0);
let straight = matches!(round, Some(Surf::Cylinder { a, .. }) if a.dot(along).abs() > 1.0 - 1e-12)
|| crate::prism::wall_along(&surface, along, self.tol * 10.0, &mut 262_144);
Ok(if straight {
Reach::Sideways
} else if round.and_then(|s| span(&s, along)).is_some_and(|(s0, s1)| s1 <= lo || s0 >= hi) {
Reach::Never
} else if let Some(Surf::Cylinder { convex, .. } | Surf::Cone { convex, .. } | Surf::Sphere { convex, .. }) = round {
Reach::Beyond { convex }
} else {
Reach::Around
})
})
.unwrap_or(Reach::Around)
}
}
/// The whole extent of a round surface along `along`, where it has one.
fn span(s: &Surf, along: Vec3) -> Option<(f64, f64)> {
let about = |c: Vec3, r: f64| Some((along.dot(c) - r.abs(), along.dot(c) + r.abs()));
match *s {
Surf::Cylinder { o, a, r, .. } if a.dot(along).abs() < 1e-9 => about(o, r),
Surf::Sphere { c, r, .. } => about(c, r),
Surf::Torus { o, a, h, r, .. } if a.dot(along).abs() > 1.0 - 1e-12 => about(o + a * h, r),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BoolOp, boolean, box_solid, cylinder, fillet};
fn clearance(body: &Body) -> Clearance<'_> {
Clearance::new(body, body.size() * 2e-4).unwrap()
}
/// The top face of a 200 x 100 x 20 block between x = `x0` and `x1`, y = 10 and 90, as two
/// triangles with their sides drawn in by 1e-4.
fn top(x0: f64, x1: f64) -> Vec<([Vec3; 3], f64)> {
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
vec![([p(x0, 10.0), p(x1, 10.0), p(x1, 90.0)], -1e-4), ([p(x0, 10.0), p(x1, 90.0), p(x0, 90.0)], -1e-4)]
}
/// The space over a block's top face is empty and the space under it is material, up to
/// the smallest thing that stands in it, wherever that is.
#[test]
fn the_space_a_face_moves_through_is_empty_or_material_until_something_stands_in_it() {
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
let c = clearance(&block);
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
// 25 down is through the bottom face (the block is 20 thick); 210 is past its side x = 200.
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 25.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(10.0, 210.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&[], Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && !c.clear_of_slab(Vec3::Z, 20.0, -3.0, -1e-4));
// A pin 0.4 x 0.4 on the top face, at x = 137.3 to 137.7: in the way of what covers it only.
let pin = box_solid(Vec3::new(137.3, 61.1, 20.0), Vec3::new(137.7, 61.5, 26.0)).unwrap();
let pinned = boolean(&block, &pin, BoolOp::Union).unwrap().unwrap();
let c = clearance(&pinned);
assert_eq!(c.swept(&top(10.0, 190.0), Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(10.0, 137.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert!(!c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0) && c.clear_of_slab(Vec3::Z, 20.0, 6.0 + 1e-4, 9.0));
// A 4 mm hole through the block at x = 60: no material all the way under what covers it.
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 2.0, 30.0).unwrap();
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
let c = clearance(&holed);
assert_eq!(c.swept(&top(10.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&top(70.0, 190.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
}
/// Round faces that end in the plane a face lies in leave the space beyond it free: a
/// hole's wall, a fillet running into the top face. A round face that rises into that space
/// by less than its chords show does not.
#[test]
fn round_faces_ending_at_the_plane_leave_the_space_beyond_it_free() {
let block = box_solid(Vec3::ZERO, Vec3::new(200.0, 100.0, 20.0)).unwrap();
let hole = cylinder(Vec3::new(60.0, 50.0, -5.0), Vec3::Z, 8.0, 30.0).unwrap();
let holed = boolean(&block, &hole, BoolOp::Cut).unwrap().unwrap();
// The fillet (r = 4) along the edge y = 0 of the top face ends on the line y = 4, z = 20.
let rounded = fillet(&holed, &[Vec3::new(100.0, 0.0, 20.0)], 4.0).unwrap();
let c = clearance(&rounded);
// The top face from x = `x0` to 190 and y = `y0` to 90.
let rect = |x0: f64, y0: f64| {
let p = |x: f64, y: f64| Vec3::new(x, y, 20.0);
vec![([p(x0, y0), p(190.0, y0), p(190.0, 90.0)], -1e-4), ([p(x0, y0), p(190.0, 90.0), p(x0, 90.0)], -1e-4)]
};
assert_eq!(c.swept(&rect(10.0, -10.0), Vec3::Z, 1e-4, 3.0), Occupancy::Empty);
assert!(c.clear_of_slab(Vec3::Z, 20.0, 1e-4, 3.0));
// Under the top face the fillet is in the way from its tangent line y = 4 down (and no
// further: its chords lie inside it), the hole from x = 68 down.
assert_eq!(c.swept(&rect(80.0, 4.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Full);
assert_eq!(c.swept(&rect(80.0, 3.9), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
assert_eq!(c.swept(&rect(60.0, 5.0), -Vec3::Z, 1e-4, 3.0), Occupancy::Mixed);
// A bar lying along x (axis at z = 12): its top ends 0.05 um short of where the space
// begins (z = 20.0001), then reaches 0.9 um into it.
for (radius, want) in [(8.00005, Occupancy::Empty), (8.001, Occupancy::Mixed)] {
let bar = cylinder(Vec3::new(20.0, 50.0, 12.0), Vec3::X, radius, 160.0).unwrap();
assert_eq!(clearance(&bar).swept(&top(30.0, 150.0), Vec3::Z, 1e-4, 3.0), want, "radius {radius}");
}
}
}
+65 -1
View File
@@ -10,6 +10,17 @@
//! - union of faces facing the same way: both faces move outward by different amounts, the
//! union is taken, and the slab beyond the plane is cut off (now a transversal cut);
//! - union of faces back to back: the tool's face moves into the target, which overlaps it.
//!
//! Each of these leaves the result unchanged only where the space a face moves through holds
//! what the boolean leaves there anyway: none of the target for a cut or an intersection, the
//! target's material for a union, nothing of either body beyond a plane whose slab is cut off
//! again. The faces of a coincident plane lie on each other, but nothing says that what lies
//! beside them does: a tool's side in the face of a wall has the neighbouring walls beside it,
//! a channel's top in the face a rib stands on has the rib over it. So a face is asked first
//! what it would move through: a join's face moving into the target at points behind it
//! (`backed`), the others on the other body's faces (see [`crate::clearance`]). A face with
//! no room stays where it is and the boolean fails with [`NO_ROOM`], for the strategies that
//! take the bodies as they lie.
use std::collections::HashMap;
@@ -18,6 +29,7 @@ use truck_modeling::{self as mt, builder};
use crate::body::shell_groups::boxes_separate;
use crate::body::{Body, Solid, from_p3, p3, v3};
use crate::clearance::{Clearance, Occupancy};
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
use crate::{BoolOp, KernelError, Result, guard, record};
@@ -307,8 +319,12 @@ pub(crate) fn coincident_planes(a: &Body, b: &Body) -> Vec<(Vec3, f64, bool)> {
found
}
/// What [`boolean_apart`] fails with when a face has no room to move.
pub(crate) const NO_ROOM: &str = "coincident faces were left in place: pushing them apart would change the result";
/// Retry a failed boolean with coincident planar faces pushed apart. `None` when there are no
/// coincident faces or the faces can't be pushed.
/// coincident faces or the faces can't be pushed; the error [`NO_ROOM`] when a face would
/// move through something that changes the result.
pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9);
@@ -320,12 +336,31 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
let back: Vec<(Vec3, f64)> = coincident.iter().filter(|(_, _, s)| !*s).map(|(n, d, _)| (*n, *d)).collect();
let (d1, d2) = (size * 0.0137, size * 0.0229);
let counts = record::counts(&[a, b]);
let (skin, chord) = (tol * 10.0, size * 2e-4);
let no_room = || KernelError::Failed(NO_ROOM.into());
let run = || -> Result<Option<Body>> {
// The target's faces, meshed when the first face asks what it would move through.
let faces_of_a = std::cell::OnceCell::new();
let target = || faces_of_a.get_or_init(|| Clearance::new(a, chord)).as_ref().ok_or_else(no_room);
let (mut a2, mut b2) = (a.clone(), b.clone());
// What the pushed bodies are made of: their histories over [a] and [b].
let (mut ha, mut hb) = (crate::boolean_record::kept(a, 0, 1), crate::boolean_record::kept(b, 0, 1));
for (n, d) in &same {
// b's face points along n too.
let free = if op == BoolOp::Union {
// Both faces move out and the slab beyond the plane is cut off the joined body
// again, everywhere: neither body may have anything in it.
let depth = d1.max(d2) * 3.0 * 1.25;
let tool = Clearance::new(b, chord).ok_or_else(no_room)?;
target()?.clear_of_slab(*n, *d, skin, depth) && tool.clear_of_slab(*n, *d, skin, depth)
} else {
// The tool takes whatever of `a` its face moves through (a rib standing on the
// face a cut ends in).
target()?.swept(&in_plane(&b2, *n, *d, skin), *n, skin, d2 * 1.25) == Occupancy::Empty
};
if !free {
return Err(no_room());
}
b2 = push_faces(&b2, *n, *d, d2)?;
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
if op == BoolOp::Union {
@@ -346,6 +381,11 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
.find(|depth| backed(a, b, -*n, -*d, *depth))
.ok_or_else(|| KernelError::Failed("pushing a face: part of it lies over a hole or past the edge of the other body".into()))?
} else {
// Back into the tool, the face gives up what the tool took of `a` there (a
// wall standing beside the face the tool's foot lies on).
if target()?.swept(&in_plane(&b2, -*n, -*d, skin), *n, skin, d2 * 1.25) != Occupancy::Empty {
return Err(no_room());
}
-d2
};
b2 = push_faces(&b2, -*n, -*d, shift)?;
@@ -445,6 +485,26 @@ pub(crate) fn faces_touch(a: &Body, b: &Body) -> bool {
})
}
/// The triangles of `tool`'s faces in the plane (`m`, `d`), `m` their outward normal, each with
/// how far its face reaches past it sideways. A face bounded by straight edges is its
/// triangles; they are drawn in by `skin`, so that faces of the other body lying against the
/// face's sides are not in its way. A face with a curved edge reaches past its triangles by
/// the chord error.
fn in_plane(tool: &Body, m: Vec3, d: f64, skin: f64) -> Vec<([Vec3; 3], f64)> {
let chord = (tool.size() * 2e-4).max(skin);
let Ok(mesh) = tool.tessellate_with(chord, true) else { return Vec::new() };
let faces: Vec<&mt::Face> = tool.solid.face_iter().collect();
let margins: HashMap<usize, f64> = planes(tool)
.into_iter()
.filter(|(_, p)| p.n.dot(m) > 1.0 - 1e-9 && (p.d - d).abs() < skin)
.filter_map(|(i, _)| {
let straight = faces.get(i)?.edge_iter().all(|e| matches!(e.curve(), mt::Curve::Line(_)));
Some((i, if straight { -skin } else { 2.0 * chord }))
})
.collect();
mesh.triangles.iter().zip(&mesh.tri_face).filter_map(|(t, face)| Some((mesh.tri(t)?, *margins.get(&(*face as usize))?))).collect()
}
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
@@ -1007,3 +1067,7 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
}
None
}
#[cfg(test)]
#[path = "coplanar_tests.rs"]
mod tests;
+77
View File
@@ -0,0 +1,77 @@
//! Booleans of bodies with faces in a common plane: a face is pushed out of the plane only where
//! that leaves the result as it is. Every expected volume is what the two bodies define.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::*;
use solidcraft_geom::Vec3;
/// An l x w x h block with two bottom edges chamfered by 1 mm: no prism along any axis, so
/// its booleans are not done on sections.
fn block(l: f64, w: f64, h: f64) -> Body {
let b = box_solid(Vec3::ZERO, Vec3::new(l, w, h)).unwrap();
let b = chamfer(&b, &[Vec3::new(l / 2.0, w, 0.0)], 1.0).unwrap();
chamfer(&b, &[Vec3::new(l, w / 2.0, 0.0)], 1.0).unwrap()
}
/// A 200 x 100 x 20 block with a rib across it: 20 wide over the block's 100, from the top
/// face z = 20 up to z = 30.
fn ribbed() -> Body {
let rib = box_solid(Vec3::new(90.0, 0.0, 20.0), Vec3::new(110.0, 100.0, 30.0)).unwrap();
boolean(&block(200.0, 100.0, 20.0), &rib, BoolOp::Union).unwrap().unwrap()
}
/// A channel along that block, longer than its 200: y 30 to 70 and z 10 to 20, so its top
/// lies in the plane of the face the rib stands on.
fn channel() -> Body {
box_solid(Vec3::new(-20.0, 30.0, 10.0), Vec3::new(220.0, 70.0, 20.0)).unwrap()
}
/// What the boolean of `a` and `b` adds to a's volume, the result's lumps, and how it was made.
fn change(a: &Body, b: &Body, op: BoolOp) -> (f64, usize, BooleanDiagnostics) {
let (r, how) = capture_boolean_diagnostics(|| boolean(a, b, op));
let r = r.unwrap().unwrap();
(measure(&r).unwrap().volume - measure(a).unwrap().volume, r.lumps().unwrap().len(), how)
}
fn used(how: &BooleanDiagnostics, strategy: BooleanStrategy) -> bool {
how.events.iter().any(|e| e.strategy == strategy)
}
/// A channel cut along a block, up to the face a rib stands on. The channel's top lies in
/// that face's plane and the rib bridges it: pushed up out of the plane (2.29 % of the
/// body's size, 5.6 mm), the top would cut a notch 20 x 40 into the rib.
#[test]
fn a_cut_up_to_a_face_leaves_what_stands_on_the_face() {
let (added, lumps, how) = change(&ribbed(), &channel(), BoolOp::Cut);
// It takes 200 x 40 x 10 of the block and nothing of the rib.
assert!((added + 80_000.0).abs() < 1e-6 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// The same with a round hole through the block beside the channel (r 4 at 30, 15: y 11 to
/// 19, the channel from 30 on). The body no longer has planar faces only, but the hole lies
/// clear of the tool, and the cut stays what the channel is.
#[test]
fn a_round_hole_beside_the_cut_changes_nothing() {
let hole = cylinder(Vec3::new(30.0, 15.0, -1.0), Vec3::Z, 4.0, 22.0).unwrap();
let drilled = boolean(&ribbed(), &hole, BoolOp::Cut).unwrap().unwrap();
let (added, lumps, how) = change(&drilled, &channel(), BoolOp::Cut);
// 200 x 40 x 10 again (the hole is measured on its mesh, before and after).
assert!((added + 80_000.0).abs() < 1e-3 && lumps == 1, "{added}, {lumps} lumps, {how:?}");
assert!(!used(&how, BooleanStrategy::CoplanarSeparation), "{how:?}");
}
/// With nothing in their way the faces are pushed as before: a pocket's top out of the plane
/// of the block's top face, a pad's foot into the block. (This holds without the check too:
/// it keeps the check from refusing what is harmless.)
#[test]
fn faces_with_room_are_pushed_apart() {
let block = block(200.0, 100.0, 20.0);
// A pocket 50 x 60 x 5 from the top face: 15 000 less.
let pocket = box_solid(Vec3::new(75.0, 20.0, 15.0), Vec3::new(125.0, 80.0, 20.0)).unwrap();
let (added, _, how) = change(&block, &pocket, BoolOp::Cut);
assert!((added + 15_000.0).abs() < 1e-6 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
// A pad 50 x 60 x 10 on the top face: 30 000 more.
let pad = box_solid(Vec3::new(75.0, 20.0, 20.0), Vec3::new(125.0, 80.0, 30.0)).unwrap();
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
}
+1
View File
@@ -21,6 +21,7 @@ mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod build;
mod clearance;
mod coplanar;
mod curveblend;
mod delete_face;
+19 -1
View File
@@ -558,6 +558,8 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
// Coincident planar faces: push them apart (exactly). Tried once, before the general
// boolean; why it failed is reported if everything else fails too.
let mut apart_failed = String::new();
// The exact polygon boolean's answer, when it was asked for early.
let mut planar = None;
if !APART.with(|c| c.get()) {
APART.with(|c| c.set(true));
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
@@ -577,6 +579,22 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
Some(Err(e)) => apart_failed = format!("; {e}"),
Some(Ok(None)) | None => {}
}
// A face with no room to move would take or add what it moves through. The exact
// polygon boolean takes the bodies as they lie (bodies with only planar faces, or a
// planar tool clear of the other body's curved faces): it is asked now, before the
// general boolean has its tries at faces lying in each other.
if apart_failed.contains(crate::coplanar::NO_ROOM) {
// What its own booleans report counts only when its result is taken.
let (exact, diagnostics) = capture_boolean_diagnostics(|| crate::polybool::planar_boolean(a, b, op));
match exact {
Ok(Some(body)) if check(&body).0 => {
merge(diagnostics);
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
return Ok(Some(body));
}
other => planar = Some(other),
}
}
}
// A face standing wholly inside a coincident face of the other body, back to back, and no
// other contact (#396): exact, by topology alone.
@@ -663,7 +681,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
last.push_str(&apart_failed);
// Coincident faces defeat the B-rep boolean; bodies with only planar faces have an exact
// polygon fallback.
match crate::polybool::planar_boolean(a, b, op) {
match planar.unwrap_or_else(|| crate::polybool::planar_boolean(a, b, op)) {
Ok(Some(body)) => {
let (ok, v) = check(&body);
if ok {
+1 -1
View File
@@ -586,7 +586,7 @@ fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
/// Certify that a curved wall is swept along `d` from its surface representation.
/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
/// both be perpendicular to a transverse direction, silently filling its hole.
fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
pub(crate) fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
if let mt::Surface::RevolutedCurve(processor) = surface {
let revolution = processor.entity();
let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
+5 -1
View File
@@ -45,7 +45,11 @@ rotation, face offset and tessellation tolerance in millimetres/radians.
A common shift of both operands is reversed on the result. Coincident-face
separation and bore offsets describe temporary working geometry; they are not
claims about the final model's error. A tool nudge changes the relative operand
claims about the final model's error. A coincident face is separated only
where the space it moves through holds what the Boolean leaves there anyway
(nothing of the target for a cut's face, the target's material for a join's);
a face with no such room stays, and the exact planar fallback is tried first.
A tool nudge changes the relative operand
placement and reports both its translation magnitude and rotation angle.
Exact planar and piecewise fallbacks report zero displacement. The existing
volume and spatial acceptance checks still apply to all results.