Files
solidcraft-upstream/crates/kernel/src/prism_local.rs
T
2026-10-16 16:26:11 -04:00

354 lines
16 KiB
Rust

//! Booleans of a prism tool with a target that is a prism along the tool's direction only round
//! the tool: a pocket made deeper in a plate whose rim is chamfered, a ring cut round a
//! counterbored hole in a chamfered disc. The tool's walls lie on the target's there, which the
//! B-rep boolean can't intersect, and the 2D route ([`crate::prism::prism_boolean`]) declines the
//! target for a face far away (the chamfer) that is neither a wall along the direction nor a cap
//! square to it.
//!
//! The target is split by planes into a column round the tool that holds none of those faces;
//! the column takes the exact 2D boolean, and the pieces are glued back along the cut faces, as
//! [`crate::polybool::sliced_planar_boolean`] does for an all-planar tool beside curved faces.
//! The cuts are imprints (no boolean), each placed beside the tool on one of three axes: the
//! tool's direction and two square to it. A cut that leaves every offending face of the middle
//! piece beyond it goes first; otherwise one close beside the tool, which splits them (a
//! chamfer all round a disc is split into two pieces apart, which the next cut leaves out).
use solidcraft_geom::{Plane, Vec3};
use truck_modeling as mt;
use crate::body::Body;
use crate::boolean_record::{self as recorded, Traced};
use crate::{BoolOp, KernelError, Result};
/// Cuts at most, each splitting the middle piece once.
const MAX_CUTS: usize = 6;
/// Cuts made with half boxes at most (two booleans each, with their retries): an imprint traced
/// wrong across a curved face is rare, and the booleans cost seconds on curved parts.
const MAX_BOX_CUTS: usize = 2;
/// Operands a piece's history is over: `a`, `b`, then the two half boxes each cut may be made
/// with (dropped from the result, whose faces are none of theirs).
const OPERANDS: usize = 2 + 2 * MAX_CUTS;
thread_local! {
/// Set while [`local_prism_boolean`] runs: the booleans it makes a cut with (a piece and a
/// half box) take the general path, not this one again.
static RUNNING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// An extent along each of three axes.
type Extent = [(f64, f64); 3];
/// [`crate::prism::prism_boolean`] of `a` and the prism `b`, taken in a column of `a` round `b`
/// (see the module notes). `None` when `b` is not a prism, `a` is one along `b`'s direction
/// already, or no column round `b` is clear of `a`'s other faces: the caller goes on as before.
pub(crate) fn local_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
// A cut or an intersection: what a tool meeting the target's walls fails on. A union (a
// shell's cavity joined with its opening) is left to the general path.
if !matches!(op, BoolOp::Cut | BoolOp::Intersect) || a.face_count() > 4096 || RUNNING.with(std::cell::Cell::get) {
return None;
}
/// Clears the flag however the boolean ends, a caught panic included.
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
RUNNING.with(|r| r.set(false));
}
}
RUNNING.with(|r| r.set(true));
let _reset = Reset;
let d = crate::prism::prism_axis(b)?;
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let frame = Plane::from_normal(Vec3::ZERO, d)?;
let axes = [frame.x, frame.y, d];
// Extents come from meshes this close to the faces, widened by as much.
let mesh_tol = (size * 1e-3).max(1e-4);
let tool = widened(extent(&b.tessellate(mesh_tol).ok()?.positions, axes)?, mesh_tol);
let gap = size * 1e-4 + 1e-6;
let pieces = survey(a, d, tol, mesh_tol, axes)?.pieces;
if pieces.is_empty() {
return None;
}
// A piece of an offending face within a hair of the tool's box: no column round the tool
// can leave it out, so nothing is cut.
let reach = size * 2e-3 + 2.0 * gap;
if pieces.iter().any(|(_, e)| e.iter().zip(&tool).all(|((c0, c1), (t0, t1))| *c1 > t0 - reach && *c0 < t1 + reach)) {
return None;
}
let run = || -> Result<Option<Body>> {
let fail = |m: &str| KernelError::Failed(format!("prism boolean in a column round the tool: {m}"));
let mut middle: Traced = (a.clone(), recorded::kept(a, 0, OPERANDS));
let mut outside: Vec<Traced> = Vec::new();
// The sides already cut (axis, positive side): each is cut once.
let mut cut: Vec<(usize, bool)> = Vec::new();
let mut box_cuts = 0;
for _ in 0..MAX_CUTS {
let survey = survey(&middle.0, d, tol, mesh_tol, axes).ok_or_else(|| fail("the middle piece"))?;
let off = survey.faces();
if off.is_empty() {
break;
}
let (k, places, keep_positive) =
next_cut(&off, &tool, &survey.here, &survey.flat, &cut, axes, size, gap).ok_or_else(|| fail("no cut leaves the other faces out"))?;
cut.push((k, !keep_positive));
let axis = axes[k];
let (u, w) = (axes[(k + 1) % 3], axes[(k + 2) % 3]);
// The first of the places where the piece splits into parts that add up to it (a
// plane whose section through the piece doesn't close is passed over). The cut's
// half boxes, if it takes them, are operands `slot` and `slot + 1`.
let slot = 2 * cut.len();
let (away, kept) = places
.iter()
.enumerate()
.find_map(|(i, x)| {
let plane = Plane::new(axis * *x, u, w)?;
let with_boxes = i == 0 && box_cuts < MAX_BOX_CUTS;
// Only a plane across an offending face can be traced wrong (a cone's piece
// meshed past its bounds): the others are taken as they are.
let verify = survey.pieces.iter().any(|(_, e)| e[k].0 < *x && e[k].1 > *x);
let r = split(&middle, &plane, axis, *x, keep_positive, slot, with_boxes, verify)?;
box_cuts += usize::from(r.2);
Some((r.0, r.1))
})
.ok_or_else(|| fail("a cut beside the tool"))?;
outside.extend(away);
middle = kept;
}
if !survey(&middle.0, d, tol, mesh_tol, axes).is_some_and(|s| s.pieces.is_empty()) {
return Err(fail("other faces are left round the tool"));
}
let r = match crate::prism::prism_boolean(&middle.0, b, op) {
Some(r) => r?,
None => return Err(fail("the column is not a prism along the tool")),
};
crate::polybool::reassembled(a, b, op, &middle, r, outside, OPERANDS, &fail)
};
Some(run())
}
/// `piece` split by `plane` (at `x` along `axis`, square to it): the parts away from the tool
/// and the one on its side (positive along `axis` when `keep_positive`), with their histories.
/// An imprint of the plane, taken as it is unless `verify`; else where its parts don't add up to
/// the piece (a plane across a cone can be traced wrong) and `with_boxes`, the piece's intersections with the half boxes
/// either side (the cut's operands `slot` and `slot + 1`), and then whether it took them. `None`
/// when neither adds up.
#[allow(clippy::too_many_arguments)]
fn split(
piece: &Traced,
plane: &Plane,
axis: Vec3,
x: f64,
keep_positive: bool,
slot: usize,
with_boxes: bool,
verify: bool,
) -> Option<(Vec<Traced>, Traced, bool)> {
if !verify {
let (away, kept) = crate::polybool::split_beside(piece, plane, axis, x, keep_positive)?;
return Some((away, kept, false));
}
let whole = crate::measure(&piece.0).ok()?.volume;
let adds_up = |away: &[Traced], kept: &Traced| {
let sum: Option<f64> = away.iter().chain([kept]).map(|(p, _)| crate::measure(p).ok().map(|m| m.volume)).sum();
sum.is_some_and(|sum| (sum - whole).abs() <= whole.abs() * 5e-5 + 1e-9)
};
if let Some((away, kept)) = crate::polybool::split_beside(piece, plane, axis, x, keep_positive)
&& adds_up(&away, &kept)
{
return Some((away, kept, false));
}
if !with_boxes {
return None;
}
let boxes = (crate::ops::half_box(&piece.0, plane, !keep_positive).ok()?, crate::ops::half_box(&piece.0, plane, keep_positive).ok()?);
let (h_away, h_kept) = (recorded::kept(&boxes.0, slot, OPERANDS), recorded::kept(&boxes.1, slot + 1, OPERANDS));
let away = crate::ops::boolean(&piece.0, &boxes.0, BoolOp::Intersect).ok()?;
let kept = crate::ops::boolean(&piece.0, &boxes.1, BoolOp::Intersect).ok()??;
let away: Vec<Traced> = away
.into_iter()
.map(|o| {
let h = recorded::of_pair(&o, &piece.1, &h_away);
(o, h)
})
.collect();
let kept: Traced = {
let h = recorded::of_pair(&kept, &piece.1, &h_kept);
(kept, h)
};
adds_up(&away, &kept).then_some((away, kept, true))
}
/// The extent of `points` along each of `axes`; `None` for no points.
fn extent(points: &[Vec3], axes: [Vec3; 3]) -> Option<Extent> {
if points.is_empty() {
return None;
}
let mut e = [(f64::INFINITY, f64::NEG_INFINITY); 3];
for p in points {
for (slot, axis) in e.iter_mut().zip(axes) {
let t = p.dot(axis);
*slot = (slot.0.min(t), slot.1.max(t));
}
}
e.iter().all(|(lo, hi)| lo.is_finite() && hi.is_finite()).then_some(e)
}
/// What a cut is chosen from: a piece's extent (`here`), the mesh triangles of its faces that
/// are neither a wall along the direction nor a plane square to it (the faces that keep it from
/// being a prism along it: each one's index and extent), and its planar faces (unit normal,
/// offset along it) for a cut to keep out of. Extents are widened by the mesh's tolerance.
struct Survey {
here: Extent,
pieces: Vec<(usize, Extent)>,
flat: Vec<(Vec3, f64)>,
}
impl Survey {
/// The extent of each offending face.
fn faces(&self) -> Vec<Extent> {
let mut faces: Vec<(usize, Extent)> = Vec::new();
for (i, e) in &self.pieces {
match faces.iter_mut().find(|(j, _)| j == i) {
Some((_, acc)) => {
for (x, y) in acc.iter_mut().zip(e) {
*x = (x.0.min(y.0), x.1.max(y.1));
}
}
None => faces.push((*i, *e)),
}
}
faces.into_iter().map(|(_, e)| e).collect()
}
}
/// [`Survey`] of `body` along `d`, from one mesh of it. `None` when it can't be meshed.
fn survey(body: &Body, d: Vec3, tol: f64, mesh_tol: f64, axes: [Vec3; 3]) -> Option<Survey> {
let mut work = 262_144usize;
let mut flat = Vec::new();
let off: Vec<bool> = body
.solid
.face_iter()
.map(|f| {
let surface = f.oriented_surface();
match &surface {
mt::Surface::Plane(p) => {
let n = p.normal();
let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { return true };
let o = p.origin();
flat.push((n, n.dot(Vec3::new(o.x, o.y, o.z))));
let along = n.dot(d).abs();
// NaN counts as inside, as the comparisons it replaces did.
along.is_nan() || (1e-9..=1.0 - 1e-9).contains(&along)
}
_ => !crate::prism::wall_along(&surface, d, tol, &mut work),
}
})
.collect();
let mesh = body.tessellate(mesh_tol).ok()?;
let here = widened(extent(&mesh.positions, axes)?, mesh_tol);
let mut pieces = Vec::new();
if off.iter().any(|x| *x) {
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
let i = *fi as usize;
if !off.get(i).copied().unwrap_or(false) {
continue;
}
let Some(tri) = mesh.tri(t) else { continue };
if let Some(e) = extent(&tri, axes) {
pieces.push((i, widened(e, mesh_tol)));
}
}
}
Some(Survey { here, pieces, flat })
}
/// `e` grown by `by` each way.
fn widened(mut e: Extent, by: f64) -> Extent {
for (lo, hi) in e.iter_mut() {
*lo -= by;
*hi += by;
}
e
}
/// The next cut of the middle piece (its extent `here`, its planar faces `flat`): the axis, the
/// place along it and whether the tool's side is the positive one. A cut leaving the most
/// offending faces (`off`) wholly beyond it, on a side not `cut` yet, placed halfway to the nearest of them; failing
/// that, one beside the tool across the most of them (on an axis square to the direction
/// first). `None` when no cut beside the tool has any offending face beyond it.
#[allow(clippy::too_many_arguments)]
fn next_cut(
off: &[Extent],
tool: &Extent,
here: &Extent,
flat: &[(Vec3, f64)],
cut: &[(usize, bool)],
axes: [Vec3; 3],
size: f64,
gap: f64,
) -> Option<(usize, Vec<f64>, bool)> {
// Places between `near` (beside the tool) and `far`, best first, out of the planes of the
// piece's faces square to the axis.
let place = |k: usize, near: f64, far: f64| -> Option<Vec<f64>> {
let axis = axes[k];
let places: Vec<f64> = [0.5, 0.37, 0.63, 0.25, 0.75, 0.1, 0.9]
.iter()
.map(|t| near + (far - near) * t)
.filter(|x| !flat.iter().any(|(n, o)| n.dot(axis).abs() > 1.0 - 1e-9 && (o * n.dot(axis).signum() - x).abs() < gap))
.collect();
(!places.is_empty()).then_some(places)
};
let mut whole: Option<(usize, usize, Vec<f64>, bool)> = None;
let mut across: Option<(usize, usize, Vec<f64>, bool)> = None;
let beside = size * 2e-3;
for k in [0usize, 1, 2] {
let (t0, t1) = tool[k];
let (h0, h1) = here[k];
for positive_side in [true, false] {
if cut.contains(&(k, positive_side)) {
continue;
}
// Past the tool on this side: is there any of the piece to cut away?
let (edge, room) = if positive_side { (t1 + gap, h1 - t1) } else { (t0 - gap, t0 - h0) };
if room <= 2.0 * gap {
continue;
}
let beyond: Vec<f64> = off
.iter()
.filter_map(|e| {
let (c0, c1) = e[k];
if positive_side { (c0 > edge).then_some(c0) } else { (c1 < edge).then_some(c1) }
})
.collect();
if let Some(nearest) = beyond.iter().copied().reduce(if positive_side { f64::min } else { f64::max }) {
let Some(x) = place(k, edge, nearest) else { continue };
if whole.as_ref().is_none_or(|(_, n, ..)| beyond.len() > *n) {
whole = Some((k, beyond.len(), x, !positive_side));
}
continue;
}
if k == 2 && across.is_some() {
continue;
}
let near = edge + if positive_side { beside } else { -beside };
let crossing = off.iter().filter(|e| if positive_side { e[k].1 > near } else { e[k].0 < near }).count();
let limit = if positive_side { h1 } else { h0 };
if crossing == 0 {
continue;
}
let Some(x) = place(k, edge, near + (near - edge))
.map(|p| p.into_iter().filter(|x| if positive_side { *x < limit - gap } else { *x > limit + gap }).collect::<Vec<_>>())
.filter(|p| !p.is_empty())
else {
continue;
};
if across.as_ref().is_none_or(|(_, n, ..)| crossing > *n) {
across = Some((k, crossing, x, !positive_side));
}
}
}
whole.or(across).map(|(k, _, x, keep_positive)| (k, x, keep_positive))
}