union_apart joins bodies whose boxes are clearly apart (many pattern tools, #261) without any intersection work: their shells side by side. Each face and edge of the result is its piece's own, unchanged, and the result now says so (a history over the pieces), so a pattern's cut keeps the exact names of every copy's faces. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
889 lines
40 KiB
Rust
889 lines
40 KiB
Rust
//! Operations on bodies: booleans and rigid transforms.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use solvecraft_geom::Vec3;
|
||
use truck_modeling as mt;
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use crate::body::{Body, Solid, from_p3};
|
||
use crate::boolean_diagnostics::{BooleanFallback, BooleanStrategy, capture_boolean_diagnostics, merge, record};
|
||
use crate::boolean_record::{self as recorded, Traced};
|
||
use crate::{KernelError, Result, guard, record};
|
||
|
||
#[cfg(test)]
|
||
#[path = "bore_union_tests.rs"]
|
||
mod bore_union_tests;
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum BoolOp {
|
||
Union,
|
||
Cut,
|
||
Intersect,
|
||
}
|
||
|
||
/// Whether a union can sew two matching planar faces without intersecting them.
|
||
/// Callers can retain the exact touching tool instead of extending it into material.
|
||
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||
}
|
||
|
||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||
/// back) leaves the geometry unchanged but changes those rays.
|
||
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
|
||
|
||
fn volume(b: &Body) -> f64 {
|
||
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
|
||
}
|
||
|
||
/// Halton low-discrepancy value (base `b`) for index `i`.
|
||
fn halton(mut i: usize, b: usize) -> f64 {
|
||
let (mut f, mut r) = (1.0, 0.0);
|
||
while i > 0 {
|
||
f /= b as f64;
|
||
r += f * (i % b) as f64;
|
||
i /= b;
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Fraction of sample points where `result` disagrees with `op` applied to `a` and `b`
|
||
/// (membership by ray parity). Samples cover the overlap box densely and the union box lightly.
|
||
fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solvecraft_geom::Mesh, op: BoolOp) -> Option<f64> {
|
||
if a.triangles.len() + b.triangles.len() + result.triangles.len() > 60_000 {
|
||
return None;
|
||
}
|
||
let (ba, bb) = (a.bounds(), b.bounds());
|
||
let lo = ba.min.max(bb.min);
|
||
let hi = ba.max.min(bb.max);
|
||
let all = ba.union(&bb);
|
||
let mut boxes = vec![(all, 300usize)];
|
||
// A touching seam can overlap by round-off only. Samples there sit on the
|
||
// operands' boundaries, where ray parity cannot classify membership reliably.
|
||
let gap = all.diagonal().max(1.0) * 1e-9;
|
||
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
|
||
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);
|
||
for (bx, count) in boxes {
|
||
let s = bx.size();
|
||
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 (ia, ib) = (ia_idx.contains(p), ib_idx.contains(p));
|
||
let want = match op {
|
||
BoolOp::Union => ia || ib,
|
||
BoolOp::Cut => ia && !ib,
|
||
BoolOp::Intersect => ia && ib,
|
||
};
|
||
n += 1;
|
||
if ir_idx.contains(p) != want {
|
||
bad += 1;
|
||
}
|
||
}
|
||
}
|
||
Some(bad as f64 / n.max(1) as f64)
|
||
}
|
||
|
||
/// How one body sits relative to another, from sample points: (fraction of `b`'s samples
|
||
/// inside `a`, fraction of `a`'s samples inside `b`).
|
||
fn overlap(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh) -> Option<(f64, f64)> {
|
||
if a.triangles.len() + b.triangles.len() > 80_000 {
|
||
return None;
|
||
}
|
||
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 s = bx.size();
|
||
let (mut n, mut hit) = (0usize, 0usize);
|
||
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));
|
||
if !inner_i.contains(p) {
|
||
continue;
|
||
}
|
||
n += 1;
|
||
if outer_i.contains(p) {
|
||
hit += 1;
|
||
}
|
||
}
|
||
(n >= 50).then(|| hit as f64 / n as f64)
|
||
};
|
||
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
|
||
/// common with coincident curved faces (a hole drilled where one already is, a body joined
|
||
/// with itself), which the intersection can't handle. `counts`: the operands' faces and edges.
|
||
fn trivial(
|
||
a: &Body,
|
||
b: &Body,
|
||
op: BoolOp,
|
||
ma: &solvecraft_geom::Mesh,
|
||
mb: &solvecraft_geom::Mesh,
|
||
counts: &[(usize, usize)],
|
||
) -> Option<Option<Body>> {
|
||
let (b_in_a, a_in_b) = overlap(ma, mb)?;
|
||
const NONE: f64 = 0.003;
|
||
const ALL: f64 = 0.997;
|
||
let disjoint = b_in_a < NONE && a_in_b < NONE;
|
||
match op {
|
||
// Sampling only selects this candidate. Validate every shell boundary and the
|
||
// complete material/void nesting before preserving a disconnected union.
|
||
BoolOp::Union if disjoint => {
|
||
let pieces = vec![(a.clone(), recorded::kept(a, 0, 2)), (b.clone(), recorded::kept(b, 1, 2))];
|
||
let joined = recorded::join(pieces, counts).ok().flatten()?;
|
||
joined.separated_lumps().ok()?;
|
||
Some(Some(joined))
|
||
}
|
||
BoolOp::Cut if disjoint => Some(Some(recorded::operand(a, 0, counts))),
|
||
BoolOp::Cut if a_in_b > ALL => Some(None),
|
||
BoolOp::Intersect if disjoint => Some(None),
|
||
BoolOp::Intersect if b_in_a > ALL => Some(Some(recorded::operand(b, 1, counts))),
|
||
BoolOp::Intersect if a_in_b > ALL => Some(Some(recorded::operand(a, 0, counts))),
|
||
BoolOp::Union if b_in_a > ALL => Some(Some(recorded::operand(a, 0, counts))),
|
||
BoolOp::Union if a_in_b > ALL => Some(Some(recorded::operand(b, 1, counts))),
|
||
// The tool wholly inside, clear of the outside: a void.
|
||
BoolOp::Cut if b_in_a > ALL => {
|
||
let candidate = void(a, b, counts).ok().flatten()?;
|
||
candidate.separated_lumps().ok()?;
|
||
Some(Some(candidate))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod shortcut_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
|
||
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
|
||
let outer = crate::sphere(Vec3::ZERO, 10.0).unwrap();
|
||
let mesh = outer.tessellate(0.03).unwrap();
|
||
for distance in [19.999, 20.001] {
|
||
let other = crate::sphere(direction * distance, 10.0).unwrap();
|
||
let other_mesh = other.tessellate(0.03).unwrap();
|
||
let counts = record::counts(&[&outer, &other]);
|
||
assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts).is_none());
|
||
assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts).is_none());
|
||
}
|
||
let protruding = crate::sphere(direction * 9.001, 1.0).unwrap();
|
||
let protruding_mesh = protruding.tessellate(0.03).unwrap();
|
||
let counts = record::counts(&[&outer, &protruding]);
|
||
assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts).is_none());
|
||
}
|
||
}
|
||
|
||
/// `a` with the inside of `b` (wholly within it) taken out: a second, inward-facing shell.
|
||
fn void(a: &Body, b: &Body, counts: &[(usize, usize)]) -> Result<Option<Body>> {
|
||
guard("void", || {
|
||
let mut inner = b.deep_copy();
|
||
let inner_edges = record::edge_origins(&inner, 1);
|
||
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}")))?;
|
||
let (faces, edges) = recorded::void(a, inner_edges, b.face_count());
|
||
Ok(Some(record::build(s, counts, Some(faces), Some(&edges))?))
|
||
})
|
||
}
|
||
|
||
thread_local! {
|
||
/// Set while a boolean runs on bodies whose coincident faces were pushed apart (no
|
||
/// second round of pushing).
|
||
static APART: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||
/// Set while a union runs with a bore narrowed off a common cylinder (no second round).
|
||
static BORES: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||
}
|
||
|
||
/// Is a union with a narrowed bore running (see [`crate::coplanar::bore_union`])?
|
||
pub(crate) fn narrowing_bores() -> bool {
|
||
BORES.with(|c| c.get())
|
||
}
|
||
|
||
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
|
||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||
///
|
||
/// The result carries its exact history ([`Body::history`]) over the operands `[a, b]`,
|
||
/// whichever way it was made, or none where a step it took does not report one.
|
||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||
let (mut r, mut diagnostics) = capture_boolean_diagnostics(|| boolean_whole(a, b, op));
|
||
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
||
let retry = match &r {
|
||
Err(_) => true,
|
||
Ok(None) => op != BoolOp::Intersect,
|
||
Ok(Some(_)) => false,
|
||
};
|
||
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
|
||
let (la, lb) = (a.lumps()?, b.lumps()?);
|
||
if la.len() > 1 || lb.len() > 1 {
|
||
let (pieces, report) = capture_boolean_diagnostics(|| by_lumps(a, b, la, lb, op));
|
||
if let Ok(Some(x)) = pieces {
|
||
r = Ok(Some(x));
|
||
diagnostics = report;
|
||
diagnostics.push(BooleanFallback::new(BooleanStrategy::Piecewise));
|
||
}
|
||
}
|
||
}
|
||
if op == BoolOp::Union
|
||
&& let Ok(Some(body)) = &r
|
||
&& !preserves_planar_surfaces(a, b, body)
|
||
{
|
||
// Numerical retries must not quietly alter a planar design's dimensions.
|
||
// Prefer the exact polygon result; reject an edge-only pinch if it cannot
|
||
// form a closed solid without displacing either operand.
|
||
r = match crate::polybool::planar_boolean(a, b, op) {
|
||
Ok(Some(exact)) if preserves_planar_surfaces(a, b, &exact) => {
|
||
diagnostics = Default::default();
|
||
diagnostics.events.push(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||
Ok(Some(exact))
|
||
}
|
||
_ => Err(KernelError::Invalid(
|
||
"join cannot preserve the original faces; check for zero-thickness edge contact or add intentional overlap".into(),
|
||
)),
|
||
};
|
||
}
|
||
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
|
||
if matches!(op, BoolOp::Cut | BoolOp::Union) {
|
||
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
|
||
}
|
||
if r.is_ok() {
|
||
merge(diagnostics);
|
||
}
|
||
r
|
||
}
|
||
|
||
/// Every face of an all-planar union must remain on an operand's original plane.
|
||
/// Curved operands keep their existing surface-specific boolean handling.
|
||
fn preserves_planar_surfaces(a: &Body, b: &Body, result: &Body) -> bool {
|
||
guard("validate planar union", || Ok(planar_surfaces_match(a, b, result))).unwrap_or(false)
|
||
}
|
||
|
||
fn planar_surfaces_match(a: &Body, b: &Body, result: &Body) -> bool {
|
||
let planes: Option<Vec<(Vec3, f64)>> = a
|
||
.solid
|
||
.face_iter()
|
||
.chain(b.solid.face_iter())
|
||
.map(|f| {
|
||
let mt::Surface::Plane(p) = f.oriented_surface() else { return None };
|
||
let n = p.normal();
|
||
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||
Some((n, n.dot(from_p3(p.origin()))))
|
||
})
|
||
.collect();
|
||
let Some(planes) = planes else { return true };
|
||
let tol = (a.size().max(b.size()) * 1e-9).max(1e-9);
|
||
result.solid.face_iter().all(|f| {
|
||
matches!(f.oriented_surface(), mt::Surface::Plane(_))
|
||
&& planes.iter().any(|(n, d)| f.vertex_iter().all(|v| (n.dot(from_p3(v.point())) - d).abs() <= tol))
|
||
})
|
||
}
|
||
|
||
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
||
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||
let bbox = |x: &Body| {
|
||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||
for v in x.solid.vertex_iter() {
|
||
bb.add(from_p3(v.point()));
|
||
}
|
||
bb
|
||
};
|
||
let (ba, bb) = (bbox(a), bbox(b));
|
||
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
|
||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||
}
|
||
|
||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||
a.require_brep("a boolean")?;
|
||
b.require_brep("a boolean")?;
|
||
let size = a.size().max(b.size());
|
||
let counts = record::counts(&[a, b]);
|
||
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||
// there coarsely enough to fail the membership test on a correct result. The meshes are
|
||
// refined: a round side trimmed to a thin band (a short boss crossing a gently sloping
|
||
// floor) is otherwise spanned by triangles cutting through it, which reads as missing
|
||
// material.
|
||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||
let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok());
|
||
let (va, vb) = match &meshes {
|
||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||
_ => (volume(a), volume(b)),
|
||
};
|
||
let slack = 2e-3 * (va + vb) + 1e-9;
|
||
// Volume bounds, then membership against the operands (catches misclassified pieces), on
|
||
// one mesh of the result. Returns (plausible, volume).
|
||
let check = |r: &Body| -> (bool, f64) {
|
||
let Ok(mr) = r.tessellate_with(tol_m, true) 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,
|
||
};
|
||
(consistent, v)
|
||
};
|
||
// One body clear of or inside the other: no intersection needed.
|
||
if let (Some(ma), Some(mb)) = &meshes
|
||
&& let Some(r) = trivial(a, b, op, ma, mb, &counts)
|
||
{
|
||
return Ok(r);
|
||
}
|
||
let mut last = String::new();
|
||
// Coincident faces make the intersection fail after many retries; push them apart first.
|
||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
|
||
// bounds are checked).
|
||
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
|
||
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
|
||
// pieces along slab boundaries. Merge those coplanar faces just as the
|
||
// general B-rep boolean does, retaining intentional Split Face lines.
|
||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||
let r = if op == BoolOp::Union {
|
||
r.map(|body| guard("heal prism boolean", || recorded::heal_body(&body, (*body.solid).clone(), size, &keep, &counts)).unwrap_or(body))
|
||
} else {
|
||
r
|
||
};
|
||
match &r {
|
||
Some(body) => {
|
||
let v = volume(body);
|
||
let bounds = 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 v > 0.0 && bounds {
|
||
return Ok(r);
|
||
}
|
||
}
|
||
None => return Ok(None),
|
||
}
|
||
}
|
||
// Bodies touching along one whole face (a part joined with its mirror image).
|
||
if op == BoolOp::Union
|
||
&& a.solid.boundaries().len() == 1
|
||
&& b.solid.boundaries().len() == 1
|
||
&& let Some(Ok(body)) = crate::coplanar::glue(a, b)
|
||
{
|
||
let (ok, _) = check(&body);
|
||
if ok {
|
||
return Ok(Some(body));
|
||
}
|
||
}
|
||
if !APART.with(|c| c.get()) && crate::coplanar::has_coincident_faces(a, b) {
|
||
APART.with(|c| c.set(true));
|
||
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::boolean_apart(a, b, op));
|
||
APART.with(|c| c.set(false));
|
||
if let Some(Ok(Some(body))) = r {
|
||
let (ok, _) = check(&body);
|
||
if ok {
|
||
merge(diagnostics);
|
||
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
|
||
event.face_shift_mm = size * 0.0229;
|
||
record(event);
|
||
return Ok(Some(body));
|
||
}
|
||
}
|
||
}
|
||
// Bores on a common cylinder (a pad's bore around the hole it is joined over).
|
||
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
|
||
BORES.with(|c| c.set(true));
|
||
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::bore_union(a, b, &|body| check(body).0));
|
||
BORES.with(|c| c.set(false));
|
||
if let Some(body) = r {
|
||
merge(diagnostics);
|
||
return Ok(Some(body));
|
||
}
|
||
}
|
||
let mut empty_votes = 0;
|
||
let mut empty_diagnostics = crate::BooleanDiagnostics::default();
|
||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||
for (attempt, j) in JITTER.iter().enumerate() {
|
||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
||
for k in [2e-3, 5e-4] {
|
||
let tol = (size * k).max(1e-5);
|
||
let r = truck_boolean(a, b, op, if attempt > 0 { Some(shift) } else { None }, tol);
|
||
match r {
|
||
Ok((s, _)) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
|
||
empty_votes += 1;
|
||
if attempt > 0 || k != 2e-3 {
|
||
let mut event =
|
||
BooleanFallback::new(if attempt > 0 { BooleanStrategy::ShiftedOperands } else { BooleanStrategy::RefinedTolerance });
|
||
event.translation_mm = Vec3::new(j[0], j[1], j[2]).len() * (size * 0.01);
|
||
event.tolerance_mm = tol;
|
||
empty_diagnostics.push(event);
|
||
}
|
||
last = "empty result".into();
|
||
}
|
||
Ok((s, h)) => match guard("boolean result", || recorded::truck_result(s, h, size, &keep, &counts)) {
|
||
Ok(body) => {
|
||
let (ok, v) = check(&body);
|
||
if ok {
|
||
if attempt > 0 || k != 2e-3 {
|
||
let mut event = BooleanFallback::new(if attempt > 0 {
|
||
BooleanStrategy::ShiftedOperands
|
||
} else {
|
||
BooleanStrategy::RefinedTolerance
|
||
});
|
||
event.translation_mm = Vec3::new(j[0], j[1], j[2]).len() * (size * 0.01);
|
||
event.tolerance_mm = tol;
|
||
record(event);
|
||
}
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("implausible result volume {v:.4}");
|
||
}
|
||
Err(e) => last = e.to_string(),
|
||
},
|
||
Err(e) => last = e.to_string(),
|
||
}
|
||
}
|
||
if empty_votes >= 2 && op != BoolOp::Union {
|
||
merge(empty_diagnostics);
|
||
return Ok(None);
|
||
}
|
||
}
|
||
// Coincident planar faces: push them apart (exactly) and try again.
|
||
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));
|
||
APART.with(|c| c.set(false));
|
||
match r {
|
||
Some(Ok(Some(body))) => {
|
||
let (ok, v) = check(&body);
|
||
if ok {
|
||
merge(diagnostics);
|
||
let mut event = BooleanFallback::new(BooleanStrategy::CoplanarSeparation);
|
||
event.face_shift_mm = size * 0.0229;
|
||
record(event);
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("{last}; faces pushed apart gave an implausible volume {v:.4}");
|
||
}
|
||
Some(Ok(None)) => {}
|
||
Some(Err(e)) => last = format!("{last}; {e}"),
|
||
None => {}
|
||
}
|
||
}
|
||
// 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) {
|
||
Ok(Some(body)) => {
|
||
let (ok, v) = check(&body);
|
||
if ok {
|
||
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("planar fallback gave an implausible volume {v:.4}");
|
||
}
|
||
Ok(None) => {
|
||
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||
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 translation = Vec3::new(0.61, -0.37, 0.71) * (size * 2e-6);
|
||
let (r, diagnostics) = capture_boolean_diagnostics(|| {
|
||
transform(b, translation, c, Vec3::new(0.27, 0.83, -0.49), 2e-6).and_then(|nb| Ok(boolean(a, &nb, op)?.map(|r| nudged(r, &nb, &counts))))
|
||
});
|
||
APART.with(|c| c.set(false));
|
||
match r {
|
||
Ok(Some(body)) => {
|
||
let (ok, v) = check(&body);
|
||
if ok {
|
||
merge(diagnostics);
|
||
let mut event = BooleanFallback::new(BooleanStrategy::ToolNudge);
|
||
event.translation_mm = translation.len();
|
||
event.rotation_rad = 2e-6;
|
||
record(event);
|
||
return Ok(Some(body));
|
||
}
|
||
last = format!("{last}; the nudged tool gave an implausible volume {v:.4}");
|
||
}
|
||
Ok(None) => {
|
||
merge(diagnostics);
|
||
let mut event = BooleanFallback::new(BooleanStrategy::ToolNudge);
|
||
event.translation_mm = translation.len();
|
||
event.rotation_rad = 2e-6;
|
||
record(event);
|
||
return Ok(None);
|
||
}
|
||
Err(e) => last = format!("{last}; nudged: {e}"),
|
||
}
|
||
}
|
||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||
}
|
||
|
||
/// `r`, a boolean of the target and `nb` (the tool nudged), with its history over the target and
|
||
/// the tool itself (the nudge keeps every face and edge).
|
||
pub(crate) fn nudged(r: Body, nb: &Body, counts: &[(usize, usize)]) -> Body {
|
||
let h = recorded::through(&r, 1, nb.history());
|
||
r.with_checked_history(h, counts)
|
||
}
|
||
|
||
/// What truck's boolean makes of `a` and `b`: the solid, and its history in our terms (see
|
||
/// [`recorded::shapeops`]). On copies of the operands moved by `shift` (when given; the result
|
||
/// is moved back), which changes the rays the boolean classifies faces by. Copies (and moved
|
||
/// copies) keep the operands' face and edge order, and so does moving the result back, so the
|
||
/// history truck records holds for the operands themselves.
|
||
pub(crate) fn truck_boolean(a: &Body, b: &Body, op: BoolOp, shift: Option<mt::Vector3>, tol: f64) -> Result<(Solid, Option<recorded::Shapeops>)> {
|
||
guard("boolean", || {
|
||
let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy());
|
||
if let Some(shift) = shift {
|
||
sa = mt::builder::translated(&sa, shift);
|
||
sb = mt::builder::translated(&sb, shift);
|
||
}
|
||
let tool_edges = (op == BoolOp::Cut).then(|| record::edge_origins(&sb, 1));
|
||
let res = match op {
|
||
BoolOp::Union => truck_shapeops::or_with_history(&sa, &sb, tol),
|
||
BoolOp::Cut => {
|
||
sb.not();
|
||
truck_shapeops::and_with_history(&sa, &sb, tol)
|
||
}
|
||
BoolOp::Intersect => truck_shapeops::and_with_history(&sa, &sb, tol),
|
||
};
|
||
let (s, h) = res.ok_or_else(|| KernelError::Failed("no result".into()))?;
|
||
let h = h.and_then(|h| recorded::shapeops(&h, &sb, tool_edges.as_ref()));
|
||
Ok((if let Some(shift) = shift { mt::builder::translated(&s, -shift) } else { s }, h))
|
||
})
|
||
}
|
||
|
||
/// A boolean of `a` and `b` given as their pieces (`la`, `lb`); the result's pieces in one body.
|
||
pub(crate) fn by_lumps(a: &Body, b: &Body, la: Vec<Body>, lb: Vec<Body>, op: BoolOp) -> Result<Option<Body>> {
|
||
let boxes_meet = |x: &Body, y: &Body| -> bool {
|
||
let (Ok(mx), Ok(my)) = (x.tessellate(x.size() * 0.05 + 1e-3), y.tessellate(y.size() * 0.05 + 1e-3)) else { return true };
|
||
let (bx, by) = (mx.bounds(), my.bounds());
|
||
let g = (x.size() + y.size()) * 0.01;
|
||
bx.min.x <= by.max.x + g
|
||
&& by.min.x <= bx.max.x + g
|
||
&& bx.min.y <= by.max.y + g
|
||
&& by.min.y <= bx.max.y + g
|
||
&& bx.min.z <= by.max.z + g
|
||
&& by.min.z <= bx.max.z + g
|
||
};
|
||
// Each piece with its history over [a, b] (a piece's faces and edges are its body's own).
|
||
let traced = |pieces: Vec<Body>, whole: &Body, operand: usize| -> Vec<Traced> {
|
||
let histories = recorded::parts_of(&pieces, whole);
|
||
pieces.into_iter().zip(histories).map(|(p, h)| (p, recorded::placed(h, operand, 2))).collect()
|
||
};
|
||
let (la, lb) = (traced(la, a, 0), traced(lb, b, 1));
|
||
let pair = |(x, hx): &Traced, (y, hy): &Traced, op: BoolOp| -> Result<Option<Traced>> {
|
||
Ok(boolean(x, y, op)?.map(|r| {
|
||
let h = recorded::of_pair(&r, hx, hy);
|
||
(r, h)
|
||
}))
|
||
};
|
||
let mut out: Vec<Traced> = Vec::new();
|
||
match op {
|
||
BoolOp::Cut => {
|
||
for x in la {
|
||
let mut cur = Some(x);
|
||
for y in &lb {
|
||
cur = match cur {
|
||
Some(c) if boxes_meet(&c.0, &y.0) => pair(&c, y, BoolOp::Cut)?,
|
||
other => other,
|
||
};
|
||
}
|
||
out.extend(cur);
|
||
}
|
||
}
|
||
BoolOp::Intersect => {
|
||
for x in &la {
|
||
for y in &lb {
|
||
if boxes_meet(&x.0, &y.0)
|
||
&& let Some(r) = pair(x, y, BoolOp::Intersect)?
|
||
{
|
||
out.push(r);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
BoolOp::Union => {
|
||
out = la;
|
||
for y in lb {
|
||
let mut cur = y;
|
||
let mut rest = Vec::new();
|
||
for x in out {
|
||
if boxes_meet(&x.0, &cur.0) {
|
||
match pair(&x, &cur, BoolOp::Union)? {
|
||
Some(u) if u.0.solid.boundaries().len() == 1 => cur = u,
|
||
_ => rest.push(x),
|
||
}
|
||
} else {
|
||
rest.push(x);
|
||
}
|
||
}
|
||
rest.push(cur);
|
||
out = rest;
|
||
}
|
||
}
|
||
}
|
||
recorded::join(out, &record::counts(&[a, b]))
|
||
}
|
||
|
||
/// Bodies whose bounding boxes are clearly apart, as one body of several pieces, without any
|
||
/// intersection work (many identical pattern tools, #261). `None` when any two may meet, or a
|
||
/// body is a mesh: the caller unions those the usual way.
|
||
pub fn union_apart(bodies: &[Body]) -> Result<Option<Body>> {
|
||
if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) {
|
||
return Ok(None);
|
||
}
|
||
let mut boxes = Vec::with_capacity(bodies.len());
|
||
for b in bodies {
|
||
// A coarse mesh is enough: the chord tolerance bounds how far the true surface lies
|
||
// outside it, and the box grows by twice that.
|
||
let tol = (b.size() * 0.02).max(1e-3);
|
||
let bb = b.tessellate(tol)?.bounds();
|
||
if bb.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let pad = Vec3::new(1.0, 1.0, 1.0) * (2.0 * tol);
|
||
boxes.push(solvecraft_geom::Aabb3 { min: bb.min - pad, max: bb.max + pad });
|
||
}
|
||
// Sweep along x: only boxes whose x ranges overlap are compared.
|
||
let mut order: Vec<usize> = (0..boxes.len()).collect();
|
||
order.sort_by(|&i, &j| boxes.get(i).map(|b| b.min.x).unwrap_or(0.0).total_cmp(&boxes.get(j).map(|b| b.min.x).unwrap_or(0.0)));
|
||
for (k, &i) in order.iter().enumerate() {
|
||
let Some(a) = boxes.get(i) else { continue };
|
||
for &j in order.iter().skip(k + 1) {
|
||
let Some(b) = boxes.get(j) else { continue };
|
||
if b.min.x > a.max.x {
|
||
break;
|
||
}
|
||
if a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z {
|
||
return Ok(None);
|
||
}
|
||
}
|
||
}
|
||
// The pieces stay as they are, side by side: each face and edge of the result is its piece's
|
||
// own (the history over `bodies`).
|
||
let Some(joined) = join_pieces(bodies.to_vec())? else { return Ok(None) };
|
||
let operands: Vec<&Body> = bodies.iter().collect();
|
||
let faces = bodies
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(operand, b)| {
|
||
(0..b.face_count()).map(move |face| (std::collections::BTreeSet::from([crate::history::FaceOrigin::Face { operand, face }]), true))
|
||
})
|
||
.collect();
|
||
let mut edges = HashMap::new();
|
||
for (operand, b) in bodies.iter().enumerate() {
|
||
edges.extend(record::edge_origins(&b.solid, operand));
|
||
}
|
||
Ok(Some(record::traced(joined, &record::counts(&operands), Some(record::regions(faces)), Some(&edges))))
|
||
}
|
||
|
||
/// Bodies (apart from each other) as one body of several pieces.
|
||
pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||
match pieces.len() {
|
||
0 => Ok(None),
|
||
1 => Ok(pieces.into_iter().next()),
|
||
_ => {
|
||
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
|
||
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
|
||
let protected = pieces.iter().any(|piece| piece.regularization_protected);
|
||
Body::new(solid).map(|mut body| {
|
||
body.regularization_protected = protected;
|
||
Some(body)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||
}
|
||
if let Some(placed) = body.mesh_transformed(&m) {
|
||
let history = crate::history::History::identity(&placed);
|
||
return placed.with_history(history, &[body]);
|
||
}
|
||
guard("transform", || {
|
||
let mat = mt::Matrix4::new(
|
||
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
|
||
m[3][3],
|
||
);
|
||
// An affine placement retains face order and appearance metadata. Its edges are the
|
||
// body's by identity: a reflection turns the solid inside out, which walks each face's
|
||
// edges the other way and so changes the edge order.
|
||
let sources = body.transformed_sources(m)?;
|
||
let (solid, copies) = placed_topology_mapped(&body.solid, mat)?;
|
||
let mut rec = record::Rebuild::default();
|
||
rec.pieces(&copies);
|
||
for (copy, face) in solid.face_iter().zip(body.solid.face_iter()) {
|
||
rec.whole(copy, face);
|
||
}
|
||
let placed = record::rebuilt(solid, &[&body.solid], &rec)?;
|
||
Ok(placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body))
|
||
})
|
||
}
|
||
|
||
/// `source` moved by `matrix`, keeping its face and edge order (transforms report an identity
|
||
/// [`crate::history::History`] on that basis). Built here rather than by generic topology
|
||
/// builders: a STEP periodic face may traverse a seam edge twice, and those reject that wire
|
||
/// while cloning even though placement preserves its topology.
|
||
pub(crate) fn placed_topology(source: &Solid, matrix: mt::Matrix4) -> Result<Solid> {
|
||
placed_topology_mapped(source, matrix).map(|(solid, _)| solid)
|
||
}
|
||
|
||
/// [`placed_topology`], with each edge of `source` (by identity) and its copy.
|
||
fn placed_topology_mapped(source: &Solid, matrix: mt::Matrix4) -> Result<(Solid, HashMap<mt::EdgeID, mt::Edge>)> {
|
||
use mt::{Transform, Transformed};
|
||
let mut vertices = HashMap::new();
|
||
let mut edges = HashMap::new();
|
||
let mut shells = Vec::new();
|
||
let mut face_count = 0usize;
|
||
let mut edge_visits = 0usize;
|
||
for shell in source.boundaries() {
|
||
let mut faces = Vec::new();
|
||
for face in shell {
|
||
face_count = face_count.saturating_add(1);
|
||
if face_count > 500_000 {
|
||
return Err(KernelError::Invalid("placement exceeds face work limit".into()));
|
||
}
|
||
let mut wires = Vec::new();
|
||
for wire in face.absolute_boundaries() {
|
||
let mut mapped = Vec::new();
|
||
for edge in wire {
|
||
edge_visits = edge_visits.saturating_add(1);
|
||
if edge_visits > 4_000_000 {
|
||
return Err(KernelError::Invalid("placement exceeds edge work limit".into()));
|
||
}
|
||
if let std::collections::hash_map::Entry::Vacant(entry) = edges.entry(edge.id()) {
|
||
for vertex in [edge.absolute_front(), edge.absolute_back()] {
|
||
if let std::collections::hash_map::Entry::Vacant(entry) = vertices.entry(vertex.id()) {
|
||
let point = matrix.transform_point(vertex.point());
|
||
if !from_p3(point).is_finite() {
|
||
return Err(KernelError::Invalid("placement produced nonfinite geometry".into()));
|
||
}
|
||
entry.insert(mt::Vertex::new(point));
|
||
}
|
||
}
|
||
let front =
|
||
vertices.get(&edge.absolute_front().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
|
||
let back = vertices.get(&edge.absolute_back().id()).ok_or_else(|| KernelError::Failed("placement vertex missing".into()))?;
|
||
entry.insert(mt::Edge::new_unchecked(front, back, edge.curve().transformed(matrix)));
|
||
}
|
||
let copied = edges.get(&edge.id()).ok_or_else(|| KernelError::Failed("placement edge missing".into()))?;
|
||
mapped.push(if edge.orientation() { copied.clone() } else { copied.inverse() });
|
||
}
|
||
wires.push(mt::Wire::from(mapped));
|
||
}
|
||
// Wire connectivity, seam sharing and orientation are copied exactly;
|
||
// no new topology is introduced by this geometry-only operation.
|
||
let mut copied = mt::Face::new_unchecked(wires, face.surface().transformed(matrix));
|
||
if !face.orientation() {
|
||
copied.invert();
|
||
}
|
||
faces.push(copied);
|
||
}
|
||
shells.push(mt::Shell::from(faces));
|
||
}
|
||
Ok((Solid::new_unchecked(shells), edges))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[test]
|
||
fn affine_placement_keeps_body_and_face_paint_attached() {
|
||
let paint = crate::Paint { opacity: 0.4, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.2, 0.1], opacity: 0.3 }] };
|
||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap().with_color(Some([0.2, 0.6, 0.8])).with_paint(Some(paint.clone()));
|
||
let source_faces = body.faces(0.01).unwrap();
|
||
let transform = [[0.0, 2.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [30.0, 40.0, 50.0, 1.0]];
|
||
let placed = transform_matrix(&body, transform).unwrap();
|
||
assert_eq!(placed.color(), body.color());
|
||
assert_eq!(placed.paint(), Some(&paint));
|
||
let faces = placed.faces(0.01).unwrap();
|
||
for source in source_faces {
|
||
let target = faces.iter().find(|f| f.index == source.index).unwrap();
|
||
let p = source.centroid;
|
||
assert!(target.centroid.dist(Vec3::new(30.0 - p.y, 40.0 + 2.0 * p.x, 50.0 + 0.5 * p.z)) < 1e-6);
|
||
}
|
||
}
|
||
|
||
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
|
||
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
|
||
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
|
||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||
}
|
||
{
|
||
// Same motion as a matrix: rotate about the line through `origin`, then translate.
|
||
let ax = if angle.abs() > 1e-12 { axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))? } else { Vec3::Z };
|
||
let (c, s) = (angle.cos(), angle.sin());
|
||
let t = 1.0 - c;
|
||
let r = [
|
||
[t * ax.x * ax.x + c, t * ax.x * ax.y + s * ax.z, t * ax.x * ax.z - s * ax.y],
|
||
[t * ax.x * ax.y - s * ax.z, t * ax.y * ax.y + c, t * ax.y * ax.z + s * ax.x],
|
||
[t * ax.x * ax.z + s * ax.y, t * ax.y * ax.z - s * ax.x, t * ax.z * ax.z + c],
|
||
];
|
||
let ro = Vec3::new(
|
||
r[0][0] * origin.x + r[1][0] * origin.y + r[2][0] * origin.z,
|
||
r[0][1] * origin.x + r[1][1] * origin.y + r[2][1] * origin.z,
|
||
r[0][2] * origin.x + r[1][2] * origin.y + r[2][2] * origin.z,
|
||
);
|
||
let tr = origin - ro + translate;
|
||
let m = [[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]];
|
||
transform_matrix(body, m)
|
||
}
|
||
}
|
||
|
||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||
/// (only the non-empty ones).
|
||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||
let mut out = Vec::new();
|
||
for positive in [true, false] {
|
||
out.extend(half_space(body, plane, positive)?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The part of a body on one side of a plane (`None` when nothing is there): its intersection
|
||
/// with [`half_box`] (whose history it carries, over the body and the box).
|
||
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
|
||
boolean(body, &half_box(body, plane, positive)?, BoolOp::Intersect)
|
||
}
|
||
|
||
/// A box covering everything of `body` on one side of a plane, its start cap in the plane.
|
||
pub(crate) fn half_box(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Body> {
|
||
body.require_brep("split")?;
|
||
let size = body.size();
|
||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||
for v in body.solid.vertex_iter() {
|
||
bb.add(crate::body::from_p3(v.point()));
|
||
}
|
||
let reach = (bb.diagonal() + plane.origin.dist(bb.center())) * 2.0 + size;
|
||
let c = plane.to_local(bb.center());
|
||
let sq = solvecraft_geom::Region2 {
|
||
outer: solvecraft_geom::Loop2::polygon(&[
|
||
solvecraft_geom::Vec2::new(c.x - reach, c.y - reach),
|
||
solvecraft_geom::Vec2::new(c.x + reach, c.y - reach),
|
||
solvecraft_geom::Vec2::new(c.x + reach, c.y + reach),
|
||
solvecraft_geom::Vec2::new(c.x - reach, c.y + reach),
|
||
]),
|
||
holes: vec![],
|
||
};
|
||
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
|
||
crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "placement_tests.rs"]
|
||
mod placement_tests;
|