Files
solidcraft/crates/kernel/src/polybool.rs
T
fe3b14e798 refactor: Aabb2 and point-built Aabb3 for every hand-rolled box (#2428)
* Iterate instead of indexing in nine loops

Replace index loops that look up neighbours by position with enumerate,
zip(cycle().skip(1)) for wrap-around pairs and zip(skip(1)) for
consecutive pairs. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: ring_edges for the edges of a closed ring

Every walk over a polygon's edges, the last back to the first, spelled
x.iter().zip(x.iter().cycle().skip(1)); eighteen of them now call
geom::ring_edges. Three redundant take(len) calls go with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Ring, a closed ring that knows it is closed

A Ring holds items in order around a closed polygon, loop or fan; edges()
walks each with the next and the last with the first, corners() each
with both neighbours. Ring<Vec2> has signed_area and even-odd contains.

Loop2::polyline returns a Ring, as do doc's face_loops and
boundary_loops, kernel's coplanar wire samples, CapLoop's segments,
tess pieces' rings, the gizmo's plane quads and the navigation cube's
facets. Their walks take edges() or corners() instead of wrapping an
index; sketch's text-hole test and doc's poly_area become contains and
signed_area. Loop2::joints pairs each segment with the next.

Replaces the ring_edges free function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Chain, Ring and Path; bounds for a chain of points

Ring becomes Chain<T, Closed> beside Path = Chain<T, Open>: edges()
closes a ring and stops at a path's end, Path::close makes the ring,
and bounds() (the empty box for no points) serves both. Eight
hand-rolled min/max folds over a loop's polyline call bounds(); doc's
flat_polyline returns a Ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: area and inside on Ring for any planar point

Ring's signed_area and contains take any Planar point (Vec2 or
[f64; 2]). The cam profile in joints_cam is a Ring: its own even-odd
test goes, and its edge walks, gap and area take edges(). face_params'
loop area, the text path's winding, tess pieces' ring area and the
copies in three tests call signed_area or contains; the formulas are
the same term for term.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: sheet's closed outlines go through Ring

