kernel: planar regions stay one 2D arrangement; prism booleans are SMLib's boolean

The prism module kept two things: the 2D arrangement of planar regions
(planar_regions_boolean, and extrude_planar_regions, which normalises the material with it,
proves the pieces apart and extrudes them with crate::extrude, SMLib's extrusion with its
face sources), and a second route to the solid boolean: prism operands read section by
section, the result sewn and its history rebuilt from section tags (prism_boolean,
prism_union_between_caps, the stacked and free-form variants, prism_lineage.rs) and the
column-local variant (prism_local.rs). On SMLib a boolean is smlib_boolean, with SMLib's
own history: the second route is deleted, with its tests (prism_spline_tests.rs, the
stack/prism tests in planar_tests) of what no longer exists.

The section tags shrink to what the arrangement uses: the free-form segment a fitted arc
stands for. wall_along reads a record surface (cylinder, revolved line, spline net) for
clearance.rs. The 2D tests and the wall test keep their assertions.

lib.rs (coordinator): drop `mod prism_local;`.

(not built: base in progress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 00:48:06 -04:00
parent 6a68571242
commit 42c41464aa
4 changed files with 60 additions and 2784 deletions
+60 -1806
View File
File diff suppressed because it is too large Load Diff
-538
View File
@@ -1,538 +0,0 @@
//! The history of a prism boolean's result (see [`super::prism_boolean`]), from what its faces
//! were built over: each wall over a section segment whose [`Tag`] says which operand wall it
//! was read beside, each cap over a section region lying on operand caps at its level. Edges
//! follow from the same tags: a wall's edge with a cap is the operand's edge between them (or
//! the meeting of the wall with the other operand's cap); an edge between walls at a section
//! vertex is the operand's edge there (or the meeting of two operands' walls).
use std::collections::{BTreeSet, HashMap};
use solidcraft_geom::{Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use super::{Index, Section, Tag, dist_to, inside};
use crate::BoolOp;
use crate::body::{Body, edge_order, from_p3};
use crate::history::{EdgeOrigin, FaceOrigin, FaceOrigin::Face, FaceProv, Side};
/// A cap of an operand: a planar face square to the prism direction.
struct Cap {
origin: FaceOrigin,
/// Its level and whether it faces along the direction.
z: f64,
up: bool,
outline: Outline,
}
/// A section's regions with the walls its outline was read beside and its segments' middles,
/// to tell whether two sections overlap.
struct Outline {
regions: Vec<Region2>,
walls: BTreeSet<(usize, usize)>,
middles: Vec<Vec2>,
}
impl Outline {
fn of(s: &Section) -> Outline {
let (segs, tags) = s.segments();
Outline {
regions: s.regions.clone(),
walls: tags.iter().filter_map(|t| Some((t.operand, t.wall?))).collect(),
middles: segs.iter().map(|g| g.point_at(0.5)).collect(),
}
}
}
/// What a prism boolean's result is built from: the operands (target, tool), the direction and
/// the plane square to it that sections are drawn in, and for simple prisms each operand's
/// extent along the direction.
pub(super) struct Context<'a> {
pub op: BoolOp,
pub operands: [&'a Body; 2],
pub index: [&'a Index; 2],
pub plane: Plane,
pub tol: f64,
pub heights: Option<[(f64, f64); 2]>,
/// Both operands' caps, read once (see [`Context::caps`]).
caps: std::cell::OnceCell<Option<Vec<Cap>>>,
}
/// How one piece of the result was built.
pub(super) enum Built {
/// One body of `crate::extrude` of a section's one region through `layers`: successive
/// slabs (the section as read in each, between its two levels), bottom first, all of one
/// outline. Several where operands stacked end to end share an outline (one wall then lies
/// over each operand's wall in turn, and is made from all of them).
Extruded { layers: Vec<(Section, f64, f64)> },
/// Sewn (`sew_connected_sections`) from these faces, the sewing saying which each face of
/// the piece was built from (`None`: it could not).
Sewn { specs: Vec<Spec>, faces: Option<Vec<usize>> },
/// Built some other way, carrying its own history over the operands (none: not known).
Traced,
}
/// A face sewn into a stepped prism.
pub(super) enum Spec {
/// A wall over one section segment through successive slabs, between successive `levels`
/// (one more than `slabs`): what it is over in each slab.
Wall { levels: Vec<f64>, slabs: Vec<Slab> },
/// A cap over `section` at level `z`, facing along the direction when `up`.
Cap { section: Section, z: f64, up: bool },
}
/// A wall in one slab: over a segment tagged `tag`; `next` is the wall over the next segment
/// of its loop in that slab, which it meets at `at`.
pub(super) struct Slab {
pub tag: Tag,
pub next: usize,
pub at: Vec2,
}
impl Spec {
/// The wall's slab between levels `lo` and `hi` (`hi` above `lo`).
fn slab(&self, lo: f64, hi: f64) -> Option<&Slab> {
let Spec::Wall { levels, slabs } = self else { return None };
let mid = (lo + hi) / 2.0;
slabs.iter().zip(levels.windows(2)).find(|(_, z)| z[0] < mid && mid < z[1]).map(|(s, _)| s)
}
/// The wall's slab at its bottom level, when that is `z`.
fn bottom(&self, z: f64, tol: f64) -> Option<&Slab> {
let Spec::Wall { levels, slabs } = self else { return None };
((levels.first()? - z).abs() <= tol).then_some(slabs.first()?)
}
/// The wall's slab at its top level, when that is `z`.
fn top(&self, z: f64, tol: f64) -> Option<&Slab> {
let Spec::Wall { levels, slabs } = self else { return None };
((levels.last()? - z).abs() <= tol).then_some(slabs.last()?)
}
/// The wall's slab at its bottom or top level `z`.
fn end(&self, z: f64, tol: f64) -> Option<&Slab> {
self.bottom(z, tol).or_else(|| self.top(z, tol))
}
}
/// A face of an extruded piece: a wall or a cap.
#[derive(Clone, Copy)]
enum Kind {
/// The wall over this segment of the section.
Wall(usize),
Cap(usize),
}
impl<'a> Context<'a> {
pub(super) fn new(op: BoolOp, operands: [&'a Body; 2], index: [&'a Index; 2], plane: Plane, tol: f64, heights: Option<[(f64, f64); 2]>) -> Self {
Context { op, operands, index, plane, tol, heights, caps: std::cell::OnceCell::new() }
}
/// `body`, made of `pieces` (their shells in order), with its history over the operands
/// (none where it is not known).
pub(super) fn attach<B: std::borrow::Borrow<Body>>(&self, body: Body, pieces: &[(B, Built)]) -> Body {
let pieces: Vec<(&Body, &Built)> = pieces.iter().map(|(b, k)| (b.borrow(), k)).collect();
let (faces, edges) = self.history(&pieces).unzip();
crate::record::traced(body, &crate::record::counts(&self.operands), faces, edges.as_ref())
}
/// The faces (each a region of operand faces, and whether it is one untouched) and the edges
/// (by identity) of the pieces, in order; `None` where something is not known.
pub(super) fn history(&self, pieces: &[(&Body, &Built)]) -> Option<(Vec<FaceProv>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>)> {
let caps = self.caps.get_or_init(|| self.caps()).as_deref()?;
let mut faces = Vec::new();
let mut edges = HashMap::new();
for (body, built) in pieces {
let (f, e) = match built {
Built::Extruded { layers } => self.extruded(caps, body, layers)?,
Built::Sewn { specs, faces } => self.sewn(caps, body, specs, faces.as_deref()?)?,
Built::Traced => {
let h = body.history()?;
faces.extend(h.faces.iter().cloned());
edges.extend(crate::record::edges_by_identity(body, h));
continue;
}
};
faces.extend(f.into_iter().map(|(origins, whole)| crate::record::region(origins, whole)));
edges.extend(e);
}
// Pieces of one body can share an origin.
crate::record::mark_shared_split(&mut faces);
Some((faces, edges))
}
/// Both operands' caps, their outlines tagged.
fn caps(&self) -> Option<Vec<Cap>> {
let d = self.plane.normal();
let mut caps = Vec::new();
let (mut work, mut segments) = (2_000_000usize, super::MAX_PLANAR_SEGMENTS * 2);
for (operand, (body, index)) in self.operands.iter().zip(self.index).enumerate() {
for (face, f) in body.solid.face_iter().enumerate() {
let mt::Surface::Plane(p) = f.oriented_surface() else { continue };
let n = p.normal();
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
if n.dot(d).abs() < 1.0 - 1e-9 {
continue;
}
let section = Section::from_loops(index.loops(f, face, &self.plane, self.tol, &mut work, &mut segments)?)?;
let (z, up) = (self.plane.height(from_p3(p.origin())), n.dot(d) > 0.0);
caps.push(Cap { origin: Face { operand, face }, z, up, outline: Outline::of(&section.oriented()) });
}
}
Some(caps)
}
/// The operand caps under a result cap over `section` at level `z` (facing along the
/// direction when `up`): the target's caps there facing the same way, and the tool's facing
/// the same way (a union or intersection) or the other way (a cut, whose tool's caps bound
/// what it leaves), wherever they overlap the section.
fn cap_origins(&self, caps: &[Cap], section: &Section, z: f64, up: bool) -> BTreeSet<FaceOrigin> {
let outline = Outline::of(section);
caps.iter()
.filter(|c| {
let facing = match c.origin {
Face { operand: 1, .. } if self.op == BoolOp::Cut => c.up != up,
_ => c.up == up,
};
facing && (c.z - z).abs() < self.tol && self.overlap(&outline, &c.outline)
})
.map(|c| c.origin)
.collect()
}
/// Do two sections (at one level) overlap? Where their outlines share a wall, or a point of
/// one's outline, clear of the other's, lies inside it.
fn overlap(&self, x: &Outline, y: &Outline) -> bool {
let within = |x: &Outline, y: &Outline| x.middles.iter().any(|m| dist_to(&y.regions, *m) > self.tol && inside(&y.regions, *m));
!x.walls.is_disjoint(&y.walls) || within(x, y) || within(y, x)
}
/// The operand wall a segment was read beside.
fn wall(t: Tag) -> Option<FaceOrigin> {
Some(Face { operand: t.operand, face: t.wall? })
}
/// The operand's edges (by number) bounding exactly the faces `faces`.
fn edges_between(&self, operand: usize, [f, g]: [usize; 2]) -> &[usize] {
self.index.get(operand).and_then(|index| index.between.get(&(f.min(g), f.max(g)))).map_or(&[], Vec::as_slice)
}
/// Is the wall over a segment tagged `t` between `lo` and `hi` its operand wall untouched?
/// The whole cap edge it was read off, the operand's whole height (simple prisms), and a
/// wall with no other cap edges than its two.
fn whole(&self, caps: &[Cap], t: Tag, lo: f64, hi: f64) -> bool {
let (Some(heights), Some(wall), Some(_), Some(_)) = (self.heights, t.wall, t.start, t.end) else { return false };
let Some((l, h)) = heights.get(t.operand) else { return false };
let Some(index) = self.index.get(t.operand) else { return false };
let cap_edges: usize = caps
.iter()
.filter_map(|c| match c.origin {
Face { operand, face } if operand == t.operand => index.between.get(&(face.min(wall), face.max(wall))).map(Vec::len),
_ => None,
})
.sum();
(lo - l).abs() < self.tol && (hi - h).abs() < self.tol && cap_edges == 2
}
/// The origins of the edge between the wall over a segment tagged `t` and a cap lying on the
/// operand faces `cap`: the operand's edge between the wall and its own cap there (the very
/// edge the segment was read off, or the one it runs along where its slab ends: the one
/// running through `at`, a point of the edge, where the wall meets its caps along several),
/// else where the wall meets the cap (all of the faces it lies on).
fn cap_edge(&self, t: Tag, cap: &BTreeSet<FaceOrigin>, at: Vec3) -> Option<Vec<EdgeOrigin>> {
let (x, w) = (t.operand, t.wall?);
let own: Vec<usize> =
cap.iter().filter_map(|o| if let Face { operand, face } = *o { (operand == x).then_some(face) } else { None }).collect();
let index = self.index.get(x)?;
let beside = |e: &usize| index.bounds.get(*e).is_some_and(|(fs, _, _)| fs.contains(&w) && fs.iter().any(|f| own.contains(f)));
if let Some(e) = [t.edge, t.far].into_iter().flatten().find(beside) {
return Some(vec![EdgeOrigin::Edge { operand: x, edge: e }]);
}
let along: Vec<usize> = own.iter().flat_map(|c| self.edges_between(x, [w, *c])).copied().collect();
match along.as_slice() {
[e] => Some(vec![EdgeOrigin::Edge { operand: x, edge: *e }]),
[] => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]),
// One wall with openings meets its caps along several edges (a side wall with ports
// cut through it meets the ports' floors at each port): the one this edge lies on,
// else (a new opening beside them) where the wall meets the cap.
many => match self.edge_through(x, many, at)? {
Some(edge) => Some(vec![EdgeOrigin::Edge { operand: x, edge }]),
None => Some(vec![EdgeOrigin::meet(Self::wall(t)?, Side::new(cap.iter().copied())?)]),
},
}
}
/// Of the operand's edges `edges`, the one the point `at` lies on (`Some(None)` when none
/// does; `None` when several do).
fn edge_through(&self, operand: usize, edges: &[usize], at: Vec3) -> Option<Option<usize>> {
if edges.len() > 4096 {
return None;
}
let body = self.operands.get(operand)?;
let order = crate::body::edge_order(body.solid.face_iter()).0;
let mut on = edges.iter().filter(|e| {
order.get(**e).is_some_and(|edge| {
use mt::{BoundedCurve, ParametricCurve};
let c = edge.curve();
let (t0, t1) = c.range_tuple();
// The edge as a fine polyline: a line exactly, an arc within its chords' sag.
let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 };
let pts: Vec<Vec3> = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect();
let length: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
let near = self.tol * 10.0 + if n == 1 { 0.0 } else { length * 2e-3 };
pts.windows(2).any(|w| at.dist_to_segment(w[0], w[1]) <= near)
})
});
match (on.next(), on.next()) {
(Some(e), None) => Some(Some(*e)),
(None, _) => Some(None),
_ => None,
}
}
/// The origins of the edge between the walls over two segments where the first (tagged
/// `t1`) runs into the second (`t2`), between levels `lo` and `hi`: the operand's edge there
/// between its walls (along the line through the operand vertex the segments meet at, else
/// the one edge between them), where two operands' walls meet, or none within one wall (a
/// segment split there).
fn junction(&self, t1: Tag, t2: Tag, lo: f64, hi: f64) -> Option<Vec<EdgeOrigin>> {
let (p, q) = (Self::wall(t1)?, Self::wall(t2)?);
if t1.operand != t2.operand {
return Some(vec![EdgeOrigin::meet(p, q)]);
}
let (x, w1, w2) = (t1.operand, t1.wall?, t2.wall?);
match (t1.end, t2.start) {
// At that vertex: the operand's edge there (a seam, within one wall), else two edges
// of one wall's cap outline running on through it.
(Some(v), Some(u)) if v == u => self.edge_over(x, [w1, w2], Some(v), lo, hi).or_else(|| (w1 == w2).then(Vec::new)),
_ if w1 == w2 => Some(Vec::new()),
// Elsewhere (a segment split off there, or read off another cap): only the operand's
// walls are around the edge, so the operand has its edge between them there, and
// that is the one edge between them over these levels where there is one.
_ => self.edge_over(x, [w1, w2], None, lo, hi),
}
}
/// The operand's edge between the walls `faces` that runs over the levels `lo` to `hi`,
/// when it has exactly one; with `at`, among the edges between them chained to that vertex.
/// The segments' vertex was read at one level and carried through others, and the line
/// between two walls is in several edges where vertices an earlier operation left (stations)
/// split it: the levels say which of them a slab's edge runs along.
fn edge_over(&self, operand: usize, faces: [usize; 2], at: Option<usize>, lo: f64, hi: f64) -> Option<Vec<EdgeOrigin>> {
let index = self.index.get(operand)?;
let between = self.edges_between(operand, faces);
if between.len() > 4096 {
return None;
}
let ends: Vec<(Option<usize>, Option<usize>)> =
between.iter().map(|e| index.bounds.get(*e).map_or((None, None), |(_, front, back)| (*front, *back))).collect();
// With `at`: the edges reached from it through shared vertices.
let mut chained = vec![at.is_none(); between.len()];
let mut reached: Vec<usize> = at.into_iter().collect();
while let Some(p) = reached.pop() {
for (k, (front, back)) in ends.iter().enumerate() {
if !chained[k] && (*front == Some(p) || *back == Some(p)) {
chained[k] = true;
reached.extend(if *front == Some(p) { *back } else { *front });
}
}
}
let height = |v: Option<usize>| v.and_then(|v| index.points.get(v)).map(|p| self.plane.height(*p));
let mut over = between.iter().zip(&ends).zip(chained).filter_map(|((e, (front, back)), chained)| {
let (a, b) = (height(*front)?, height(*back)?);
(chained && a.min(b) <= lo + self.tol && a.max(b) >= hi - self.tol).then_some(*e)
});
match (over.next(), over.next()) {
(Some(edge), None) => Some(vec![EdgeOrigin::Edge { operand, edge }]),
_ => None,
}
}
/// The origins of the edge between walls over one segment stacked at a level (tagged `t1`
/// below, `t2` above): within one wall (none), along the operand's edge between two of its
/// walls, or where two operands' walls meet.
fn stacked(&self, t1: Tag, t2: Tag) -> Option<Vec<EdgeOrigin>> {
let (p, q) = (Self::wall(t1)?, Self::wall(t2)?);
if p == q {
return Some(Vec::new());
}
if t1.operand != t2.operand {
return Some(vec![EdgeOrigin::meet(p, q)]);
}
// An edge across the direction at a level: the levels cannot tell edges apart here.
self.one_edge(t1.operand, [t1.wall?, t2.wall?])
}
/// The operand's edge between the faces `faces`, when it has exactly one.
fn one_edge(&self, operand: usize, faces: [usize; 2]) -> Option<Vec<EdgeOrigin>> {
match self.edges_between(operand, faces) {
[edge] => Some(vec![EdgeOrigin::Edge { operand, edge: *edge }]),
_ => None,
}
}
/// Faces and edges of a piece `crate::extrude` built from a section of one region through
/// `layers` (see [`Built::Extruded`]). Its history says which faces are walls over which
/// segment (numbered over the region's loops in order) and which are its caps; the start
/// cap's wires run over the segments in loop order (as the extrude builds them), so the
/// vertex after each wire edge is where one segment runs into the next, or splits. A wall
/// through several layers is made from the wall each layer's segment was read beside, and
/// an edge between two walls from the operand edges there in each layer.
#[allow(clippy::type_complexity)]
fn extruded(
&self,
caps: &[Cap],
body: &Body,
layers: &[(Section, f64, f64)],
) -> Option<(Vec<(BTreeSet<FaceOrigin>, bool)>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>)> {
let h = body.history()?;
let ((first, lo, _), (last, _, hi)) = (layers.first()?, layers.last()?);
let (lo, hi) = (*lo, *hi);
let tags: Vec<Vec<Tag>> = layers.iter().map(|(section, ..)| section.segments().1).collect();
let count = tags.first()?.len();
if tags.iter().any(|t| t.len() != count) {
return None;
}
let tag = |layer: usize, segment: usize| tags.get(layer).and_then(|t| t.get(segment)).copied();
let tops = [self.cap_origins(caps, first, lo, false), self.cap_origins(caps, last, hi, true)];
let kinds: Vec<Kind> = h
.faces
.iter()
.map(|f| match f.origins.as_slice() {
[FaceOrigin::Profile { segment }] => (*segment < count).then_some(Kind::Wall(*segment)),
[FaceOrigin::Cap { end }] => Some(Kind::Cap(usize::from(*end))),
_ => None,
})
.collect::<Option<_>>()?;
let faces = kinds
.iter()
.map(|k| match *k {
Kind::Wall(segment) => {
let walls = (0..layers.len()).map(|layer| Self::wall(tag(layer, segment)?)).collect::<Option<BTreeSet<_>>>()?;
let whole = match layers {
[(_, lo, hi)] => self.whole(caps, tag(0, segment)?, *lo, *hi),
_ => false,
};
Some((walls, whole))
}
Kind::Cap(end) => Some(tops.get(end).filter(|o| !o.is_empty())?.clone()).map(|o| (o, false)),
})
.collect::<Option<Vec<_>>>()?;
let (edges, bounds) = edge_order(body.solid.face_iter());
let position: HashMap<mt::EdgeID, usize> = edges.iter().enumerate().map(|(i, e)| (e.id(), i)).collect();
// The segment of the wall beside an edge of the start cap.
let segment_of = |e: &mt::Edge| -> Option<usize> {
bounds.get(*position.get(&e.id())?)?.iter().find_map(|f| match h.faces.get(*f)?.origins.as_slice() {
[FaceOrigin::Profile { segment }] => Some(*segment),
_ => None,
})
};
let start = kinds.iter().position(|k| matches!(k, Kind::Cap(0)))?;
// At each vertex of the start cap: the segment running into it, the one running out,
// and whether that is one segment the extrude split there.
let mut junctions: HashMap<mt::VertexID, (usize, usize, bool)> = HashMap::new();
for w in body.solid.face_iter().nth(start)?.absolute_boundaries() {
let es: Vec<mt::Edge> = w.edge_iter().cloned().collect();
let segs: Vec<usize> = es.iter().map(segment_of).collect::<Option<_>>()?;
if segs.windows(2).any(|p| p.first() > p.get(1)) {
return None;
}
for (k, e) in es.iter().enumerate() {
let (s, next) = (*segs.get(k)?, *segs.get((k + 1) % segs.len())?);
junctions.insert(e.back().id(), (s, next, s == next && k + 1 != segs.len()));
}
}
let mut origins = HashMap::new();
for (e, fs) in edges.iter().zip(&bounds) {
let [f, g] = fs.as_slice() else { return None };
let o = match (kinds.get(*f)?, kinds.get(*g)?) {
(Kind::Wall(segment), Kind::Cap(end)) | (Kind::Cap(end), Kind::Wall(segment)) => {
let layer = if *end == 0 { 0 } else { layers.len() - 1 };
self.cap_edge(tag(layer, *segment)?, tops.get(*end)?, crate::heal::edge_points(e).get(2).copied()?)?
}
(Kind::Wall(..), Kind::Wall(..)) => {
let (s, next, split) = *[e.absolute_front().id(), e.absolute_back().id()].iter().find_map(|v| junctions.get(v))?;
let mut o = BTreeSet::new();
for (layer, (_, lo, hi)) in layers.iter().enumerate() {
let (t, u) = (tag(layer, s)?, tag(layer, next)?);
// A segment the extrude split in two runs on through the vertex between.
let (t, u) = if split { (Tag { end: None, ..t }, Tag { start: None, ..t }) } else { (t, u) };
o.extend(self.junction(t, u, *lo, *hi)?);
}
o.into_iter().collect()
}
_ => return None,
};
origins.insert(e.id(), o);
}
Some((faces, origins))
}
/// Faces and edges of a piece sewn from `specs`: the sewing reports which spec each face was
/// built from. Walls meeting at a vertex of their loop (when two walls meet at both ends,
/// the edge's vertex says which); walls over one segment meeting at a level; walls meeting
/// caps.
#[allow(clippy::type_complexity)]
fn sewn(
&self,
caps: &[Cap],
body: &Body,
specs: &[Spec],
built: &[usize],
) -> Option<(Vec<(BTreeSet<FaceOrigin>, bool)>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>)> {
let cap_of: Vec<Option<BTreeSet<FaceOrigin>>> = specs
.iter()
.map(|s| match s {
Spec::Cap { section, z, up } => Some(self.cap_origins(caps, section, *z, *up)).filter(|o| !o.is_empty()),
Spec::Wall { .. } => None,
})
.collect();
let faces = built
.iter()
.map(|i| match specs.get(*i)? {
Spec::Wall { slabs, .. } => Some((slabs.iter().map(|s| Self::wall(s.tag)).collect::<Option<_>>()?, false)),
Spec::Cap { .. } => cap_of.get(*i)?.clone().map(|o| (o, false)),
})
.collect::<Option<Vec<_>>>()?;
let (edges, bounds) = edge_order(body.solid.face_iter());
let mut origins = HashMap::new();
for (e, fs) in edges.iter().zip(&bounds) {
let [f, g] = fs.as_slice() else { return None };
let (i, j) = (*built.get(*f)?, *built.get(*g)?);
let (x, y) = (specs.get(i)?, specs.get(j)?);
// The edge's levels, lower first, and where it stands in plan.
let (front, back) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
let (h1, h2) = (self.plane.height(front), self.plane.height(back));
let (lo, hi) = (h1.min(h2), h1.max(h2));
let middle = || crate::heal::edge_points(e).get(2).copied();
let o = match (x, y) {
(Spec::Wall { .. }, Spec::Cap { .. }) => self.cap_edge(x.end(lo, self.tol)?.tag, cap_of.get(j)?.as_ref()?, middle()?)?,
(Spec::Cap { .. }, Spec::Wall { .. }) => self.cap_edge(y.end(lo, self.tol)?.tag, cap_of.get(i)?.as_ref()?, middle()?)?,
(Spec::Wall { .. }, Spec::Wall { .. }) if hi - lo <= self.tol => {
// One over the other at a level: the lower first.
let (below, above) = match (x.top(lo, self.tol), y.bottom(lo, self.tol)) {
(Some(s), Some(t)) => (s, t),
_ => (y.top(lo, self.tol)?, x.bottom(lo, self.tol)?),
};
self.stacked(below.tag, above.tag)?
}
(Spec::Wall { .. }, Spec::Wall { .. }) => {
// Side by side within a slab: the vertex the edge stands on (in plan) says
// which way round they meet.
let (s, t) = (x.slab(lo, hi)?, y.slab(lo, hi)?);
let p = self.plane.to_local(front);
let at = |q: &Vec2| p.dist(*q) < self.tol * 10.0;
if s.next == j && at(&s.at) {
self.junction(s.tag, t.tag, lo, hi)?
} else if t.next == i && at(&t.at) {
self.junction(t.tag, s.tag, lo, hi)?
} else {
return None;
}
}
_ => return None,
};
origins.insert(e.id(), o);
}
Some((faces, origins))
}
}
-353
View File
@@ -1,353 +0,0 @@
//! 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.mesh(&crate::MeshTolerance::chord(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.mesh(&crate::MeshTolerance::chord(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))
}
-87
View File
@@ -1,87 +0,0 @@
use super::*;
/// A rounded square of four quadratic curves (a font's kind) about (20, 15).
fn rounded() -> Region2 {
let p = Vec2::new;
let q = |a: Vec2, apex: Vec2, b: Vec2| Seg2::Conic { a, apex, b, w: 1.0 };
let segs = vec![
q(p(10.0, 15.0), p(10.0, 5.0), p(20.0, 5.0)),
q(p(20.0, 5.0), p(30.0, 5.0), p(30.0, 15.0)),
q(p(30.0, 15.0), p(30.0, 25.0), p(20.0, 25.0)),
q(p(20.0, 25.0), p(10.0, 25.0), p(10.0, 15.0)),
];
Region2 { outer: Loop2 { segs }, holes: vec![] }
}
fn block() -> Body {
crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(40.0, 30.0, 10.0)).unwrap()
}
/// Its free-form faces (the tool's walls kept as the curves they were).
fn spline_faces(b: &Body) -> usize {
b.solid.face_iter().filter(|f| matches!(f.surface(), mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_))).count()
}
/// A shape of a font's curves engraved into a block is decided in 2D, its walls the exact curves.
#[test]
fn an_engraving_of_quadratic_curves_is_exact_in_2d() {
let region = rounded();
let area = region.outer.signed_area().abs();
let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&region), 9.0, 11.0).unwrap().remove(0);
let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
// Half the tool, measured as the result is (meshes of curved faces are off by about 1e-4).
let taken = 12000.0 - crate::measure(&out).unwrap().volume;
let half = crate::measure(&tool).unwrap().volume / 2.0;
assert!((taken - half).abs() < 1e-6 * half && (taken - area).abs() < 2e-4 * area, "{taken} vs {half} ({area})");
assert_eq!(spline_faces(&out), 4);
}
/// A shape running off the block's side is cut where its curves exactly cross the side.
#[test]
fn an_engraving_running_off_the_side_ends_on_its_exact_crossings() {
let region = rounded();
// Moved 14 to the left: the block's side x = 0 cuts its two left curves.
let shift = |s: &Seg2| match *s {
Seg2::Conic { a, apex, b, w } => {
Seg2::Conic { a: a - Vec2::new(14.0, 0.0), apex: apex - Vec2::new(14.0, 0.0), b: b - Vec2::new(14.0, 0.0), w }
}
other => other,
};
let moved = Region2 { outer: Loop2 { segs: region.outer.segs.iter().map(shift).collect() }, holes: vec![] };
let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&moved), 9.0, 11.0).unwrap().remove(0);
let out = prism_boolean(&block(), &tool, BoolOp::Cut).expect("the 2D route takes it").unwrap().unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
// What the block keeps of the shape: the shape's part at x > 0.
let inside = planar_regions_boolean(
std::slice::from_ref(&moved),
&[Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]),
holes: vec![],
}],
BoolOp::Intersect,
1e-6,
)
.unwrap();
let area: f64 = inside.iter().map(|r| r.outer.signed_area().abs()).sum();
let taken = 12000.0 - crate::measure(&out).unwrap().volume;
assert!((taken - area).abs() < 2e-4 * area, "{taken} vs {area}");
assert!(spline_faces(&out) >= 2, "{}", spline_faces(&out));
}
/// A tool that misses its target leaves it as it is: the 2D route declines it (the B-rep
/// boolean then reuses the target itself). An engraving taking far less than a ten-thousandth
/// of a large part is a change all the same, and the route keeps it: told from the exact
/// sections, not from volumes measured off meshes, which could not tell it from no change.
#[test]
fn a_tool_that_misses_is_declined_and_a_fine_engraving_is_not() {
let big = crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(200.0, 200.0, 10.0)).unwrap();
let region = rounded();
let tool = |lo: f64, hi: f64| crate::extrude(&Plane::XY, std::slice::from_ref(&region), lo, hi).unwrap().remove(0);
assert!(prism_boolean(&big, &tool(10.0, 12.0), BoolOp::Cut).is_none(), "a tool above the part misses it");
// 0.05 mm deep: 17 mm³ of a 400000 mm³ part.
let out = prism_boolean(&big, &tool(9.95, 11.0), BoolOp::Cut).expect("an engraving is a change").unwrap().unwrap();
let taken = 400_000.0 - crate::measure(&out).unwrap().volume;
let want = region.outer.signed_area().abs() * 0.05;
assert!((taken - want).abs() < 2e-3 * want, "{taken} vs {want}");
}