//! Partitions: a design's bodies cut into cells by planes, for meshing cell by cell.
//!
//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel (the body's
//! intersections with a box on each side of the plane, as SolidCraft's split body does), in
//! memory: the design file is never changed. The partitions live in the FieldCraft model and are applied again, in order,
//! whenever the design is opened, so an edit of the design followed by a re-open replays them.
//!
//! Names are FieldCraft's own, so they do not depend on the order of the design's timeline:
//! - **Cells.** A body cut by a plane gives `
/a` (the piece on the plane's negative side)
//! and `/b`; several disjoint pieces on one side are `/a1`, `/a2`, … ordered
//! by centroid.
//! - **Cut faces** pair across the cut: on a cell on the negative side (its outward normal along
//! the plane's normal) the face is `partition::a`, on the positive side
//! `partition::b`. Several disjoint faces on one cell (a cut through a hole) are `…#0`,
//! `…#1`, ordered along the plane, which a set naming `partition::a` selects together.
//! - **Every other face** inherits the persistent name of the face it is a piece of (found on the
//! same surface under a point of the piece). Where a cell has several pieces of one face they
//! are `#0`, `#1`, …, which sets naming `` select.
use solidcraft_geom::{Plane, Vec3};
use solidcraft_kernel::guard;
use crate::{Design, Error, Face, FaceSurface, FeatureError, Result, Shape, SurfaceMesh};
/// Most partitions a design takes.
pub const MAX_CUTS: usize = 256;
/// Most cells the partitions make in all.
pub const MAX_CELLS: usize = 4096;
/// A cut face lies on the plane within this fraction of its body's size.
const ON_PLANE: f64 = 1e-6;
/// Tessellation tolerance (fraction of a body's size) for matching and ordering faces.
const MATCH_TOLERANCE: f64 = 2e-3;
/// One partition: the bodies it cuts by a plane.
#[derive(Debug, Clone, PartialEq)]
pub struct Cut {
/// Names the cut faces (`partition::a|b`).
pub name: String,
/// A point on the plane and its normal (any length but zero).
pub point: [f64; 3],
pub normal: [f64; 3],
/// The bodies to cut, by name, as they are when this partition comes (the cells of earlier
/// partitions included); a body earlier partitions cut stands for its cells (`B` for `B/a`,
/// `B/b/a`, …); empty: every body the plane divides.
pub bodies: Vec,
}
/// What a partition did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CutReport {
pub name: String,
/// The bodies it cut, each into two or more cells.
pub split: Vec,
/// How many cut faces it named.
pub faces: usize,
/// What may make its cells hard to mesh: the plane lying on a face of a body it cut.
pub warnings: Vec,
}
fn vec3(p: [f64; 3]) -> Vec3 {
Vec3 { x: p[0], y: p[1], z: p[2] }
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn lexical(a: &[f64; 3], b: &[f64; 3]) -> std::cmp::Ordering {
a.iter().zip(b).map(|(x, y)| x.total_cmp(y)).find(|o| o.is_ne()).unwrap_or(std::cmp::Ordering::Equal)
}
/// The plane of a cut, with its unit normal.
fn plane_of(cut: &Cut) -> Result<(Plane, [f64; 3])> {
let finite = cut.point.iter().chain(&cut.normal).all(|v| v.is_finite() && v.abs() <= 1e9);
let len = dot(cut.normal, cut.normal).sqrt();
if !finite || !(len > 1e-12) {
return Err(Error::Invalid(format!("partition `{}`: the plane needs a finite point and a non-zero normal", cut.name)));
}
let n = cut.normal.map(|v| v / len);
let plane = Plane::from_normal(vec3(cut.point), vec3(n))
.ok_or_else(|| Error::Invalid(format!("partition `{}`: the plane needs a non-zero normal", cut.name)))?;
Ok((plane, n))
}
impl Design {
/// The design as opened, cut by `cuts` in order (the partitions replace any applied before).
/// A cut that names a body the design does not have, that does not divide a body it names,
/// or that the kernel fails to make on any body it applies to, is listed in [`Design::errors`] (feature `partition `), as a feature of the
/// design that failed to rebuild; the rest still apply.
pub fn partitioned(&self, cuts: &[Cut]) -> Result {
if cuts.len() > MAX_CUTS {
return Err(Error::Invalid(format!("a design takes at most {MAX_CUTS} partitions")));
}
let planes: Vec<(Plane, [f64; 3])> = cuts.iter().map(plane_of).collect::>()?;
let mut bodies: Vec = self.source.as_ref().clone();
let mut errors: Vec = self.errors.iter().filter(|e| !crate::virtual_faces::derived(&e.feature)).cloned().collect();
let mut reports = Vec::with_capacity(cuts.len());
for (cut, (plane, n)) in cuts.iter().zip(&planes) {
let feature = format!("partition {}", cut.name);
// A listed name is the body of that name, or the cells earlier partitions cut it into.
let names = |name: &str, b: &str| name == b || name.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'));
for b in &cut.bodies {
if !bodies.iter().any(|x| names(&x.name, b)) {
errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] });
}
}
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() };
let mut next = Vec::with_capacity(bodies.len() + 1);
// The listed names some body of which the plane divides.
let mut divided = vec![false; cut.bodies.len()];
for body in bodies {
let listed: Vec = (0..cut.bodies.len()).filter(|&i| cut.bodies.get(i).is_some_and(|b| names(&body.name, b))).collect();
if !(cut.bodies.is_empty() || !listed.is_empty()) {
next.push(body);
continue;
}
match split(&body, plane, *n, cut)? {
Split::Cells(cells, faces, warnings) => {
for &i in &listed {
if let Some(d) = divided.get_mut(i) {
*d = true;
}
}
report.split.push(body.name.clone());
report.faces += faces;
report.warnings.extend(warnings);
next.extend(cells);
}
Split::Misses => next.push(body),
// Listed or not: the plane may well divide the body, and a cut that silently
// leaves it whole would be mistaken for one that does not reach it (#283).
Split::Failed(e) => {
// Reported here; not again as a plane that divides nothing.
for &i in &listed {
if let Some(d) = divided.get_mut(i) {
*d = true;
}
}
errors.push(FeatureError {
feature: feature.clone(),
message: format!("the kernel could not cut `{}` ({e})", body.name),
bodies: vec![body.name.clone()],
});
next.push(body);
}
}
if next.len() > MAX_CELLS {
return Err(Error::Invalid(format!("the partitions make at most {MAX_CELLS} cells")));
}
}
for (b, divided) in cut.bodies.iter().zip(divided) {
// A body that is not there is reported above; one that is, and that the plane
// misses (all of its cells), is an error: the cut would do nothing to it.
if !divided && next.iter().any(|x| names(&x.name, b)) {
errors.push(FeatureError {
feature: feature.clone(),
message: format!("the plane does not divide `{b}`"),
bodies: vec![b.clone()],
});
}
}
bodies = next;
reports.push(report);
}
Ok(Design {
name: self.name.clone(),
bodies,
errors,
cuts: reports,
source: std::sync::Arc::clone(&self.source),
opened: std::sync::Arc::clone(&self.source),
history: self.history.clone(),
})
}
}
/// The parts of `body` on either side of `plane` (only the non-empty ones): its intersections
/// with two boxes, one on each side, whose faces in the plane are squares as wide as the body's
/// bounding-box diagonal and that reach that far from it. SolidCraft's `split_by_plane` uses boxes
/// several times the body's size, and its boolean then puts the cut up to a few micrometres off
/// the plane (1.8 µm on a 140 mm body), off the faces it should meet (#384); boxes of the body's own
/// size keep the cut on the plane.
///
/// A boolean the kernel could only make by moving one body against the other (its last-resort
/// tool nudge, micrometres and microradians) is refused: the cut would lie off the plane, next to
/// the face it was meant to run along, and leave a cell bounded by slivers no mesher takes (#427).
fn halves(body: &Shape, plane: &Plane) -> solidcraft_kernel::Result> {
let bbox = solidcraft_kernel::measure(&body.body)?.bbox;
let reach = bbox.diagonal().max(LINEAR_REACH);
let c = plane.to_local(bbox.center());
let square = solidcraft_geom::Region2 {
outer: solidcraft_geom::Loop2::polygon(&[
solidcraft_geom::Vec2::new(c.x - reach, c.y - reach),
solidcraft_geom::Vec2::new(c.x + reach, c.y - reach),
solidcraft_geom::Vec2::new(c.x + reach, c.y + reach),
solidcraft_geom::Vec2::new(c.x - reach, c.y + reach),
]),
holes: vec![],
};
let mut out = Vec::with_capacity(2);
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
let Some(side) = solidcraft_kernel::extrude(plane, std::slice::from_ref(&square), lo, hi)?.pop() else { continue };
let (cut, report) =
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&body.body, &side, solidcraft_kernel::BoolOp::Intersect));
if let Some(moved) = report.warning() {
return Err(solidcraft_kernel::KernelError::Failed(format!(
"the cut is only made off the plane ({moved}); the plane runs along a face or edge of the body: move it off (by a fraction of the element size)"
)));
}
out.extend(cut?);
}
Ok(out)
}
/// Planar faces of `body` the cut's plane lies on (within [`COINCIDENT`] of the body's size),
/// as warnings: the kernel may leave micrometre slivers where a cut runs along a face (#384).
fn coincident(body: &Shape, parent: &Faces, cut: &Cut, n: [f64; 3], size: f64) -> Vec {
let reach = COINCIDENT * size.max(1.0);
let mut out = Vec::new();
for (i, surface) in parent.surfaces.iter().enumerate() {
let Some(FaceSurface::Plane { normal, point }) = surface else { continue };
let along = dot(*normal, n).abs();
let distance = dot(sub(*point, cut.point), n).abs();
if (along - 1.0).abs() < 1e-9 && distance <= reach {
let name = parent.names.get(i).map_or_else(|| i.to_string(), |f| f.name.clone());
out.push(format!(
"partition `{}`: the plane lies on face `{name}` of `{}` ({distance:.2e} mm from it); a cut along an existing face can leave micrometre slivers that no mesher takes: move the plane off the face (by a fraction of the element size)",
cut.name, body.name
));
}
}
out
}
/// A plane within this fraction of a body's size of one of its faces lies on it.
const COINCIDENT: f64 = 1e-5;
/// The least reach of the boxes [`halves`] cuts with (mm), for bodies with no extent.
const LINEAR_REACH: f64 = 1.0;
/// What cutting one body gave.
enum Split {
/// Its cells, named, how many cut faces they have, and warnings.
Cells(Vec, usize, Vec),
/// The plane does not divide it.
Misses,
/// The kernel failed to cut it: its message.
Failed(String),
}
/// Cut one body.
fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result {
let sides = match guard("partition", || halves(body, plane)) {
Ok(p) if p.len() >= 2 => p,
Ok(_) => return Ok(Split::Misses),
Err(e) => return Ok(Split::Failed(e.to_string())),
};
// Each side may fall apart into pieces that do not touch (#388): every piece is a cell.
let mut parts = Vec::with_capacity(sides.len());
for side in sides {
match guard("partition", || side.lumps()) {
Ok(pieces) if !pieces.is_empty() => parts.extend(pieces),
Ok(_) => parts.push(side),
Err(e) => return Ok(Split::Failed(e.to_string())),
}
}
let size = body.size()?;
let tol = (MATCH_TOLERANCE * size).max(1e-4);
let parent = Faces::of(body, tol)?;
let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len());
for part in parts {
let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new(), cuts: Vec::new() };
let m = shape.measure()?;
let negative = dot(sub(m.centroid, cut.point), n) < 0.0;
shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false, members: Vec::new() }).collect();
cells.push((negative, m.centroid, shape));
}
cells.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| lexical(&a.1, &b.1)));
let mut faces = 0;
let count = |neg: bool| cells.iter().filter(|c| c.0 == neg).count();
let (na, nb) = (count(true), count(false));
let (mut ka, mut kb) = (0, 0);
let mut out = Vec::with_capacity(cells.len());
for (negative, _, mut shape) in cells {
let (k, of) = if negative {
ka += 1;
(ka, na)
} else {
kb += 1;
(kb, nb)
};
let side = if negative { "a" } else { "b" };
shape.name = if of > 1 { format!("{}/{side}{k}", body.name) } else { format!("{}/{side}", body.name) };
faces += name_faces(&mut shape, &parent, cut, n, size, tol)?;
out.push(shape);
}
Ok(Split::Cells(out, faces, coincident(body, &parent, cut, n, size)))
}
/// A body's faces as matching needs them: each face's analytic surface, name and triangles.
struct Faces {
surfaces: Vec