dedup takes and returns a Ring (dropping a last point back on the first
is what makes it a ring's), near_outline walks a Ring's edges, and the
copy of it beside fold checks calls it. point_in goes: its callers ask
the Ring. Its formula anchored each crossing on the other end of the
edge, so the two can differ only for a point on an edge to the last
digit; one caller sets such points aside first, the others test a
region's interior point, a cut's midpoint or another outline's corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: cam tests build their outlines with Ring::from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: area_vector on Ring<Vec3>; three Newell sums use it

Ring<Vec3>::area_vector is half the sum of a × b round the ring. heal's
loop_area, lip's outer-loop turning and the revolve's loop orientation
summed it by hand with a wrapped index; they take it from the Ring
(the half is exact, and two of them only read its sign).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb3 from points; boxes built and asked through it

Aabb3 collects from points (skipping non-finite ones, as add does) and
gains inflated, contains and meets. Fifteen hand-rolled min/max folds
over points or boxes in kernel, doc and engine become collect or union,
and their "within tol of the box" tests become inflated(tol).contains.
blend's refused-edge boxes and naming's face index hold an Aabb3.

A NaN point is now outside every box, where the old negated
comparisons let it in; an infinite point no longer stretches a box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb2, the plane's box; 2D folds and box tuples use it

Aabb2 mirrors Aabb3 (collect, add, union, inflated, contains, meets)
and adds relative, a point's place in the box. sketch's link and
link_loops each had that formula; link_loops' boundaries, prism's
segment and loop boxes, profiles' shape boxes and grid, and ends'
segment box hold an Aabb2 instead of a (low, high) pair. Folds in the
cam, sketch bounds, sheet's perforation grid and the sketch clipboard
collect one.

Boxes are built from the same terms (a round's box keeps c - r - tol).
As with Aabb3, a NaN point is outside every box and an infinite point
no longer stretches one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel: prism's segment boxes are plain Aabb2s

The SegBox alias named a (low, high) pair; Aabb2 says it itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Chain::bounds is an Aabb2; the boxes #2382 left as pairs follow

Chain::bounds returns an Aabb2. Its callers keep it: sheet's hole and
cut boxes (loop_box), letter boxes, island outlines and line-near-cut
test use meets, contains and inflated; profile's outline overlap and
max_clearance, profiles' box tests and the cam test read its corners.
flat_size and the flat pattern's JSON size are the union of the loops'
boxes (0 × 0 for no loop, where the pair fold gave -∞).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb3::intersection; hand-grown and hand-met boxes use the box

Aabb3::intersection is the box both hold. ops' four overlap boxes of a
boolean's operands take it, and seven boxes grown by hand (ops' body
boxes, the distance set's bounds, coplanar's face box, eval_more's
padded mesh box, compare's group and region boxes) call inflated with
the same amount on every side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:44:49 +00:00

1315 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Exact-plane booleans for bodies whose faces are all planar (BSP-tree CSG on polygons, in
//! the classic style), used when the B-rep boolean fails — typically on coincident faces, such
//! as a wall flush with the side of its base. The polygons are rebuilt into a B-rep: coplanar
//! pieces are merged into faces, T-junctions are split so neighbouring faces share edges.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, edge_order, from_p3, p3};
use crate::boolean_record::{self as recorded, Traced};
use crate::heal::find;
use crate::history::{EdgeOrigin, FaceOrigin, History};
use crate::{BoolOp, KernelError, Result, guard, record};
const MAX_DEPTH: usize = 4000;
const MAX_POLYS: usize = 200_000;
#[derive(Clone, Copy, Debug)]
struct PlaneEq {
n: Vec3,
w: f64,
}
impl PlaneEq {
fn flipped(self) -> PlaneEq {
PlaneEq { n: -self.n, w: -self.w }
}
}
#[derive(Clone, Debug)]
struct Poly {
v: Vec<Vec3>,
plane: PlaneEq,
/// The operand face this is (a piece of).
src: FaceOrigin,
}
impl Poly {
fn flip(&mut self) {
self.v.reverse();
self.plane = self.plane.flipped();
}
}
const COPLANAR: u8 = 0;
const FRONT: u8 = 1;
const BACK: u8 = 2;
const SPANNING: u8 = 3;
/// Split `p` by `pl` into the four buckets.
fn split(pl: PlaneEq, p: &Poly, eps: f64, cf: &mut Vec<Poly>, cb: &mut Vec<Poly>, f: &mut Vec<Poly>, b: &mut Vec<Poly>) {
let mut ptype = 0u8;
let types: Vec<u8> =
p.v.iter()
.map(|v| {
let t = pl.n.dot(*v) - pl.w;
let ty = if t < -eps {
BACK
} else if t > eps {
FRONT
} else {
COPLANAR
};
ptype |= ty;
ty
})
.collect();
match ptype {
COPLANAR => {
if pl.n.dot(p.plane.n) > 0.0 {
cf.push(p.clone());
} else {
cb.push(p.clone());
}
}
FRONT => f.push(p.clone()),
BACK => b.push(p.clone()),
_ => {
let (mut fv, mut bv) = (Vec::new(), Vec::new());
let n = p.v.len();
for i in 0..n {
let j = (i + 1) % n;
let (Some(&ti), Some(&tj), Some(&vi), Some(&vj)) = (types.get(i), types.get(j), p.v.get(i), p.v.get(j)) else { continue };
if ti != BACK {
fv.push(vi);
}
if ti != FRONT {
bv.push(vi);
}
if (ti | tj) == SPANNING {
let den = pl.n.dot(vj - vi);
if den.abs() > 1e-300 {
let t = (pl.w - pl.n.dot(vi)) / den;
let x = vi.lerp(vj, t);
fv.push(x);
bv.push(x);
}
}
}
if fv.len() >= 3 {
f.push(Poly { v: fv, plane: p.plane, src: p.src });
}
if bv.len() >= 3 {
b.push(Poly { v: bv, plane: p.plane, src: p.src });
}
}
}
}
#[derive(Default)]
struct Node {
plane: Option<PlaneEq>,
front: Option<Box<Node>>,
back: Option<Box<Node>>,
polys: Vec<Poly>,
}
impl Node {
fn build(&mut self, polys: Vec<Poly>, eps: f64, depth: usize) -> Result<()> {
if polys.is_empty() {
return Ok(());
}
if depth > MAX_DEPTH {
return Err(KernelError::Failed("planar boolean: tree too deep".into()));
}
let pl = match self.plane {
Some(p) => p,
None => {
let p = polys.first().map(|p| p.plane).ok_or_else(|| KernelError::Failed("bsp".into()))?;
self.plane = Some(p);
p
}
};
let (mut f, mut b) = (Vec::new(), Vec::new());
let mut cf = Vec::new();
let mut cb = Vec::new();
for p in &polys {
split(pl, p, eps, &mut cf, &mut cb, &mut f, &mut b);
}
self.polys.extend(cf);
self.polys.extend(cb);
if !f.is_empty() {
self.front.get_or_insert_with(Default::default).build(f, eps, depth + 1)?;
}
if !b.is_empty() {
self.back.get_or_insert_with(Default::default).build(b, eps, depth + 1)?;
}
Ok(())
}
fn invert(&mut self) {
for p in &mut self.polys {
p.flip();
}
self.plane = self.plane.map(PlaneEq::flipped);
if let Some(f) = &mut self.front {
f.invert();
}
if let Some(b) = &mut self.back {
b.invert();
}
std::mem::swap(&mut self.front, &mut self.back);
}
fn clip_polys(&self, polys: Vec<Poly>, eps: f64) -> Vec<Poly> {
let Some(pl) = self.plane else { return polys };
let (mut f, mut b) = (Vec::new(), Vec::new());
let (mut cf, mut cb) = (Vec::new(), Vec::new());
for p in &polys {
split(pl, p, eps, &mut cf, &mut cb, &mut f, &mut b);
}
f.extend(cf);
b.extend(cb);
let f = match &self.front {
Some(n) => n.clip_polys(f, eps),
None => f,
};
let b = match &self.back {
Some(n) => n.clip_polys(b, eps),
None => Vec::new(),
};
let mut out = f;
out.extend(b);
out
}
fn clip_to(&mut self, other: &Node, eps: f64) {
let polys = std::mem::take(&mut self.polys);
self.polys = other.clip_polys(polys, eps);
if let Some(f) = &mut self.front {
f.clip_to(other, eps);
}
if let Some(b) = &mut self.back {
b.clip_to(other, eps);
}
}
fn all(&self, out: &mut Vec<Poly>) {
out.extend(self.polys.iter().cloned());
if let Some(f) = &self.front {
f.all(out);
}
if let Some(b) = &self.back {
b.all(out);
}
}
}
/// Why a body's polygons can't be had: in words that finish "the {role} has …" or "the
/// {role}'s …".
#[derive(Debug, PartialEq)]
enum NotPlanar {
/// Faces on curved surfaces, counted by kind.
Curved(std::collections::BTreeMap<&'static str, usize>),
/// Something else (a face that could not be meshed, too many polygons).
Other(String),
}
impl NotPlanar {
fn describe(&self, role: &str) -> String {
match self {
NotPlanar::Curved(kinds) => {
let n: usize = kinds.values().sum();
let list: Vec<&str> = kinds.keys().copied().collect();
format!("the {role} has {n} curved face{} ({})", if n == 1 { "" } else { "s" }, list.join(", "))
}
NotPlanar::Other(m) => format!("the {role}'s {m}"),
}
}
}
/// The kind of curved surface `face` lies on, or None when it lies on a plane. `flat`: whether
/// its mesh is flat. The meshes here are as coarse as a thousandth of the body, which spans a
/// round much smaller than that (a 2 mm corner of a 1.2 m channel) with one flat strip of
/// facets: the surface decides, and the mesh only for a surface none of these kinds fits.
fn curved_kind(face: &mt::Face, flat: bool, tol: f64) -> Option<&'static str> {
use crate::offset::{Surf, surf_of};
if matches!(face.surface(), mt::Surface::Plane(_)) {
return None;
}
match surf_of(face, tol) {
Some(Surf::Plane { .. }) => None,
Some(Surf::Cylinder { .. }) => Some("cylinder"),
Some(Surf::Cone { .. }) => Some("cone"),
Some(Surf::Sphere { .. }) => Some("sphere"),
Some(Surf::Torus { .. }) => Some("torus"),
None => (!flat).then_some("freeform"),
}
}
/// The tolerance [`curved_kind`] tells surfaces apart by: face_surface's, with the body's
/// bounds computed once. Walking to each indexed face separately made curved-body diagnostics
/// quadratic.
fn kind_tol(b: &Body) -> f64 {
(b.size() * 1e-7).max(1e-9) * 100.0
}
/// The plane of each face (from its mesh, which must be flat), or what stands in the way: the
/// curved faces by the kind of surface they lie on.
fn face_planes(b: &Body, faces: &[crate::FaceInfo]) -> std::result::Result<Vec<PlaneEq>, NotPlanar> {
let surface_tol = kind_tol(b);
let mut surfaces = b.solid.face_iter();
let mut curved: std::collections::BTreeMap<&'static str, usize> = std::collections::BTreeMap::new();
let mut out = Vec::with_capacity(faces.len());
for (index, f) in faces.iter().enumerate() {
let Some(surface) = surfaces.next() else {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
};
if f.index != index {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
}
if f.area <= 0.0 {
return Err(NotPlanar::Other(format!("face {} could not be meshed", f.index)));
}
match (f.plane_normal, curved_kind(surface, f.plane_normal.is_some(), surface_tol)) {
(Some(n), None) => out.push(PlaneEq { n, w: n.dot(f.centroid) }),
(None, None) => return Err(NotPlanar::Other(format!("face {} is planar but its mesh is not flat", f.index))),
(_, Some(kind)) => *curved.entry(kind).or_insert(0) += 1,
}
}
if surfaces.next().is_some() {
return Err(NotPlanar::Other("mesh is inconsistent".into()));
}
if curved.is_empty() { Ok(out) } else { Err(NotPlanar::Curved(curved)) }
}
/// Polygons of a body whose faces are all planar, as pieces of its faces as operand `operand`.
fn planar_polys(b: &Body, operand: usize) -> std::result::Result<Vec<Poly>, NotPlanar> {
let tol = (b.size() * 1e-3).max(1e-3);
let unmeshed = |e: KernelError| NotPlanar::Other(format!("faces could not be meshed ({e})"));
let faces = b.faces(tol).map_err(unmeshed)?;
let mesh = b.tessellate(tol).map_err(unmeshed)?;
let planes = face_planes(b, &faces)?;
if mesh.triangles.len() > MAX_POLYS {
return Err(NotPlanar::Other(format!("mesh is too detailed ({} triangles)", mesh.triangles.len())));
}
let bad_mesh = || NotPlanar::Other("mesh is inconsistent".into());
let mut out = Vec::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
let [a, bb, c] = mesh.tri(t).ok_or_else(bad_mesh)?;
let plane = *planes.get(*fi as usize).ok_or_else(bad_mesh)?;
// Snap the vertices onto the exact face plane.
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
let mut v = vec![snap(a), snap(bb), snap(c)];
if (v[1] - v[0]).cross(v[2] - v[0]).dot(plane.n) < 0.0 {
v.reverse();
}
out.push(Poly { v, plane, src: FaceOrigin::Face { operand, face: *fi as usize } });
}
Ok(out)
}
/// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`);
/// pieces of its faces as operand `operand`.
fn faceted_polys(b: &Body, tol: f64, operand: usize) -> Option<Vec<Poly>> {
let faces = b.faces(tol).ok()?;
let mesh = b.tessellate(tol).ok()?;
let planes: Vec<Option<PlaneEq>> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect();
let mut out = Vec::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
let [a, bb, c] = mesh.tri(t)?;
let plane = match planes.get(*fi as usize)? {
Some(pl) => *pl,
None => {
let Some(n) = (bb - a).cross(c - a).normalized() else { continue };
PlaneEq { n, w: n.dot(a) }
}
};
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
let mut v = vec![snap(a), snap(bb), snap(c)];
if (v[1] - v[0]).cross(v[2] - v[0]).dot(plane.n) < 0.0 {
v.reverse();
}
out.push(Poly { v, plane, src: FaceOrigin::Face { operand, face: *fi as usize } });
}
(out.len() <= MAX_POLYS).then_some(out)
}
/// Boolean of a body, its curved faces faceted to `tol`, with a closed triangle mesh (wound
/// outward); the result has planar faces only. For tools the B-rep boolean cannot take, such as
/// the helical groove of a thread.
///
/// The result's history is over `[a, b]`, the mesh's faces being its triangles (and it having
/// no edges): a face of the result lies on faces of `a` (a facet of a curved one) or on
/// triangles of `b`; an edge between facets of one face of `a`, or between triangles of `b`,
/// has no origin.
pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
crate::profile::timed(crate::profile::Kind::Boolean, || faceted_boolean_untimed(a, b, op, tol))
}
fn faceted_boolean_untimed(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
if !(tol.is_finite() && tol > 0.0) {
return Err(KernelError::Invalid("facet tolerance".into()));
}
let Some(pa) = faceted_polys(a, tol, 0) else {
return Err(KernelError::Failed("the body is too detailed to facet".into()));
};
let mut pb = Vec::with_capacity(b.triangles.len());
for (face, t) in b.triangles.iter().enumerate() {
let Some([p, q, r]) = b.tri(t) else { continue };
let Some(n) = (q - p).cross(r - p).normalized() else { continue };
pb.push(Poly { v: vec![p, q, r], plane: PlaneEq { n, w: n.dot(p) }, src: FaceOrigin::Face { operand: 1, face } });
}
let edges = OperandEdges::of(a);
let sources = Sources { counts: vec![(a.face_count(), edges.ends.len()), (b.triangles.len(), 0)], edges: vec![Some(edges), None] };
if pa.len() + pb.len() > MAX_POLYS {
return Err(KernelError::Failed("the bodies are too detailed to facet".into()));
}
let size = if b.positions.is_empty() { a.size() } else { a.size().max(b.positions.iter().copied().collect::<Aabb3>().diagonal()) };
// Fine facets of curved faces meet at shallow angles, where the coplanar test decides
// whether the pieces' boundaries come out consistent: try a few tolerances, and take the
// first whose result is watertight and agrees with the complementary boolean (A − B and
// A ∩ B add up to A; A ∪ B is A plus B less A ∩ B).
// The BSP takes each list's first polygon as its splitting plane. Facets in sweep order
// (a thread's groove, turn after turn) make every splitter cut nothing, so the tree is as
// deep as the list is long: quadratic time and memory, and a long thread ran out of depth
// (#1250). The same lists in a fixed shuffled order give a shallow tree.
let (pa, pb) = (shuffled(pa), shuffled(pb));
let (va, vb) = (polys_volume(&pa), polys_volume(&pb));
let mut last = KernelError::Failed("faceted boolean".into());
for rel in [1e-8, 1e-7, 3e-8, 1e-9, 3e-7] {
let eps = size * rel;
let out = csg(pa.clone(), pb.clone(), op, eps)?;
let both = csg(pa.clone(), pb.clone(), BoolOp::Intersect, eps)?;
let (vo, vi) = (polys_volume(&out), polys_volume(&both));
let expect = match op {
BoolOp::Cut => va - vi,
BoolOp::Union => va + vb - vi,
BoolOp::Intersect => vi,
};
if (vo - expect).abs() > 1e-6 * (va.abs() + vb.abs()) || vi < -1e-9 * (va.abs() + vb.abs()) {
last = KernelError::Failed(format!("faceted boolean: inconsistent volumes ({vo} vs {expect})"));
continue;
}
if out.is_empty() {
return Ok(None);
}
let weld = size * 1e-7;
match rebuild(&out, size, weld, &sources) {
Ok(b) => return Ok(Some(b)),
Err(e) => last = e,
}
// Thin facets at a thread crest can be narrower than the usual reconstruction
// tolerance. Welding/splitting at that coarser tolerance collapses their boundaries
// into a non-manifold edge. Retry the same volume-checked polygons at the BSP's
// finer precision. Allow a small margin for points classified on either side of
// a splitter, then try the exact BSP tolerance if that still collapses a facet.
// Never return a shell that fails the closed-solid check.
for precision in [3.0 * eps, eps] {
if precision < weld {
match rebuild(&out, size, precision, &sources) {
Ok(b) => return Ok(Some(b)),
Err(e) => last = e,
}
}
}
}
Err(last)
}
/// `polys` in a fixed pseudo-random order (the same every run, so results are reproducible).
fn shuffled(mut polys: Vec<Poly>) -> Vec<Poly> {
let mut state = 0x9E37_79B9_7F4A_7C15u64 ^ polys.len() as u64;
let mut next = || {
// SplitMix64.
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
for i in (1..polys.len()).rev() {
polys.swap(i, (next() % (i as u64 + 1)) as usize);
}
polys
}
/// Boolean of two all-planar bodies.
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
let (pa, pb) = match (planar_polys(a, 0), planar_polys(b, 1)) {
(Ok(pa), Ok(pb)) => (pa, pb),
(ra, rb) => {
if let Some(r) = sliced_planar_boolean(a, b, op) {
return r;
}
let why: Vec<String> =
[(ra.err(), "target"), (rb.err(), "tool")].into_iter().filter_map(|(e, role)| e.map(|e| e.describe(role))).collect();
return Err(KernelError::Failed(format!(
"not supported yet: the exact fallback for where the bodies meet (faces that touch, or cross at a shallow angle) takes planar faces only, and {}",
why.join(" and ")
)));
}
};
let size = a.size().max(b.size());
polys_boolean(pa, pb, size, op, size * 1e-9, &Sources::of(&[a, b]))
}
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b, 1).ok()?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
let infos = a.faces(tol).ok()?;
let mesh = a.tessellate(tol).ok()?;
// Each face's plane normal where it is planar (see [`curved_kind`]): a small round meshed
// flat is no plane, and taken for one the middle piece would rebuild it as a chamfer.
let surface_tol = kind_tol(a);
let planes: Vec<Option<Vec3>> =
a.solid.face_iter().zip(&infos).map(|(f, info)| info.plane_normal.filter(|_| curved_kind(f, true, surface_tol).is_none())).collect();
let mut curved: BTreeMap<u32, solidcraft_geom::Aabb3> = BTreeMap::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if planes.get(*fi as usize)?.is_some() {
continue;
}
let bx = curved.entry(*fi).or_insert(solidcraft_geom::Aabb3::EMPTY);
for p in mesh.tri(t)? {
bx.add(p);
}
}
// All planar: the exact boolean itself, not this.
if curved.is_empty() {
return None;
}
let mut tool = solidcraft_geom::Aabb3::EMPTY;
for p in pb.iter().flat_map(|p| &p.v) {
tool.add(*p);
}
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
let flat: Vec<(Vec3, Vec3)> = planes.iter().zip(&infos).filter_map(|(n, f)| Some(((*n)?, f.centroid))).collect();
let gap = size * 1e-4;
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
let axis = *axes.get(k)?;
[0.5, 0.37, 0.63, 0.25, 0.75]
.iter()
.map(|t| near + (far - near) * t)
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
};
// An axis along which every curved face lies clear of the tool: the cuts either side.
let (k, lo, hi) = (0..3).find_map(|k| {
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
let (mut below, mut above) = (None::<f64>, None::<f64>);
for bx in curved.values() {
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
if c1 < t0 - gap {
below = Some(below.map_or(c1, |x| x.max(c1)));
} else if c0 > t1 + gap {
above = Some(above.map_or(c0, |x| x.min(c0)));
} else {
return None;
}
}
let lo = match below {
Some(c) => Some(place(k, t0, c)?),
None => None,
};
let hi = match above {
Some(c) => Some(place(k, t1, c)?),
None => None,
};
Some((k, lo, hi))
})?;
let (u, w) = match k {
0 => (Vec3::Y, Vec3::Z),
1 => (Vec3::Z, Vec3::X),
_ => (Vec3::X, Vec3::Y),
};
let axis = *axes.get(k)?;
let run = || -> Result<Option<Body>> {
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
// Every piece with its history over [a, b, then the boxes of each cut: the one away
// from the middle, the one around it]; the boxes' faces are gone from the result (glued
// away), so they are dropped at the end.
let n = 6;
let mut middle: Traced = (a.clone(), recorded::kept(a, 0, n));
let mut outside: Vec<Traced> = Vec::new();
for (slot, (cut, keep_positive)) in [(2, (lo, true)), (4, (hi, false))] {
let Some(x) = cut else { continue };
let plane = solidcraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
// Split along the plane (an imprint: no boolean); two booleans with half boxes
// where the split can't be sorted into one piece kept and the rest away.
if let Some((away, kept)) = split_beside(&middle, &plane, axis, x, keep_positive) {
outside.extend(away);
middle = kept;
continue;
}
let (away, kept) = (crate::ops::half_box(&middle.0, &plane, !keep_positive)?, crate::ops::half_box(&middle.0, &plane, keep_positive)?);
let (h_away, h_kept) = (recorded::kept(&away, slot, n), recorded::kept(&kept, slot + 1, n));
if let Some(o) = crate::ops::boolean(&middle.0, &away, BoolOp::Intersect)? {
let h = recorded::of_pair(&o, &middle.1, &h_away);
outside.push((o, h));
}
let m = crate::ops::boolean(&middle.0, &kept, BoolOp::Intersect)?.ok_or_else(|| fail("nothing beside the tool"))?;
let h = recorded::of_pair(&m, &middle.1, &h_kept);
middle = (m, h);
}
let pm = planar_polys(&middle.0, 0).map_err(|e| fail(&e.describe("middle piece")))?;
let r = polys_boolean(pm, pb, size, op, size * 1e-9, &Sources::of(&[&middle.0, b]))?;
reassembled(a, b, op, &middle, r, outside, n, &fail)
};
Some(run())
}
/// The middle piece's boolean with `b` (`r`, over `[middle, b]`) and the pieces of `a` split
/// away from the middle (`outside`) glued back into one result over `[a, b]`: the operands past
/// the second (`n` in all, the boxes a piece was cut with) are dropped. `fail` names the route
/// in its errors. Shared with [`crate::prism_local`].
#[allow(clippy::too_many_arguments)]
pub(crate) fn reassembled(
a: &Body,
b: &Body,
op: BoolOp,
middle: &Traced,
r: Option<Body>,
outside: Vec<Traced>,
n: usize,
fail: &dyn Fn(&str) -> KernelError,
) -> Result<Option<Body>> {
let size = a.size().max(b.size());
let h_b = recorded::kept(b, 1, n);
let counts = record::counts(&[a, b]);
if op == BoolOp::Intersect {
return Ok(r.map(|r| {
let h = dropped(recorded::of_pair(&r, &middle.1, &h_b));
r.with_checked_history(h, &counts)
}));
}
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
// they match the pieces outside edge for edge.
let pieces: Vec<Traced> = match r {
Some(m) => {
let healed = recorded::heal_body(&m, m.deep_copy(), size, a.split_keep(), &record::counts(&[&middle.0, b]))?;
let h = recorded::of_pair(&healed, &middle.1, &h_b);
vec![(healed, h)]
}
None => Vec::new(),
};
// Each piece in its lumps: a cut plane crossing a frame or a ring leaves the middle (or
// a piece outside) in several parts, which meet the others along several cut faces.
let lumps = |(whole, h): Traced| -> Result<Vec<Traced>> {
if whole.solid.boundaries().len() == 1 {
return Ok(vec![(whole, h)]);
}
let parts = whole.lumps()?;
let histories = recorded::parts_of(&parts, &whole);
Ok(parts
.into_iter()
.zip(histories)
.map(|(p, hp)| {
let h = match (hp, h.as_ref()) {
(Some(hp), Some(h)) => recorded::composed(hp.substituted(0, h)),
_ => None,
};
(p, h)
})
.collect())
};
let mut all: Vec<Traced> = Vec::new();
for piece in pieces.into_iter().chain(outside) {
all.extend(lumps(piece)?);
}
// Glue pieces that meet along their cut faces until one is left; every cut face pair
// goes (a pair left behind would be a slit through the result).
let mut joined: Vec<Traced> = Vec::new();
while let Some(mut cur) = all.pop() {
loop {
let found = all.iter().enumerate().find_map(|(i, (o, _))| Some((i, crate::coplanar::glue(&cur.0, o)?.ok()?)));
let Some((i, j)) = found else { break };
let other = all.swap_remove(i);
let h = recorded::of_pair(&j, &cur.1, &other.1);
cur = (j, h);
}
joined.push(cur);
}
if joined.iter().any(|(x, _)| x.solid.boundaries().len() != 1) {
return Err(fail("a piece in several parts"));
}
// A join onto one piece is one piece: anything apart is a cut face that did not glue.
if op == BoolOp::Union && a.solid.boundaries().len() == 1 && joined.len() != 1 {
return Err(fail("a piece does not join back"));
}
let pieces = joined;
recorded::join(pieces.into_iter().map(|(p, h)| (p, dropped(h))).collect(), &counts)
}
/// `piece` split by `plane` (at `x` along `axis`): the parts on the side away from the tool
/// and the one part on its side (positive along `axis` when `keep_positive`), each with its
/// history over the operands `piece`'s is over. `None` when the split leaves no part on either
/// side, or several on the tool's.
pub(crate) fn split_beside(piece: &Traced, plane: &solidcraft_geom::Plane, axis: Vec3, x: f64, keep_positive: bool) -> Option<(Vec<Traced>, Traced)> {
let parts = crate::splitface::split_body(&piece.0, &crate::splitface::SplitTool::Plane(*plane)).ok()?;
if parts.len() < 2 {
return None;
}
let tol = piece.0.size() * 1e-6 + 1e-9;
let (mut away, mut kept) = (Vec::new(), Vec::new());
for part in parts {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for v in part.solid.vertex_iter() {
let t = from_p3(v.point()).dot(axis) - x;
lo = lo.min(t);
hi = hi.max(t);
}
let h = match (part.history(), piece.1.as_ref()) {
(Some(hp), Some(h)) => recorded::composed(hp.clone().substituted(0, h)),
_ => None,
};
let positive = if lo >= -tol {
true
} else if hi <= tol {
false
} else {
return None;
};
if positive == keep_positive { kept.push((part, h)) } else { away.push((part, h)) }
}
let kept = kept.pop().filter(|_| kept.is_empty())?;
(!away.is_empty()).then_some((away, kept))
}
/// `h` over `[a, b]`: the operands after them (the boxes a piece was cut with) dropped, which
/// fails where something of them is left.
fn dropped(h: Option<History>) -> Option<History> {
let h = h?;
let map: Vec<Option<usize>> = (0..h.operands).map(|op| (op < 2).then_some(op)).collect();
recorded::composed(h.renumbered(&map, 2))
}
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64, sources: &Sources) -> Result<Option<Body>> {
let polys = csg(pa, pb, op, eps)?;
if polys.is_empty() {
return Ok(None);
}
rebuild(&polys, size, size * 1e-7, sources).map(Some)
}
/// What a rebuilt body's history is over: each operand's face and edge counts and, where it is
/// a B-rep, its edges in its edge order (the faces each bounds, and its ends).
struct Sources {
counts: Vec<(usize, usize)>,
edges: Vec<Option<OperandEdges>>,
}
/// An operand's edges in its edge order (each one's ends), and those between each pair of its
/// faces (lesser face first).
struct OperandEdges {
ends: Vec<(Vec3, Vec3)>,
between: BTreeMap<(usize, usize), Vec<usize>>,
}
impl OperandEdges {
fn of(b: &Body) -> OperandEdges {
let (edges, bounds) = edge_order(b.solid.face_iter());
let mut between: BTreeMap<(usize, usize), Vec<usize>> = BTreeMap::new();
for (i, fs) in bounds.iter().enumerate() {
if let [f, g] = fs.as_slice() {
between.entry((*f.min(g), *f.max(g))).or_default().push(i);
}
}
let ends = edges.iter().map(|e| (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()))).collect();
OperandEdges { ends, between }
}
}
impl Sources {
fn of(bodies: &[&Body]) -> Sources {
let edges: Vec<OperandEdges> = bodies.iter().map(|b| OperandEdges::of(b)).collect();
let counts = bodies.iter().zip(&edges).map(|(b, e)| (b.face_count(), e.ends.len())).collect();
Sources { counts, edges: edges.into_iter().map(Some).collect() }
}
/// The origins of an edge between a piece of `p` and a piece of `q` running from `ends.0`
/// to `ends.1`: where faces of two operands meet; the edge between two faces of one
/// operand (the one the piece lies along, when they share several); none between pieces of
/// one face, or between triangles of a mesh operand. `None` when it can't be told.
fn edge(&self, p: FaceOrigin, q: FaceOrigin, ends: (Vec3, Vec3), tol: f64) -> Option<Vec<EdgeOrigin>> {
let (FaceOrigin::Face { operand: x, face: f }, FaceOrigin::Face { operand: y, face: g }) = (p, q) else { return None };
if x != y {
return Some(vec![EdgeOrigin::meet(p, q)]);
}
let Some(edges) = self.edges.get(x)?.as_ref() else { return Some(Vec::new()) };
if f == g {
return Some(Vec::new());
}
let between = edges.between.get(&(f.min(g), f.max(g)))?;
let along: Vec<usize> = match between.as_slice() {
[_] => between.clone(),
_ => between
.iter()
.copied()
.filter(|i| edges.ends.get(*i).is_some_and(|(s, t)| ends.0.dist_to_segment(*s, *t) <= tol && ends.1.dist_to_segment(*s, *t) <= tol))
.collect(),
};
match along.as_slice() {
[edge] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *edge }]),
_ => None,
}
}
}
/// Enclosed volume of a closed set of polygons (divergence theorem).
fn polys_volume(ps: &[Poly]) -> f64 {
let mut v6 = 0.0;
for p in ps {
let Some(&o) = p.v.first() else { continue };
for w in p.v.windows(2).skip(1) {
v6 += o.dot(w[0].cross(w[1]));
}
}
v6 / 6.0
}
/// The polygons of a BSP boolean (csg.js style).
fn csg(pa: Vec<Poly>, pb: Vec<Poly>, op: BoolOp, eps: f64) -> Result<Vec<Poly>> {
let (mut na, mut nb) = (Node::default(), Node::default());
na.build(pa, eps, 0)?;
nb.build(pb, eps, 0)?;
match op {
BoolOp::Union => {
na.clip_to(&nb, eps);
nb.clip_to(&na, eps);
nb.invert();
nb.clip_to(&na, eps);
nb.invert();
let mut extra = Vec::new();
nb.all(&mut extra);
na.build(extra, eps, 0)?;
}
BoolOp::Cut => {
na.invert();
na.clip_to(&nb, eps);
nb.clip_to(&na, eps);
nb.invert();
nb.clip_to(&na, eps);
nb.invert();
let mut extra = Vec::new();
nb.all(&mut extra);
na.build(extra, eps, 0)?;
na.invert();
}
BoolOp::Intersect => {
na.invert();
nb.clip_to(&na, eps);
nb.invert();
na.clip_to(&nb, eps);
nb.clip_to(&na, eps);
let mut extra = Vec::new();
nb.all(&mut extra);
na.build(extra, eps, 0)?;
na.invert();
}
}
let mut polys = Vec::new();
na.all(&mut polys);
Ok(polys)
}
/// `shell`'s connected components, each face in the shell's order and the components by their
/// first face. truck's own come out in the hash order of face addresses, so the same rebuild
/// gave a body with its faces in a different order from run to run (#707).
pub(crate) fn components_in_order(shell: &mt::Shell) -> Vec<mt::Shell> {
let pos: HashMap<mt::FaceID, usize> = shell.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
let key = |f: &mt::Face| pos.get(&f.id()).copied().unwrap_or(usize::MAX);
let mut comps: Vec<Vec<mt::Face>> = shell
.connected_components()
.into_iter()
.map(|c| {
let mut faces: Vec<mt::Face> = c.into_iter().collect();
faces.sort_by_key(|f| key(f));
faces
})
.collect();
comps.sort_by_key(|c| c.first().map(key).unwrap_or(usize::MAX));
comps.into_iter().map(mt::Shell::from).collect()
}
/// Position key for vertex merging.
fn key(p: Vec3, q: f64) -> (i64, i64, i64) {
((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64)
}
/// Turn polygons into a B-rep solid: merge by plane, split T-junctions, chain boundary loops.
/// Its history over the operands the polygons are pieces of (`sources`): each face the
/// operand faces whose pieces it joins (pieces joined across the edges between them), each edge
/// what the pieces either side of it say (see [`Sources::edge`]).
fn rebuild(polys: &[Poly], size: f64, q: f64, sources: &Sources) -> Result<Body> {
// Global vertex table.
let mut index: BTreeMap<(i64, i64, i64), usize> = BTreeMap::new();
let mut verts: Vec<Vec3> = Vec::new();
// Points within `q` share a vertex, also across the rounding grid's cell boundaries.
let mut id = |p: Vec3, verts: &mut Vec<Vec3>| -> usize {
let k = key(p, q);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(&i) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz))
&& verts.get(i).is_some_and(|v| (*v - p).len() <= q)
{
return i;
}
}
}
}
verts.push(p);
index.insert(k, verts.len() - 1);
verts.len() - 1
};
let mut loops: Vec<(PlaneEq, Vec<usize>)> = Vec::new();
let mut srcs: Vec<FaceOrigin> = Vec::new();
for p in polys {
let mut l: Vec<usize> = p.v.iter().map(|v| id(*v, &mut verts)).collect();
l.dedup();
if l.first() == l.last() && l.len() > 1 {
l.pop();
}
if l.len() >= 3 {
loops.push((p.plane, l));
srcs.push(p.src);
}
}
// Split every polygon edge at vertices lying on it (T-junctions). Vertices are binned in a
// grid sized to the typical edge, so each edge only looks at its neighbourhood.
let tol = size * 1e-7;
let edge_len: Vec<f64> = loops
.iter()
.flat_map(|(_, l)| (0..l.len()).filter_map(move |i| Some((*l.get(i)?, *l.get((i + 1) % l.len())?))))
.filter_map(|(a, b)| Some((*verts.get(b)? - *verts.get(a)?).len()))
.collect();
let mean = edge_len.iter().sum::<f64>() / edge_len.len().max(1) as f64;
let cell = mean.max(size * 1e-4).max(1e-9);
let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
let mut grid: BTreeMap<(i64, i64, i64), Vec<usize>> = BTreeMap::new();
for (k, v) in verts.iter().enumerate() {
grid.entry(ckey(*v)).or_default().push(k);
}
let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to)
// The polygons (by loop) each directed edge comes from.
let mut owners: BTreeMap<(usize, usize, usize), Vec<usize>> = BTreeMap::new();
let mut groups: Vec<PlaneEq> = Vec::new();
let mut group_of: BTreeMap<(i64, i64, i64, i64), usize> = BTreeMap::new();
for (li, (pl, l)) in loops.iter().enumerate() {
let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64);
let g = *group_of.entry(gk).or_insert_with(|| {
groups.push(*pl);
groups.len() - 1
});
let n = l.len();
for i in 0..n {
let (Some(&a), Some(&b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
let (Some(&pa), Some(&pb)) = (verts.get(a), verts.get(b)) else { continue };
let d = pb - pa;
let len2 = d.len2();
let (lo, hi) = (ckey(pa.min(pb) - Vec3::new(q, q, q)), ckey(pa.max(pb) + Vec3::new(q, q, q)));
let cells = (hi.0 - lo.0 + 1) * (hi.1 - lo.1 + 1) * (hi.2 - lo.2 + 1);
let candidates: Vec<usize> = if cells > 4096 {
(0..verts.len()).collect()
} else {
let mut c = Vec::new();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(ks) = grid.get(&(x, y, z)) {
c.extend(ks.iter().copied());
}
}
}
}
c
};
let mut on: Vec<(f64, usize)> = candidates
.into_iter()
.filter(|k| *k != a && *k != b)
.filter_map(|k| {
let v = verts.get(k)?;
let t = (*v - pa).dot(d) / len2.max(1e-300);
(t > 1e-9 && t < 1.0 - 1e-9 && v.dist_to_segment(pa, pb) < q).then_some((t, k))
})
.collect();
on.sort_by(|x, y| x.0.total_cmp(&y.0));
let mut prev = a;
for (_, k) in on {
directed.push((g, prev, k));
owners.entry((g, prev, k)).or_default().push(li);
prev = k;
}
directed.push((g, prev, b));
owners.entry((g, prev, b)).or_default().push(li);
}
}
// Interior edges cancel against their reverse within the same plane group.
let mut count: BTreeMap<(usize, usize, usize), i64> = BTreeMap::new();
for (g, a, b) in &directed {
*count.entry((*g, *a, *b)).or_insert(0) += 1;
}
let mut boundary: Vec<(usize, usize, usize)> = Vec::new();
for (&(g, a, b), &c) in &count {
let rev = count.get(&(g, b, a)).copied().unwrap_or(0);
for _ in 0..(c - rev).max(0) {
boundary.push((g, a, b));
}
}
boundary.sort_unstable();
// Pieces either side of an edge inside a plane group lie in one face.
let mut parent: Vec<usize> = (0..loops.len()).collect();
for ((g, a, b), here) in &owners {
let (Some(there), Some(&first)) = (owners.get(&(*g, *b, *a)), here.first()) else { continue };
for other in here.iter().chain(there) {
let (rp, rq) = (find(&mut parent, first), find(&mut parent, *other));
if let Some(slot) = parent.get_mut(rp) {
*slot = rq;
}
}
}
let roots: Vec<usize> = (0..loops.len()).map(|li| find(&mut parent, li)).collect();
let mut joined: BTreeMap<usize, BTreeSet<FaceOrigin>> = BTreeMap::new();
for (root, src) in roots.iter().zip(&srcs) {
joined.entry(*root).or_default().insert(*src);
}
// What lies on each side of each boundary edge (from, to): the faces of its polygons.
let mut side: BTreeMap<(usize, usize), BTreeSet<FaceOrigin>> = BTreeMap::new();
for (g, a, b) in &boundary {
let set = side.entry((*a, *b)).or_default();
for li in owners.get(&(*g, *a, *b)).into_iter().flatten() {
set.extend(srcs.get(*li));
}
}
// The faces of one boundary loop's polygons.
let loop_faces = |g: usize, lp: &[usize]| -> Option<BTreeSet<FaceOrigin>> {
let (a, b) = (*lp.first()?, *lp.get(1)?);
let li = *owners.get(&(g, a, b))?.first()?;
joined.get(roots.get(li)?).cloned()
};
guard("planar boolean rebuild", || {
let mut verts = verts.clone();
// Every plane group's boundary loops (vertex indices).
let mut all_loops: Vec<Vec<Vec<usize>>> = Vec::new();
for (g, _) in groups.iter().enumerate() {
let mut segs: Vec<(usize, usize)> = boundary.iter().filter(|e| e.0 == g).map(|e| (e.1, e.2)).collect();
let mut loops_g: Vec<Vec<usize>> = Vec::new();
while let Some((s, t)) = segs.pop() {
let mut lp = vec![s, t];
let mut cur = t;
let mut guard_n = 0;
while cur != s && guard_n < 100_000 {
guard_n += 1;
let Some(k) = segs.iter().position(|(a, _)| *a == cur) else { break };
let (_, nb) = segs.remove(k);
lp.push(nb);
cur = nb;
}
if cur == s {
lp.pop();
// A walk through a vertex twice (two loops touching there) is split into
// simple loops at that vertex.
let mut stack: Vec<usize> = Vec::new();
for v in lp {
if let Some(at) = stack.iter().position(|x| *x == v) {
let sub: Vec<usize> = stack.split_off(at);
if sub.len() >= 3 {
loops_g.push(sub);
}
}
stack.push(v);
}
if stack.len() >= 3 {
loops_g.push(stack);
}
}
}
all_loops.push(loops_g);
}
// The original vertex of each vertex index (pinched vertices get copies, below).
let mut orig: Vec<usize> = (0..verts.len()).collect();
split_pinched_vertices(&mut all_loops, &mut verts, &mut orig);
let tv: Vec<mt::Vertex> = verts.iter().map(|v| builder::vertex(p3(*v))).collect();
let mut edges: BTreeMap<(usize, usize), mt::Edge> = BTreeMap::new();
let mut faces: Vec<mt::Face> = Vec::new();
let mut face_from: HashMap<mt::FaceID, Option<BTreeSet<FaceOrigin>>> = HashMap::new();
for (g, plane) in groups.iter().enumerate() {
let Some(loops_g) = all_loops.get(g) else { continue };
let mut wires: Vec<mt::Wire> = Vec::new();
for lp in loops_g {
let n = lp.len();
let mut es = Vec::with_capacity(n);
for i in 0..n {
let (Some(&a), Some(&b)) = (lp.get(i), lp.get((i + 1) % n)) else { continue };
let k = (a.min(b), a.max(b));
let e = match edges.get(&k) {
Some(e) => e.clone(),
None => {
let (Some(va), Some(vb)) = (tv.get(k.0), tv.get(k.1)) else { continue };
let e = builder::line(va, vb);
edges.insert(k, e.clone());
e
}
};
es.push(if a < b { e } else { e.inverse() });
}
wires.push(es.into());
}
if wires.is_empty() {
continue;
}
// Outer loops are counter-clockwise about the plane normal; holes go with the
// outer loop that contains them.
let area = |lp: &Vec<usize>| -> f64 {
let n = lp.len();
let mut s = Vec3::ZERO;
for i in 0..n {
if let (Some(a), Some(b)) = (lp.get(i).and_then(|k| verts.get(*k)), lp.get((i + 1) % n).and_then(|k| verts.get(*k))) {
s += a.cross(*b);
}
}
s.dot(plane.n) * 0.5
};
let (outer, holes): (Vec<usize>, Vec<usize>) = (0..loops_g.len()).partition(|i| loops_g.get(*i).is_some_and(|l| area(l) > 0.0));
let u = plane.n.any_perp();
let w = plane.n.cross(u);
let to2 = |p: Vec3| (p.dot(u), p.dot(w));
let inside = |pt: Vec3, lp: &Vec<usize>| {
let (px, py) = to2(pt);
let n = lp.len();
let mut c = false;
for i in 0..n {
let (Some(a), Some(b)) = (lp.get(i).and_then(|k| verts.get(*k)), lp.get((i + 1) % n).and_then(|k| verts.get(*k))) else {
continue;
};
let ((ax, ay), (bx, by)) = (to2(*a), to2(*b));
if (ay > py) != (by > py) && px < ax + (py - ay) / (by - ay) * (bx - ax) {
c = !c;
}
}
c
};
for o in &outer {
let Some(ol) = loops_g.get(*o) else { continue };
let mut ws = vec![wires.get(*o).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?];
for h in &holes {
let Some(hl) = loops_g.get(*h) else { continue };
let probe = hl.first().and_then(|k| verts.get(*k)).copied().unwrap_or_default();
// The innermost outer loop containing the hole.
let owner = outer.iter().filter(|x| loops_g.get(**x).is_some_and(|l| inside(probe, l))).min_by(|x, y| {
let ax = loops_g.get(**x).map(area).unwrap_or(0.0);
let ay = loops_g.get(**y).map(area).unwrap_or(0.0);
ax.total_cmp(&ay)
});
if owner == Some(o) {
ws.push(wires.get(*h).cloned().ok_or_else(|| KernelError::Failed("wire".into()))?);
}
}
let from = loop_faces(g, &ol.iter().map(|k| orig.get(*k).copied().unwrap_or(*k)).collect::<Vec<_>>());
// A loop a hair off flat (fine facets of a curved face) takes the group's plane.
let face = match builder::try_attach_plane(&ws) {
Ok(f) => f,
Err(e) => {
let o = plane.n * plane.w;
let surface = mt::Plane::new(p3(o), p3(o + u), p3(o + w));
mt::Face::try_new(ws, mt::Surface::Plane(surface))
.map_err(|e2| KernelError::Failed(format!("planar boolean face: {e}; {e2}")))?
}
};
face_from.insert(face.id(), from);
faces.push(face);
}
}
let shell: mt::Shell = faces.into();
let parts = components_in_order(&shell);
let solid = Solid::try_new(parts).map_err(|e| KernelError::Failed(format!("planar boolean: {e}")))?;
let regions = solid.face_iter().map(|f| face_from.get(&f.id()).cloned().flatten().map(|o| (o, false))).collect::<Option<Vec<_>>>();
let tol = size * 1e-6;
let edge_from = edges
.iter()
.map(|((a, b), e)| {
let ends = (*verts.get(*a)?, *verts.get(*b)?);
let (a, b) = (orig.get(*a)?, orig.get(*b)?);
let mut origins = BTreeSet::new();
for p in side.get(&(*a, *b))? {
for q in side.get(&(*b, *a))? {
origins.extend(sources.edge(*p, *q, ends, tol)?);
}
}
Some((e.id(), origins.into_iter().collect()))
})
.collect::<Option<HashMap<mt::EdgeID, Vec<EdgeOrigin>>>>();
record::build(solid, &sources.counts, regions.map(record::regions), edge_from.as_ref())
})
}
/// A vertex where the solid's surface pinches, as where two blocks meet at one corner only
/// (#679): the faces around it form several fans that share no edge there. Each fan past the
/// first gets its own copy of the vertex (same point), so every vertex of the shell is a
/// manifold one.
fn split_pinched_vertices(loops: &mut [Vec<Vec<usize>>], verts: &mut Vec<Vec3>, orig: &mut Vec<usize>) {
// Every corner: (group, loop, position), by vertex.
let mut corners: BTreeMap<usize, Vec<(usize, usize, usize)>> = BTreeMap::new();
for (g, ls) in loops.iter().enumerate() {
for (li, l) in ls.iter().enumerate() {
for (i, v) in l.iter().enumerate() {
corners.entry(*v).or_default().push((g, li, i));
}
}
}
let mut renames: Vec<((usize, usize, usize), usize)> = Vec::new();
let mut ids: Vec<usize> = corners.keys().copied().collect();
ids.sort_unstable();
for v in ids {
let Some(cs) = corners.get(&v) else { continue };
if cs.len() < 2 {
continue;
}
// The two neighbours of each corner; corners sharing an edge (v, x) are in one fan.
let nbrs: Vec<[usize; 2]> = cs
.iter()
.map(|(g, li, i)| {
let l = loops.get(*g).and_then(|ls| ls.get(*li)).map(Vec::as_slice).unwrap_or(&[]);
let n = l.len().max(1);
[l.get((i + n - 1) % n).copied().unwrap_or(v), l.get((i + 1) % n).copied().unwrap_or(v)]
})
.collect();
let mut parent: Vec<usize> = (0..cs.len()).collect();
for i in 0..cs.len() {
for j in i + 1..cs.len() {
let (Some(a), Some(b)) = (nbrs.get(i), nbrs.get(j)) else { continue };
if a.iter().any(|x| b.contains(x)) {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if let Some(slot) = parent.get_mut(ri) {
*slot = rj;
}
}
}
}
let mut fan_id: BTreeMap<usize, usize> = BTreeMap::new();
for (k, c) in cs.iter().enumerate() {
let root = find(&mut parent, k);
let next = fan_id.len();
let fan = *fan_id.entry(root).or_insert(next);
if fan > 0 {
// A copy per extra fan.
let copy = verts.len() + fan - 1;
renames.push((*c, copy));
}
}
let p = verts.get(v).copied().unwrap_or_default();
for _ in 1..fan_id.len() {
verts.push(p);
orig.push(v);
}
}
for ((g, li, i), new) in renames {
if let Some(slot) = loops.get_mut(g).and_then(|ls| ls.get_mut(li)).and_then(|l| l.get_mut(i)) {
*slot = new;
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod diagnostic_tests {
use super::*;
#[test]
fn compound_curved_diagnostics_count_every_surface_kind() {
// Many independent shells exercise the error path without performing a costly
// Boolean. Each cylinder has two analytic half-cylinder walls and two flat caps.
let mut shells = Vec::new();
for i in 0..64 {
let cylinder = crate::cylinder(Vec3::X * (i as f64 * 6.0), Vec3::Z, 2.0, 5.0).unwrap();
shells.extend(cylinder.solid.boundaries().iter().cloned());
}
let sphere = crate::sphere(Vec3::new(0.0, 12.0, 0.0), 2.0).unwrap();
let sphere_faces = sphere.face_count();
shells.extend(sphere.solid.boundaries().iter().cloned());
let body = guard("diagnostic compound", || Body::new(Solid::try_new(shells).unwrap())).unwrap();
let faces = body.faces(0.02).unwrap();
let error = face_planes(&body, &faces).err().unwrap();
let expected = std::collections::BTreeMap::from([("cylinder", 128), ("sphere", sphere_faces)]);
assert_eq!(error, NotPlanar::Curved(expected));
assert_eq!(error.describe("tool"), format!("the tool has {} curved faces (cylinder, sphere)", 128 + sphere_faces));
}
#[test]
fn planar_mesh_inconsistency_does_not_become_curved_classification() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(4.0, 5.0, 6.0)).unwrap();
let mut faces = body.faces(0.01).unwrap();
assert_eq!(face_planes(&body, &faces).unwrap().len(), 6);
faces[0].plane_normal = None;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 is planar but its mesh is not flat".into()));
faces[0].area = 0.0;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("face 0 could not be meshed".into()));
faces[0].index = 999;
assert_eq!(face_planes(&body, &faces).err().unwrap(), NotPlanar::Other("mesh is inconsistent".into()));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod small_round_tests {
use std::f64::consts::FRAC_PI_2;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
use super::*;
/// An `l` x `w` x 1 plate on XY with its four corners rounded (radius `r`), and a hole of
/// diameter 4 through it 100 from one end when `hole`.
fn rounded_plate(l: f64, w: f64, r: f64, hole: bool) -> Body {
let (x, y) = (l / 2.0, w / 2.0);
let corners = [(x - r, y - r), (r - x, y - r), (r - x, r - y), (x - r, r - y)];
let mut segs = Vec::new();
for (k, (cx, cy)) in corners.iter().enumerate() {
let start = k as f64 * FRAC_PI_2;
let at = |t: f64| Vec2::new(cx + r * t.cos(), cy + r * t.sin());
segs.push(Seg2::Arc { center: Vec2::new(*cx, *cy), radius: r, start, sweep: FRAC_PI_2 });
let (nx, ny) = corners[(k + 1) % 4];
let next = start + FRAC_PI_2;
segs.push(Seg2::Line { a: at(next), b: Vec2::new(nx + r * next.cos(), ny + r * next.sin()) });
}
let holes = if hole { vec![Loop2::circle(Vec2::new(x - 100.0, 0.0), 2.0).reversed()] } else { Vec::new() };
crate::extrude_planar_regions(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes }], 0.0, 1.0).unwrap()
}
/// Faces of `b` on cylinders of radius `r`.
fn cylinders(b: &Body, r: f64) -> usize {
(0..b.face_count())
.filter(|&i| matches!(crate::face_surface(b, i), Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-9))
.count()
}
/// A pocket cut into the middle of a long plate by the exact fallback leaves the plate's
/// small corner rounds round. Meshed at a thousandth of the 1200 mm plate (1.2 mm), each
/// 2 mm quarter round (its chord 0.59 mm inside the arc) is one flat strip of facets, which
/// was taken for a plane: the whole plate went to the polygon boolean (and, beside a round
/// hole, the rounds went into the planar slab around the pocket), and the rounds came back
/// flat chamfers, each taking r^2 (pi/4 - 1/2) = 1.14 mm^3 more than its round.
#[test]
fn a_long_plates_small_rounds_stay_round_beside_a_pocket() {
for hole in [false, true] {
let plate = rounded_plate(1200.0, 40.0, 2.0, hole);
assert_eq!(cylinders(&plate, 2.0), 4 + 2 * usize::from(hole));
// 10 x 5, 0.1 deep from the top.
let tool = crate::box_solid(Vec3::new(-5.0, -2.5, 0.9), Vec3::new(5.0, 2.5, 1.0)).unwrap();
let r = planar_boolean(&plate, &tool, BoolOp::Cut).unwrap().unwrap();
assert_eq!(cylinders(&r, 2.0), 4 + 2 * usize::from(hole), "hole {hole}");
// The pocket's 10 x 5 x 0.1 and nothing else (a flattened round takes 1.14 more).
let removed = crate::measure(&plate).unwrap().volume - crate::measure(&r).unwrap().volume;
assert!((removed - 5.0).abs() < 1e-3, "hole {hole}: removed {removed}");
}
}
}