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:
1 parent
d2676da789
commit
9184994721
7 files changed
+491
-4
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -21,6 +21,7 @@ mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod build;
|
||||
mod clearance;
|
||||
mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user