Chamfer is one of the most common features and is usually applied right after a cut. A chamfer cuts with a prism cutter, and its history came out None after four of the six common box cuts. Without exact history, any reference to a chamfer face (or a face it trimmed) falls back to geometric guessing in naming, which can silently re-attach to the wrong face. Two gaps in the stepped-prism section tags caused it, both now closed by bookkeeping at construction, never by sampling or geometric tie-breaks: - Cap edge at the far end of a slab. A slab's section is read off the cap where it starts and carried up to the cap where it ends; that cap's outline can run along one wall in several collinear edges, so which one a result edge lies on was unknown. When the stack is read, each slab segment that runs end to end along one segment of an ending cap's outline (beside the same wall) records that cap edge as `far`; the lineage takes it when it bounds the wall and the result cap. - Walls an earlier cut left in coplanar pieces. A segment carried through a level kept the wall it was read beside, even where that wall gives way to another piece at the level (or within the slab), so the tag named the wrong face. `Stations` records the operand's edges across walls; at each level a carried segment keeps its wall when nothing crosses it there, moves to the one wall across it when it ends there, and is otherwise unknown (no history). A wall crossed inside a slab is unknown too, for simple and stepped prisms alike. chamfer_after_box_cuts now covers all six cuts (notch, pocket, step, slot, hole, bite), each verified against geometry and composed through the cut over [block, tool]. The layered partial-depth cut test now verifies its second cut, whose history was None before. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2102 lines
101 KiB
Rust
2102 lines
101 KiB
Rust
//! Booleans of prisms in 2D: when the target is a prism (two caps square to one direction,
|
|
//! walls of planes and cylinders along it) and the tool a prism along the same direction
|
|
//! spanning the target's height, the result is the prism over the 2D boolean of their
|
|
//! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
|
|
//! intersection's trouble with walls meeting along lines.
|
|
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
|
|
//! circular boss can meet the rim without shifting either operand off its exact plane.
|
|
|
|
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
|
use truck_modeling as mt;
|
|
|
|
use crate::body::{Body, from_p3};
|
|
use crate::{BoolOp, KernelError, Result};
|
|
|
|
#[path = "prism_lineage.rs"]
|
|
mod lineage;
|
|
|
|
const MAX_PLANAR_SEGMENTS: usize = 8192;
|
|
const MAX_PLANAR_EVENTS: usize = 131_072;
|
|
const MAX_PLANAR_PAIRS: usize = 16_000_000;
|
|
|
|
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
|
|
/// analytic; disjoint results and retained islands are returned as separate regions.
|
|
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
|
|
crate::guard("planar region boolean", || {
|
|
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
|
|
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
|
|
}
|
|
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
|
|
let mut edges = 0usize;
|
|
let mut samples = 0usize;
|
|
for lp in loops() {
|
|
edges = edges.saturating_add(lp.segs.len());
|
|
if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) {
|
|
return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into()));
|
|
}
|
|
for s in &lp.segs {
|
|
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
|
let valid = match *s {
|
|
Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12,
|
|
Seg2::Arc { center, radius, start, sweep } => {
|
|
finite(center)
|
|
&& radius.is_finite()
|
|
&& radius > 0.0
|
|
&& radius < 1e9
|
|
&& start.is_finite()
|
|
&& sweep.is_finite()
|
|
&& sweep.abs() > 1e-12
|
|
&& sweep.abs() <= std::f64::consts::TAU + 1e-9
|
|
}
|
|
_ => false,
|
|
};
|
|
if !valid {
|
|
return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into()));
|
|
}
|
|
samples = samples.saturating_add(s.polyline(1e-3).len());
|
|
if samples > 65_536 {
|
|
return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into()));
|
|
}
|
|
}
|
|
}
|
|
boolean2d(a, b, op, tolerance)
|
|
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
|
|
})
|
|
}
|
|
|
|
/// Extrude normalized planar material as one logical compound, retaining every disjoint
|
|
/// region and its holes. Planar arrangement certifies separation before shells are joined.
|
|
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
|
|
crate::guard("planar material extrusion", || {
|
|
// Validate aggregate inputs first; an arrangement compares opposite operands,
|
|
// so regions in the same operand must be normalized through balanced unions.
|
|
planar_regions_boolean(regions, &[], BoolOp::Union, 1e-7)?;
|
|
let mut groups: Vec<Vec<Region2>> = regions.iter().cloned().map(|r| vec![r]).collect();
|
|
let mut remaining_pairs = 32_000_000usize;
|
|
while groups.len() > 1 {
|
|
let mut next = Vec::new();
|
|
let mut iter = groups.into_iter();
|
|
while let Some(a) = iter.next() {
|
|
if let Some(b) = iter.next() {
|
|
let count = |rs: &[Region2]| {
|
|
rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len()))
|
|
};
|
|
remaining_pairs = remaining_pairs
|
|
.checked_sub(count(&a).saturating_mul(count(&b)))
|
|
.ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?;
|
|
next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?);
|
|
} else {
|
|
next.push(a);
|
|
}
|
|
}
|
|
groups = next;
|
|
}
|
|
let normalized = groups.pop().unwrap_or_default();
|
|
if normalized.is_empty() {
|
|
return Err(KernelError::Invalid("empty planar material extrusion".into()));
|
|
}
|
|
for (i, a) in normalized.iter().enumerate() {
|
|
for b in normalized.iter().skip(i + 1) {
|
|
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
|
|
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
|
|
remaining_pairs = remaining_pairs
|
|
.checked_sub(1)
|
|
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
|
|
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7)
|
|
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7);
|
|
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
|
|
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let pieces = crate::extrude(plane, &normalized, start, end)?;
|
|
crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into()))
|
|
})
|
|
}
|
|
|
|
/// A prism: the plane of its bottom cap (normal along the walls), its height and section (its
|
|
/// segments' tags alongside).
|
|
struct Prism {
|
|
lo: f64,
|
|
hi: f64,
|
|
section: Section,
|
|
}
|
|
|
|
/// What a section's segment was made from, for the history of what is built over it: an
|
|
/// operand's cap edge (`edge`, by its place in the operand's edge order), the wall face beside it
|
|
/// (`wall`), and the operand's vertices (by [`Index`]) the segment starts and ends at (`None`
|
|
/// where it was split off a longer one). Where the wall ends with a slab of a stepped operand, at
|
|
/// a cap whose outline runs along the whole segment, `far` is the operand's edge there (the
|
|
/// segment may have been read off a cap at another level). `None` fields: not known (no history
|
|
/// then).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) struct Tag {
|
|
pub operand: usize,
|
|
pub wall: Option<usize>,
|
|
pub edge: Option<usize>,
|
|
pub far: Option<usize>,
|
|
pub start: Option<usize>,
|
|
pub end: Option<usize>,
|
|
}
|
|
|
|
/// A segment nothing is known of.
|
|
const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None };
|
|
|
|
impl Tag {
|
|
/// The segment run the other way.
|
|
fn reversed(self) -> Tag {
|
|
Tag { start: self.end, end: self.start, ..self }
|
|
}
|
|
|
|
/// Piece `k` of `n` the segment was split into, in its direction.
|
|
fn piece(self, k: usize, n: usize) -> Tag {
|
|
Tag { start: if k == 0 { self.start } else { None }, end: if k + 1 == n { self.end } else { None }, ..self }
|
|
}
|
|
|
|
/// The segment beside `wall` instead (`None`: not known). Its edge and vertices were read
|
|
/// beside the wall it leaves, so they are dropped with it.
|
|
fn beside(self, wall: Option<usize>) -> Tag {
|
|
if wall == self.wall { self } else { Tag { operand: self.operand, wall, ..BLANK } }
|
|
}
|
|
}
|
|
|
|
/// An operand's [`Index`], built when first needed (only bodies that are prisms are read).
|
|
pub(crate) struct LazyIndex<'a> {
|
|
body: &'a Body,
|
|
operand: usize,
|
|
index: std::cell::OnceCell<Index>,
|
|
}
|
|
|
|
impl<'a> LazyIndex<'a> {
|
|
fn new(body: &'a Body, operand: usize) -> LazyIndex<'a> {
|
|
LazyIndex { body, operand, index: std::cell::OnceCell::new() }
|
|
}
|
|
|
|
fn get(&self) -> &Index {
|
|
self.index.get_or_init(|| Index::new(self.body, self.operand))
|
|
}
|
|
}
|
|
|
|
/// Regions with a tag for each segment: per region, per loop (outer, then holes), per segment.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct Section {
|
|
pub regions: Vec<Region2>,
|
|
pub tags: Vec<Vec<Vec<Tag>>>,
|
|
}
|
|
|
|
impl Section {
|
|
/// Regions whose segments nothing is known of.
|
|
fn untagged(regions: &[Region2]) -> Section {
|
|
let tags = regions.iter().map(|r| std::iter::once(&r.outer).chain(&r.holes).map(|l| vec![BLANK; l.segs.len()]).collect()).collect();
|
|
Section { regions: regions.to_vec(), tags }
|
|
}
|
|
|
|
/// The tag of segment `s` of loop `l` of region `r` (blank where missing).
|
|
fn tag(&self, r: usize, l: usize, s: usize) -> Tag {
|
|
self.tags.get(r).and_then(|t| t.get(l)).and_then(|t| t.get(s)).copied().unwrap_or(BLANK)
|
|
}
|
|
|
|
/// The tags of region `r`'s loops, as long as its loops (blank where missing).
|
|
fn region_tags(&self, r: usize) -> Vec<Vec<Tag>> {
|
|
self.regions
|
|
.get(r)
|
|
.map(|reg| {
|
|
std::iter::once(®.outer)
|
|
.chain(®.holes)
|
|
.enumerate()
|
|
.map(|(l, lp)| (0..lp.segs.len()).map(|s| self.tag(r, l, s)).collect())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// One region from its loops (each segment tagged): the loop with the largest area is the
|
|
/// outer one.
|
|
fn from_loops(loops: Vec<(Loop2, Vec<Tag>)>) -> Option<Section> {
|
|
let ((outer, outer_tags), holes) = split_outer(loops)?;
|
|
let (holes, hole_tags): (Vec<Loop2>, Vec<Vec<Tag>>) = holes.into_iter().unzip();
|
|
Some(Section { regions: vec![Region2 { outer, holes }], tags: vec![std::iter::once(outer_tags).chain(hole_tags).collect()] })
|
|
}
|
|
|
|
/// Region `r` alone.
|
|
fn region(&self, r: usize) -> Section {
|
|
Section { regions: self.regions.get(r).cloned().into_iter().collect(), tags: vec![self.region_tags(r)] }
|
|
}
|
|
|
|
/// Every segment in order (regions, then loops) with its tag.
|
|
fn segments(&self) -> (Vec<Seg2>, Vec<Tag>) {
|
|
let mut out = (Vec::new(), Vec::new());
|
|
for (r, reg) in self.regions.iter().enumerate() {
|
|
for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
|
for (s, seg) in lp.segs.iter().enumerate() {
|
|
out.0.push(*seg);
|
|
out.1.push(self.tag(r, l, s));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Each segment's tag mapped by `f`.
|
|
fn retag(&mut self, f: impl Fn(Tag) -> Tag) {
|
|
for t in self.tags.iter_mut().flatten().flatten() {
|
|
*t = f(*t);
|
|
}
|
|
}
|
|
|
|
/// Mark each segment that runs end to end along a segment of the outline of one of `caps`
|
|
/// (the operand's caps ending its slab) read beside the same wall with that segment's edge
|
|
/// as its `far` edge: there the wall meets that cap. A segment along none, or along segments
|
|
/// of different edges, stays unmarked; so do those past `budget` (segment pairs compared).
|
|
fn mark_far(&mut self, caps: &[&Section], tol: f64, budget: &mut usize) {
|
|
let mut by_wall: std::collections::HashMap<usize, Vec<(Seg2, Option<usize>)>> = std::collections::HashMap::new();
|
|
for (segs, tags) in caps.iter().map(|c| c.segments()) {
|
|
for (seg, tag) in segs.into_iter().zip(tags) {
|
|
if let Some(w) = tag.wall {
|
|
by_wall.entry(w).or_default().push((seg, tag.edge));
|
|
}
|
|
}
|
|
}
|
|
for (reg, tags) in self.regions.iter().zip(&mut self.tags) {
|
|
for (lp, tags) in std::iter::once(®.outer).chain(®.holes).zip(tags) {
|
|
for (seg, tag) in lp.segs.iter().zip(tags) {
|
|
let Some(beside) = tag.wall.and_then(|w| by_wall.get(&w)) else { continue };
|
|
let Some(left) = budget.checked_sub(beside.len()) else { return };
|
|
*budget = left;
|
|
// The one edge it runs along (`Some(None)`: along none, or not one edge).
|
|
let mut along: Option<Option<usize>> = None;
|
|
for (_, edge) in beside.iter().filter(|(t, _)| runs_along(seg, t, tol)) {
|
|
along = match along {
|
|
None => Some(*edge),
|
|
Some(e) if e == *edge => Some(e),
|
|
Some(_) => Some(None),
|
|
};
|
|
}
|
|
if let Some(Some(e)) = along {
|
|
tag.far = Some(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Segments beside a wall another crosses between levels `lo` and `hi` are not beside it
|
|
/// all the way: their walls are not known.
|
|
fn drop_crossed(&mut self, stations: &Stations, lo: f64, hi: f64, tol: f64) {
|
|
self.retag(|t| match t.wall {
|
|
Some(w) if stations.across(w, lo, hi, tol) => t.beside(None),
|
|
_ => t,
|
|
});
|
|
}
|
|
|
|
/// Loops oriented: outers counter-clockwise, holes clockwise; the tags following their
|
|
/// segments.
|
|
fn oriented(&self) -> Section {
|
|
let turn = |lp: &Loop2, tags: Vec<Tag>, reverse: bool| -> (Loop2, Vec<Tag>) {
|
|
if reverse { (lp.reversed(), tags.into_iter().rev().map(Tag::reversed).collect()) } else { (lp.clone(), tags) }
|
|
};
|
|
let mut out = Section::default();
|
|
for (r, reg) in self.regions.iter().enumerate() {
|
|
let mut tags = self.region_tags(r).into_iter();
|
|
let (outer, outer_tags) = turn(®.outer, tags.next().unwrap_or_default(), reg.outer.signed_area() < 0.0);
|
|
let mut loop_tags = vec![outer_tags];
|
|
let mut holes = Vec::new();
|
|
for h in ®.holes {
|
|
let (hole, hole_tags) = turn(h, tags.next().unwrap_or_default(), h.signed_area() > 0.0);
|
|
holes.push(hole);
|
|
loop_tags.push(hole_tags);
|
|
}
|
|
out.regions.push(Region2 { outer, holes });
|
|
out.tags.push(loop_tags);
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// An operand's faces, edges and vertices by identity, to tag the section segments read off its
|
|
/// caps ([`Tag`]).
|
|
pub(crate) struct Index {
|
|
operand: usize,
|
|
/// Each edge's place in the edge order.
|
|
edges: std::collections::HashMap<mt::EdgeID, usize>,
|
|
/// Per edge (edge order): the faces it bounds, and its ends (by vertex number).
|
|
bounds: Vec<(Vec<usize>, Option<usize>, Option<usize>)>,
|
|
/// The edges between each pair of faces (lesser first; a face twice for a seam).
|
|
between: std::collections::HashMap<(usize, usize), Vec<usize>>,
|
|
/// Each vertex's number (first seen along the edge order).
|
|
vertices: std::collections::HashMap<mt::VertexID, usize>,
|
|
/// Each vertex's point, by number.
|
|
points: Vec<Vec3>,
|
|
/// Per edge (edge order): whether it is a line.
|
|
lines: Vec<bool>,
|
|
}
|
|
|
|
impl Index {
|
|
fn new(b: &Body, operand: usize) -> Index {
|
|
let (edges, bounds) = crate::body::edge_order(b.solid.face_iter());
|
|
let mut vertices = std::collections::HashMap::new();
|
|
let mut points = Vec::new();
|
|
for e in &edges {
|
|
for v in [e.absolute_front(), e.absolute_back()] {
|
|
if let std::collections::hash_map::Entry::Vacant(slot) = vertices.entry(v.id()) {
|
|
slot.insert(points.len());
|
|
points.push(from_p3(v.point()));
|
|
}
|
|
}
|
|
}
|
|
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
|
|
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
|
|
for (i, fs) in bounds.iter().enumerate() {
|
|
match fs.as_slice() {
|
|
[f] => between.entry((*f, *f)).or_default().push(i),
|
|
[f, g] => between.entry((*f.min(g), *f.max(g))).or_default().push(i),
|
|
_ => {}
|
|
}
|
|
}
|
|
let bounds = edges
|
|
.iter()
|
|
.zip(bounds)
|
|
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
|
|
.collect();
|
|
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines }
|
|
}
|
|
|
|
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
|
|
fn tag(&self, e: &mt::Edge, cap: usize) -> Tag {
|
|
let edge = self.edges.get(&e.id()).copied();
|
|
let wall = edge.and_then(|i| self.bounds.get(i)).and_then(|(fs, _, _)| match fs.as_slice() {
|
|
[x, y] if *x == cap => Some(*y),
|
|
[x, y] if *y == cap => Some(*x),
|
|
_ => None,
|
|
});
|
|
Tag {
|
|
operand: self.operand,
|
|
wall,
|
|
edge,
|
|
far: None,
|
|
start: self.vertices.get(&e.front().id()).copied(),
|
|
end: self.vertices.get(&e.back().id()).copied(),
|
|
}
|
|
}
|
|
|
|
/// The loops of cap face `f` (number `cap`) as segments, each with its tag; `None` where an
|
|
/// edge isn't a line or an arc in the plane.
|
|
fn loops(&self, f: &mt::Face, cap: usize, plane: &Plane, tol: f64, work: &mut usize, segments: &mut usize) -> Option<Vec<(Loop2, Vec<Tag>)>> {
|
|
let mut loops = Vec::new();
|
|
for w in f.boundaries() {
|
|
let (mut segs, mut tags) = (Vec::new(), Vec::new());
|
|
for e in w.edge_iter() {
|
|
let pieces = crate::prism_curve::segment(e, plane, tol, work)?;
|
|
*segments = segments.checked_sub(pieces.len())?;
|
|
let tag = self.tag(e, cap);
|
|
tags.extend((0..pieces.len()).map(|k| tag.piece(k, pieces.len())));
|
|
segs.extend(pieces);
|
|
}
|
|
loops.push((Loop2 { segs }, tags));
|
|
}
|
|
Some(loops)
|
|
}
|
|
}
|
|
|
|
/// Where an operand's walls along a direction end other than at its caps: at edges between two
|
|
/// walls that do not run along the direction (between pieces of one surface an earlier operation
|
|
/// left apart). A section segment carried through a level keeps the wall it was read beside
|
|
/// only where these say so, as its operand's prism or slab is read.
|
|
struct Stations {
|
|
/// Each wall's highest level.
|
|
tops: std::collections::HashMap<usize, f64>,
|
|
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
|
|
/// A curve other than a line spans every level.
|
|
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
|
|
}
|
|
|
|
impl Stations {
|
|
/// From the operand's walls (by face number, with their highest levels).
|
|
fn new(index: &Index, tops: std::collections::HashMap<usize, f64>, d: Vec3, plane: &Plane, tol: f64) -> Stations {
|
|
let mut cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>> = std::collections::HashMap::new();
|
|
for (i, (fs, front, back)) in index.bounds.iter().enumerate() {
|
|
let [f, g] = fs[..] else { continue };
|
|
if f == g || !tops.contains_key(&f) || !tops.contains_key(&g) {
|
|
continue;
|
|
}
|
|
let ends = front.and_then(|v| index.points.get(v)).zip(back.and_then(|v| index.points.get(v)));
|
|
let (lo, hi) = match ends {
|
|
Some((p, q)) if index.lines.get(i) == Some(&true) => {
|
|
let run = *q - *p;
|
|
if (run - d * run.dot(d)).len() < tol {
|
|
continue; // Along the direction: between walls over two segments.
|
|
}
|
|
let (h, k) = (plane.height(*p), plane.height(*q));
|
|
(h.min(k), h.max(k))
|
|
}
|
|
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
|
};
|
|
cross.entry(f).or_default().push((g, lo, hi));
|
|
cross.entry(g).or_default().push((f, lo, hi));
|
|
}
|
|
Stations { tops, cross }
|
|
}
|
|
|
|
/// The edges across wall `w`.
|
|
fn of(&self, w: usize) -> &[(usize, f64, f64)] {
|
|
self.cross.get(&w).map_or(&[], Vec::as_slice)
|
|
}
|
|
|
|
/// Does wall `w` meet another wall across it between levels `lo` and `hi` (not at them)?
|
|
fn across(&self, w: usize, lo: f64, hi: f64, tol: f64) -> bool {
|
|
self.of(w).iter().any(|(_, l, h)| *l < hi - tol && *h > lo + tol)
|
|
}
|
|
|
|
/// The wall over a section segment beside wall `w` just past level `z`, when the segment
|
|
/// runs on through `z` without meeting a cap there: `w` itself when nothing crosses it at
|
|
/// `z`, and it reaches past; the one wall across it at `z` when it ends there; else not
|
|
/// known.
|
|
fn past(&self, w: usize, z: f64, tol: f64) -> Option<usize> {
|
|
let at: std::collections::BTreeSet<usize> =
|
|
self.of(w).iter().filter(|(_, l, h)| (*l - z).abs() < tol && (*h - z).abs() < tol).map(|(y, _, _)| *y).collect();
|
|
let reaches = self.tops.get(&w).is_some_and(|t| *t > z + tol);
|
|
match (at.len(), reaches) {
|
|
(0, true) => Some(w),
|
|
(1, false) => at.first().copied(),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The highest level of face `f`'s vertices.
|
|
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
|
|
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
|
|
}
|
|
|
|
/// Certify that a curved wall is swept along `d` from its surface representation.
|
|
/// Seam vertices alone cannot establish this: a half-cylinder's seam normals can
|
|
/// both be perpendicular to a transverse direction, silently filling its hole.
|
|
fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool {
|
|
if let mt::Surface::RevolutedCurve(processor) = surface {
|
|
let revolution = processor.entity();
|
|
let mt::Curve::Line(line) = revolution.entity_curve() else { return false };
|
|
let axis = revolution.axis();
|
|
let meridian = line.1 - line.0;
|
|
let local_axis = Vec3::new(axis.x, axis.y, axis.z);
|
|
let local_meridian = Vec3::new(meridian.x, meridian.y, meridian.z);
|
|
let (Some(a), Some(m)) = (local_axis.normalized(), local_meridian.normalized()) else { return false };
|
|
if a.cross(m).len() > 1e-9 {
|
|
return false; // A conical meridian is not a cylindrical wall.
|
|
}
|
|
let placed = *processor.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.0);
|
|
return placed.w.abs() < 1e-12 && Vec3::new(placed.x, placed.y, placed.z).normalized().is_some_and(|a| a.cross(d).len() < 1e-9);
|
|
}
|
|
let dimensions = match surface {
|
|
mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)),
|
|
mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)),
|
|
_ => return false,
|
|
};
|
|
let (rows, cols) = dimensions;
|
|
let count = rows.saturating_mul(cols);
|
|
let rectangular = match surface {
|
|
mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
|
|
mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols),
|
|
_ => false,
|
|
};
|
|
if rows < 2 || cols < 2 || count > 16_384 || !rectangular {
|
|
return false;
|
|
}
|
|
let Some(left) = remaining.checked_sub(count.saturating_mul(2)) else { return false };
|
|
*remaining = left;
|
|
let points: Vec<Vec<(Vec3, f64)>> = match surface {
|
|
mt::Surface::BSplineSurface(s) => s.control_points().iter().map(|r| r.iter().map(|p| (from_p3(*p), 1.0)).collect()).collect(),
|
|
mt::Surface::NurbsSurface(s) => {
|
|
s.control_points().iter().map(|r| r.iter().map(|p| (Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w), p.w)).collect()).collect()
|
|
}
|
|
_ => return false,
|
|
};
|
|
if points.iter().any(|r| r.len() != cols)
|
|
|| points.iter().flatten().any(|(p, w)| !w.is_finite() || *w <= 0.0 || [p.x, p.y, p.z].iter().any(|x| !x.is_finite()))
|
|
{
|
|
return false;
|
|
}
|
|
let max_weight = points.iter().flatten().map(|p| p.1).fold(0.0, f64::max);
|
|
if points.iter().flatten().any(|p| p.1 / max_weight < 1e-12) {
|
|
return false;
|
|
}
|
|
// Either parameter can be the extrusion direction. Projected positions must
|
|
// be invariant in that parameter; rational weights must also separate into
|
|
// a product of one factor per parameter, otherwise mixed weights bend the wall.
|
|
for transpose in [false, true] {
|
|
let (groups, length) = if transpose { (cols, rows) } else { (rows, cols) };
|
|
let at = |i: usize, j: usize| if transpose { points[j][i] } else { points[i][j] };
|
|
let mut valid = true;
|
|
let mut extended = false;
|
|
for i in 0..groups {
|
|
let (base, base_w) = at(i, 0);
|
|
for j in 0..length {
|
|
let (p, w) = at(i, j);
|
|
let delta = p - base;
|
|
let magnitude = [p.x.abs(), p.y.abs(), p.z.abs(), base.x.abs(), base.y.abs(), base.z.abs()].into_iter().fold(1.0, f64::max);
|
|
if delta.cross(d).len() > tol * 1e-3 + magnitude * f64::EPSILON * 128.0 {
|
|
valid = false;
|
|
break;
|
|
}
|
|
extended |= delta.dot(d).abs() > tol * 1e-3;
|
|
let lhs = w / max_weight * (at(0, 0).1 / max_weight);
|
|
let rhs = base_w / max_weight * (at(0, j).1 / max_weight);
|
|
if (lhs - rhs).abs() > 1e-12 * lhs.abs().max(rhs.abs()) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
if !valid {
|
|
break;
|
|
}
|
|
}
|
|
if valid && extended {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The body as a prism along `d` (unit), seen in `plane` (normal `d`).
|
|
#[cfg(test)]
|
|
fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
|
prism_tagged(b, d, plane, tol, &LazyIndex::new(b, 0))
|
|
}
|
|
|
|
/// [`prism_of`], its section's segments tagged from `index` (the body's).
|
|
fn prism_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) -> Option<Prism> {
|
|
// A simple extrusion with N section edges has N walls plus its cap faces.
|
|
// Even disconnected two-arc sections need at most 2N faces. Keep that
|
|
// complete planar arrangement envelope, independently of stepped limits.
|
|
let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2;
|
|
let mut lo = f64::INFINITY;
|
|
let mut hi = f64::NEG_INFINITY;
|
|
let mut tops: Vec<(usize, &mt::Face)> = Vec::new();
|
|
let mut top_h = Vec::new();
|
|
let mut bottom_h = Vec::new();
|
|
let mut walls = std::collections::HashMap::new();
|
|
let mut wall_work = 262_144;
|
|
let mut curve_work = 2_000_000usize;
|
|
for (fi, f) in b.solid.face_iter().enumerate() {
|
|
remaining_faces = remaining_faces.checked_sub(1)?;
|
|
curve_work = curve_work.checked_sub(1)?;
|
|
let surf = f.oriented_surface();
|
|
if let mt::Surface::Plane(p) = &surf {
|
|
let n = p.normal();
|
|
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
|
let h = plane.height(from_p3(p.origin()));
|
|
if n.dot(d) > 1.0 - 1e-9 {
|
|
tops.push((fi, f));
|
|
top_h.push(h);
|
|
hi = hi.max(h);
|
|
continue;
|
|
}
|
|
if n.dot(d) < -1.0 + 1e-9 {
|
|
bottom_h.push(h);
|
|
lo = lo.min(h);
|
|
continue;
|
|
}
|
|
if n.dot(d).abs() > 1e-9 {
|
|
return None;
|
|
}
|
|
walls.insert(fi, top_of(f, plane));
|
|
continue;
|
|
}
|
|
if !wall_along(&surf, d, tol, &mut wall_work) {
|
|
return None;
|
|
}
|
|
walls.insert(fi, top_of(f, plane));
|
|
}
|
|
// One height for every top (and bottom) cap.
|
|
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
|
return None;
|
|
}
|
|
let mut section = Section::default();
|
|
let mut remaining_segments = MAX_PLANAR_SEGMENTS;
|
|
let mut remaining_loops = MAX_PLANAR_SEGMENTS;
|
|
for (fi, f) in tops {
|
|
remaining_loops = remaining_loops.checked_sub(f.boundaries().len())?;
|
|
let loops = index.get().loops(f, fi, plane, tol, &mut curve_work, &mut remaining_segments)?;
|
|
let one = Section::from_loops(loops)?;
|
|
section.regions.extend(one.regions);
|
|
section.tags.extend(one.tags);
|
|
}
|
|
section.drop_crossed(&Stations::new(index.get(), walls, d, plane, tol), lo, hi, tol);
|
|
Some(Prism { lo, hi, section })
|
|
}
|
|
|
|
/// The loop with the largest area is the outer one.
|
|
fn split_outer<T>(mut loops: Vec<(Loop2, T)>) -> Option<((Loop2, T), Vec<(Loop2, T)>)> {
|
|
let i = (0..loops.len()).max_by(|a, b| loops[*a].0.signed_area().abs().total_cmp(&loops[*b].0.signed_area().abs()))?;
|
|
let outer = loops.remove(i);
|
|
Some((outer, loops))
|
|
}
|
|
|
|
fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
|
|
b.solid.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold((f64::INFINITY, f64::NEG_INFINITY), |m, h| (m.0.min(h), m.1.max(h)))
|
|
}
|
|
|
|
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
|
|
/// circle, `s`'s ends and middle on `t`.
|
|
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
|
|
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol;
|
|
match (*s, *t) {
|
|
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
|
|
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
|
|
c.dist(d) < tol && (r - q).abs() < tol && on(s.start()) && on(s.end()) && on(s.point_at(0.5))
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Points where two segments cross (parameters on each), lines and arcs only.
|
|
fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
|
|
let mut pts: Vec<Vec2> = Vec::new();
|
|
match (*s, *t) {
|
|
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
|
let (r, q) = (b - a, d - c);
|
|
let den = r.cross(q);
|
|
if den.abs() > 1e-300 {
|
|
let u = (c - a).cross(q) / den;
|
|
pts.push(a + r * u);
|
|
}
|
|
}
|
|
(Seg2::Line { a, b }, Seg2::Arc { center, radius, .. }) | (Seg2::Arc { center, radius, .. }, Seg2::Line { a, b }) => {
|
|
let dir = b - a;
|
|
let f = a - center;
|
|
let (qa, qb, qc) = (dir.dot(dir), 2.0 * f.dot(dir), f.dot(f) - radius * radius);
|
|
let disc = qb * qb - 4.0 * qa * qc;
|
|
if qa > 1e-300 && disc >= -tol * tol * qa {
|
|
let sd = disc.max(0.0).sqrt();
|
|
for u in [(-qb - sd) / (2.0 * qa), (-qb + sd) / (2.0 * qa)] {
|
|
pts.push(a + dir * u);
|
|
}
|
|
}
|
|
}
|
|
(Seg2::Arc { center: c0, radius: r0, .. }, Seg2::Arc { center: c1, radius: r1, .. }) => {
|
|
let dd = c0.dist(c1);
|
|
if dd > 1e-12 && dd <= r0 + r1 + tol && dd >= (r0 - r1).abs() - tol {
|
|
let a = (r0 * r0 - r1 * r1 + dd * dd) / (2.0 * dd);
|
|
let h = (r0 * r0 - a * a).max(0.0).sqrt();
|
|
let m = c0 + (c1 - c0) * (a / dd);
|
|
let perp = (c1 - c0).perp() * (1.0 / dd);
|
|
pts.push(m + perp * h);
|
|
pts.push(m - perp * h);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
let mut out = Vec::new();
|
|
for p in pts {
|
|
let (u, v) = (s.closest_param(p), t.closest_param(p));
|
|
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
|
|
out.push((u, v));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a
|
|
/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords.
|
|
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
|
|
let mut odd = false;
|
|
for seg in &lp.segs {
|
|
match *seg {
|
|
Seg2::Line { a, b } => {
|
|
if (a.y > p.y) != (b.y > p.y) {
|
|
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
|
|
if x > p.x {
|
|
odd = !odd;
|
|
}
|
|
}
|
|
}
|
|
Seg2::Arc { center, radius, start, sweep } => {
|
|
let y = (p.y - center.y) / radius;
|
|
if !(-1.0..=1.0).contains(&y) {
|
|
continue;
|
|
}
|
|
let a = y.asin();
|
|
let root = (1.0 - y * y).max(0.0).sqrt();
|
|
let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12;
|
|
for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() {
|
|
// A tangent has one geometric root. Interior tangencies contribute zero
|
|
// parity; a minimum at an endpoint follows the same half-open rule as lines.
|
|
if root == 0.0 && i == 1 {
|
|
continue;
|
|
}
|
|
if x <= p.x {
|
|
continue;
|
|
}
|
|
let mut delta = if sweep > 0.0 { angle - start } else { start - angle };
|
|
delta = delta.rem_euclid(std::f64::consts::TAU);
|
|
if (delta - std::f64::consts::TAU).abs() < 1e-12 {
|
|
delta = 0.0;
|
|
}
|
|
if delta > sweep.abs() + 1e-12 {
|
|
continue;
|
|
}
|
|
let dy = angle.cos() * sweep;
|
|
let tangent = dy.abs() < sweep.abs() * 1e-12;
|
|
let at_start = delta < 1e-12;
|
|
let at_end = (delta - sweep.abs()).abs() < 1e-12;
|
|
let crosses = if full {
|
|
!tangent
|
|
} else if at_start {
|
|
dy > 0.0 && !tangent || tangent && y < 0.0
|
|
} else if at_end {
|
|
dy < 0.0 && !tangent || tangent && y < 0.0
|
|
} else {
|
|
!tangent
|
|
};
|
|
if crosses {
|
|
odd = !odd;
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// Private prism callers only supply lines/arcs. Keep a controlled fallback
|
|
// for future internal callers; the public helper rejects other segment kinds.
|
|
let points = seg.polyline(1e-7);
|
|
for pair in points.windows(2) {
|
|
if let [a, b] = pair
|
|
&& (a.y > p.y) != (b.y > p.y)
|
|
&& a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x
|
|
{
|
|
odd = !odd;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
odd
|
|
}
|
|
|
|
fn inside(regions: &[Region2], p: Vec2) -> bool {
|
|
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
|
|
}
|
|
|
|
fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
|
|
regions
|
|
.iter()
|
|
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
|
|
.flat_map(|l| l.segs.iter())
|
|
.map(|s| s.point_at(s.closest_param(p)).dist(p))
|
|
.fold(f64::INFINITY, f64::min)
|
|
}
|
|
|
|
/// Direction of the boundary of `regions` through `p` (on it), as the boundary runs.
|
|
fn dir_at(regions: &[Region2], p: Vec2, tol: f64) -> Option<Vec2> {
|
|
for r in regions {
|
|
for l in std::iter::once(&r.outer).chain(&r.holes) {
|
|
for s in &l.segs {
|
|
let t = s.closest_param(p);
|
|
if s.point_at(t).dist(p) < tol {
|
|
return s.derivative(t).normalized();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 2D boolean of regions bounded by lines and arcs.
|
|
fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<Region2>> {
|
|
boolean2d_tagged(&Section::untagged(a), &Section::untagged(b), op, tol).map(|s| s.regions)
|
|
}
|
|
|
|
/// [`boolean2d`] of tagged sections: each segment of the result tagged as the segment of `a` or
|
|
/// `b` it is (a piece of).
|
|
fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Section> {
|
|
let count =
|
|
|rs: &[Region2]| rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, lp| n.saturating_add(lp.segs.len()));
|
|
let (na, nb) = (count(&a.regions), count(&b.regions));
|
|
if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS {
|
|
return None;
|
|
}
|
|
let (a, b) = (a.oriented(), b.oriented());
|
|
let ((sa, ta), (sb, tb)) = (a.segments(), b.segments());
|
|
let (a, b) = (a.regions, b.regions);
|
|
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
|
|
return None;
|
|
}
|
|
// Split every segment where it crosses the other side's boundary.
|
|
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
|
|
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
|
|
let mut events = 0usize;
|
|
for (i, s) in sa.iter().enumerate() {
|
|
for (j, t) in sb.iter().enumerate() {
|
|
for (u, v) in crossings(s, t, tol) {
|
|
events += 2;
|
|
if events > MAX_PLANAR_EVENTS {
|
|
return None;
|
|
}
|
|
cut_a.get_mut(i)?.push(u);
|
|
cut_b.get_mut(j)?.push(v);
|
|
}
|
|
}
|
|
}
|
|
// Ends of the other side's segments lying on a segment split it too (touching corners).
|
|
for (i, s) in sa.iter().enumerate() {
|
|
for t in &sb {
|
|
for p in [t.start(), t.end()] {
|
|
let u = s.closest_param(p);
|
|
if s.point_at(u).dist(p) < tol {
|
|
events += 1;
|
|
if events > MAX_PLANAR_EVENTS {
|
|
return None;
|
|
}
|
|
cut_a.get_mut(i)?.push(u);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (j, t) in sb.iter().enumerate() {
|
|
for s in &sa {
|
|
for p in [s.start(), s.end()] {
|
|
let v = t.closest_param(p);
|
|
if t.point_at(v).dist(p) < tol {
|
|
events += 1;
|
|
if events > MAX_PLANAR_EVENTS {
|
|
return None;
|
|
}
|
|
cut_b.get_mut(j)?.push(v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let pieces = |s: &Seg2, cuts: &mut Vec<f64>| -> Vec<Seg2> {
|
|
cuts.sort_by(f64::total_cmp);
|
|
let mut out = Vec::new();
|
|
let mut rest = *s;
|
|
let mut done = 0.0;
|
|
for &c in cuts.iter() {
|
|
if c <= done + 1e-9 || c >= 1.0 - 1e-9 {
|
|
continue;
|
|
}
|
|
let local = (c - done) / (1.0 - done);
|
|
let (x, y) = rest.split_at(local);
|
|
if x.length() > tol {
|
|
out.push(x);
|
|
rest = y;
|
|
done = c;
|
|
}
|
|
}
|
|
out.push(rest);
|
|
out
|
|
};
|
|
let mut kept: Vec<(Seg2, Tag)> = Vec::new();
|
|
// A's pieces.
|
|
for (i, s) in sa.iter().enumerate() {
|
|
if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS {
|
|
return None;
|
|
}
|
|
let split = pieces(s, cut_a.get_mut(i)?);
|
|
let (n, tag) = (split.len(), ta.get(i).copied().unwrap_or(BLANK));
|
|
for (k, p) in split.into_iter().enumerate() {
|
|
let m = p.point_at(0.5);
|
|
let on = dist_to(&b, m) < tol;
|
|
let keep = if on {
|
|
let same = dir_at(&b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
|
|
match op {
|
|
BoolOp::Union | BoolOp::Intersect => same,
|
|
BoolOp::Cut => !same,
|
|
}
|
|
} else {
|
|
let ins = inside(&b, m);
|
|
match op {
|
|
BoolOp::Union | BoolOp::Cut => !ins,
|
|
BoolOp::Intersect => ins,
|
|
}
|
|
};
|
|
if keep {
|
|
if kept.len() >= MAX_PLANAR_SEGMENTS {
|
|
return None;
|
|
}
|
|
kept.push((p, tag.piece(k, n)));
|
|
}
|
|
}
|
|
}
|
|
// B's pieces (coincident ones were decided with A's).
|
|
for (j, t) in sb.iter().enumerate() {
|
|
if cut_b.get(j)?.len() > MAX_PLANAR_SEGMENTS {
|
|
return None;
|
|
}
|
|
let split = pieces(t, cut_b.get_mut(j)?);
|
|
let (n, tag) = (split.len(), tb.get(j).copied().unwrap_or(BLANK));
|
|
for (k, p) in split.into_iter().enumerate() {
|
|
let m = p.point_at(0.5);
|
|
if dist_to(&a, m) < tol {
|
|
continue;
|
|
}
|
|
let ins = inside(&a, m);
|
|
if kept.len() >= MAX_PLANAR_SEGMENTS {
|
|
return None;
|
|
}
|
|
let tag = tag.piece(k, n);
|
|
match op {
|
|
BoolOp::Union if !ins => kept.push((p, tag)),
|
|
BoolOp::Intersect if ins => kept.push((p, tag)),
|
|
BoolOp::Cut if ins => kept.push((p.reversed(), tag.reversed())),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
// Chain into loops.
|
|
let mut loops: Vec<(Loop2, Vec<Tag>)> = Vec::new();
|
|
let mut used = vec![false; kept.len()];
|
|
for start in 0..kept.len() {
|
|
if used.get(start).copied().unwrap_or(true) {
|
|
continue;
|
|
}
|
|
let (seg, tag) = *kept.get(start)?;
|
|
let (mut segs, mut tags) = (vec![seg], vec![tag]);
|
|
if let Some(u) = used.get_mut(start) {
|
|
*u = true;
|
|
}
|
|
let first = seg.start();
|
|
let mut guard = 0;
|
|
loop {
|
|
guard += 1;
|
|
if guard > kept.len() + 2 {
|
|
return None;
|
|
}
|
|
let end = segs.last()?.end();
|
|
let full_circle =
|
|
segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9));
|
|
if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) {
|
|
break;
|
|
}
|
|
let next = (0..kept.len())
|
|
.filter(|k| !used.get(*k).copied().unwrap_or(true))
|
|
.min_by(|x, y| kept[*x].0.start().dist(end).total_cmp(&kept[*y].0.start().dist(end)))?;
|
|
let (seg, tag) = *kept.get(next)?;
|
|
if seg.start().dist(end) > tol * 10.0 {
|
|
if end.dist(first) < tol * 10.0 {
|
|
break;
|
|
}
|
|
return None;
|
|
}
|
|
if let Some(u) = used.get_mut(next) {
|
|
*u = true;
|
|
}
|
|
segs.push(seg);
|
|
tags.push(tag);
|
|
}
|
|
let l = Loop2 { segs };
|
|
if l.signed_area().abs() > tol * tol {
|
|
loops.push((l, tags));
|
|
}
|
|
}
|
|
// Outers (counter-clockwise) take the holes they contain, the smallest first.
|
|
let (mut outers, holes): (Vec<(Loop2, Vec<Tag>)>, Vec<(Loop2, Vec<Tag>)>) = loops.into_iter().partition(|(l, _)| l.signed_area() > 0.0);
|
|
outers.sort_by(|x, y| x.0.signed_area().abs().total_cmp(&y.0.signed_area().abs()));
|
|
let mut out = Section::default();
|
|
for (o, tags) in outers {
|
|
out.regions.push(Region2 { outer: o, holes: Vec::new() });
|
|
out.tags.push(vec![tags]);
|
|
}
|
|
for (h, tags) in holes {
|
|
let p = h.segs.first()?.point_at(0.5);
|
|
let r = out.regions.iter().position(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?;
|
|
out.regions.get_mut(r)?.holes.push(h);
|
|
out.tags.get_mut(r)?.push(tags);
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// The boolean done on prism sections, or `None` when it doesn't apply.
|
|
/// The pieces of a segment between the given points (those lying on it, inside it).
|
|
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
|
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
|
|
return None;
|
|
}
|
|
let mut ts: Vec<f64> = pts
|
|
.iter()
|
|
.filter_map(|p| {
|
|
let t = seg.closest_param(*p);
|
|
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
|
})
|
|
.collect();
|
|
ts.sort_by(f64::total_cmp);
|
|
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
|
let mut out = Vec::new();
|
|
let mut t0 = 0.0;
|
|
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
|
|
out.push(match *seg {
|
|
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
|
|
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
|
|
_ => return None,
|
|
});
|
|
t0 = t1;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
|
|
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
|
|
/// section's outline; each level has a face up over the section below less the one above, and
|
|
/// a face down over the one above less the one below. Every outline is split where any other
|
|
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
|
|
/// line or an arc.
|
|
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64, history: &lineage::Context) -> Option<Result<Body>> {
|
|
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
|
|
levels.sort_by(f64::total_cmp);
|
|
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
|
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
|
|
let mut slabs: Vec<Section> = Vec::new();
|
|
for w in levels.windows(2) {
|
|
let (z0, z1) = (*w.first()?, *w.get(1)?);
|
|
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
|
|
(true, true) => boolean2d_tagged(&pa.section, &pb.section, BoolOp::Union, tol)?,
|
|
(true, false) => pa.section.clone(),
|
|
(false, true) => pb.section.clone(),
|
|
(false, false) => return None,
|
|
};
|
|
slabs.push(s.oriented());
|
|
}
|
|
sew_sections(plane, d, &levels, &slabs, tol, false, history)
|
|
}
|
|
|
|
/// Sew the exact sections between successive levels, including empty gaps.
|
|
fn sew_sections(
|
|
plane: &Plane,
|
|
d: Vec3,
|
|
levels: &[f64],
|
|
slabs: &[Section],
|
|
tol: f64,
|
|
coalesce: bool,
|
|
history: &lineage::Context,
|
|
) -> Option<Result<Body>> {
|
|
if levels.len() != slabs.len() + 1
|
|
|| slabs.len() > 128
|
|
|| plane.normal().dot(d) < 1. - 1e-12
|
|
|| levels.iter().any(|z| !z.is_finite())
|
|
|| levels.windows(2).any(|z| z[0] >= z[1])
|
|
{
|
|
return None;
|
|
}
|
|
let mut blocks = Vec::new();
|
|
let mut start = None;
|
|
for (i, slab) in slabs.iter().enumerate() {
|
|
if slab.regions.is_empty() {
|
|
if let Some(first) = start.take() {
|
|
blocks.push((first, i));
|
|
}
|
|
} else if start.is_none() {
|
|
start = Some(i);
|
|
}
|
|
}
|
|
if let Some(first) = start {
|
|
blocks.push((first, slabs.len()));
|
|
}
|
|
if blocks.is_empty() {
|
|
return None;
|
|
}
|
|
Some((|| {
|
|
let mut pieces = Vec::new();
|
|
let mut remaining_constructed_faces = MAX_PLANAR_SEGMENTS * 2;
|
|
let mut remaining_faces = MAX_PLANAR_SEGMENTS * 2;
|
|
for (first, end) in blocks {
|
|
let tagged = slabs.get(first).ok_or_else(|| KernelError::Failed("missing prism section".into()))?;
|
|
let section = &tagged.regions;
|
|
if slabs[first..end].iter().all(|s| s.regions == *section) {
|
|
let edge_count = section.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| &l.segs).fold(0usize, |n, s| {
|
|
n.saturating_add(match s {
|
|
Seg2::Arc { sweep, .. } if sweep.abs() > 1.5 * std::f64::consts::PI => 2,
|
|
_ => 1,
|
|
})
|
|
});
|
|
remaining_constructed_faces = remaining_constructed_faces
|
|
.checked_sub(edge_count.saturating_add(section.len().saturating_mul(2)))
|
|
.ok_or_else(|| KernelError::Failed("prism construction face budget".into()))?;
|
|
// This is a NEW exact extrusion of the current Boolean section,
|
|
// not copied lineage from either operand's old footprint.
|
|
for (r, piece) in crate::extrude(plane, section, levels[first], levels[end])?.into_iter().enumerate() {
|
|
remaining_faces =
|
|
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
|
|
pieces.push((piece, lineage::Built::Extruded { section: tagged.region(r), lo: levels[first], hi: levels[end] }));
|
|
}
|
|
} else {
|
|
let (piece, specs, faces) = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce)
|
|
.ok_or_else(|| KernelError::Failed("stepped prism construction failed".into()))??;
|
|
remaining_faces =
|
|
remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
|
|
pieces.push((piece, lineage::Built::Sewn { specs, faces }));
|
|
}
|
|
}
|
|
let mut old = std::collections::HashMap::new();
|
|
let mut shells = Vec::new();
|
|
let mut remaining_map_faces = MAX_PLANAR_SEGMENTS * 2;
|
|
for (piece, _) in &pieces {
|
|
for (index, face) in piece.solid.face_iter().enumerate() {
|
|
remaining_map_faces = remaining_map_faces.checked_sub(1).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?;
|
|
let source = piece.face_sources().and_then(|s| s.get(index)).and_then(Option::as_ref);
|
|
if old.insert(face.id(), Some((face.orientation(), source))).is_some() {
|
|
old.insert(face.id(), None);
|
|
}
|
|
}
|
|
shells.extend(piece.solid.boundaries().iter().cloned());
|
|
}
|
|
let body = Body::new(crate::body::Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("prism compound: {e}")))?)?;
|
|
let sources = body
|
|
.solid
|
|
.face_iter()
|
|
.map(|face| match old.get(&face.id()) {
|
|
Some(Some((orientation, source))) if *orientation == face.orientation() => source.cloned(),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let body = body.with_face_sources(Some(std::sync::Arc::new(sources)));
|
|
Ok(history.attach(body, &pieces))
|
|
})())
|
|
}
|
|
|
|
/// Stable exact identity for the bounded analytic segment subset.
|
|
fn wall_key(s: &Seg2) -> Option<[u64; 6]> {
|
|
let bits = |v: f64| v.is_finite().then(|| if v == 0. { 0 } else { v.to_bits() });
|
|
match s {
|
|
Seg2::Line { a, b } => Some([0, bits(a.x)?, bits(a.y)?, bits(b.x)?, bits(b.y)?, 0]),
|
|
Seg2::Arc { center, radius, start, sweep } => Some([1, bits(center.x)?, bits(center.y)?, bits(*radius)?, bits(*start)?, bits(*sweep)?]),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A genuinely stepped connected axial block has no single extrusion footprint.
|
|
#[allow(clippy::type_complexity)]
|
|
fn sew_connected_sections(
|
|
plane: &Plane,
|
|
d: Vec3,
|
|
levels: &[f64],
|
|
slabs: &[Section],
|
|
tol: f64,
|
|
coalesce: bool,
|
|
) -> Option<Result<(Body, Vec<lineage::Spec>, Option<Vec<usize>>)>> {
|
|
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
|
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
|
let diff = |x: &Section, y: &Section| -> Option<Section> {
|
|
match (x.regions.is_empty(), y.regions.is_empty()) {
|
|
(true, _) => Some(Section::default()),
|
|
(false, true) => Some(x.clone()),
|
|
(false, false) => boolean2d_tagged(x, y, BoolOp::Cut, tol).map(|r| r.oriented()),
|
|
}
|
|
};
|
|
let empty = Section::default();
|
|
let mut caps: Vec<(f64, bool, Section)> = Vec::new();
|
|
for (k, z) in levels.iter().enumerate() {
|
|
let below: &Section = if k == 0 { &empty } else { slabs.get(k - 1)? };
|
|
let above: &Section = slabs.get(k).unwrap_or(&empty);
|
|
caps.push((*z, true, diff(below, above)?));
|
|
caps.push((*z, false, diff(above, below)?));
|
|
}
|
|
// Every segment end, everywhere: outlines split there.
|
|
let loops_of =
|
|
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
|
|
let mut pts: Vec<Vec2> = Vec::new();
|
|
for lp in slabs.iter().flat_map(|s| loops_of(&s.regions)).chain(caps.iter().flat_map(|c| loops_of(&c.2.regions))) {
|
|
for sg in &lp.segs {
|
|
pts.push(sg.start());
|
|
pts.push(sg.end());
|
|
}
|
|
}
|
|
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
|
|
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
|
|
match sg {
|
|
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
|
|
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
|
|
}
|
|
};
|
|
// Exact oriented generated segments alone define a run. No support fitting,
|
|
// proximity matching, or old face lineage is used here.
|
|
let mut runs: Vec<(usize, usize, Seg2)> = Vec::new();
|
|
let mut last = std::collections::BTreeMap::new();
|
|
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
|
|
// What each face is over, for the result's history. Coalesced walls span several slabs
|
|
// and are not described: such a piece carries no history.
|
|
let mut specs: Vec<lineage::Spec> = Vec::new();
|
|
for (i, slab) in slabs.iter().enumerate() {
|
|
let z0 = *levels.get(i)?;
|
|
for (r, reg) in slab.regions.iter().enumerate() {
|
|
for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
|
let first = specs.len();
|
|
for (si, sg0) in lp.segs.iter().enumerate() {
|
|
let pieces = split_seg(sg0, &pts, tol)?;
|
|
let (n, tag) = (pieces.len(), slab.tag(r, l, si));
|
|
for (k, sg) in pieces.into_iter().enumerate() {
|
|
remaining = remaining.checked_sub(1)?;
|
|
let key = wall_key(&sg)?;
|
|
if coalesce {
|
|
if let Some(&index) = last.get(&key) {
|
|
let run: &mut (usize, usize, Seg2) = runs.get_mut(index)?;
|
|
if run.1 == i {
|
|
run.1 = i + 1;
|
|
continue;
|
|
}
|
|
// Duplicate oriented boundaries in one slab are not
|
|
// a valid reason to reconstruct a larger wall.
|
|
if run.1 == i + 1 {
|
|
return None;
|
|
}
|
|
}
|
|
last.insert(key, runs.len());
|
|
} else {
|
|
specs.push(lineage::Spec::Wall { tag: tag.piece(k, n), z0, next: specs.len() + 1, at: sg.end() });
|
|
}
|
|
runs.push((i, i + 1, sg));
|
|
}
|
|
}
|
|
// The loop's last wall runs into its first.
|
|
if !coalesce && let Some(lineage::Spec::Wall { next, .. }) = specs.last_mut() {
|
|
*next = first;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut faces: Vec<FaceSpec> = Vec::new();
|
|
let mut sources = Vec::new();
|
|
let frame_is_orthonormal = plane.origin.is_finite()
|
|
&& plane.x.is_finite()
|
|
&& plane.y.is_finite()
|
|
&& d.is_finite()
|
|
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
|
|
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
|
|
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
|
|
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
|
|
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
|
|
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
|
|
for (first, end, sg) in runs {
|
|
let (z0, z1) = (levels[first], levels[end]);
|
|
let (a, b) = (sg.start(), sg.end());
|
|
let mut lp3 = Vec::with_capacity(2 + 2 * (end - first));
|
|
lp3.push(edge(&sg, z0));
|
|
// Keep station vertices at both ends: neighboring stepped walls/caps
|
|
// must share complete edges rather than create T-junctions.
|
|
for k in first..end {
|
|
lp3.push(EdgeSpec::Line { a: at(b, levels[k]), b: at(b, levels[k + 1]) });
|
|
}
|
|
lp3.push(edge(&sg.reversed(), z1));
|
|
for k in (first..end).rev() {
|
|
lp3.push(EdgeSpec::Line { a: at(a, levels[k + 1]), b: at(a, levels[k]) });
|
|
}
|
|
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
|
|
let surface = match sg {
|
|
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
|
_ => SurfSpec::Plane,
|
|
};
|
|
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
|
// Axis-aligned generated lines have a common oriented support frame
|
|
// independent of axial stations or along-line endpoint interpolation.
|
|
let generated_plane = if coalesce
|
|
&& frame_is_orthonormal
|
|
&& let Seg2::Line { a, b } = sg
|
|
{
|
|
let (origin, x) = if a.y == b.y && a.x != b.x {
|
|
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
|
|
} else if a.x == b.x && a.y != b.y {
|
|
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
|
|
} else {
|
|
(Vec3::ZERO, Vec3::ZERO)
|
|
};
|
|
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
|
|
} else {
|
|
None
|
|
};
|
|
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
|
|
segment: sg,
|
|
plane: generated_plane,
|
|
coalesced: end > first + 1,
|
|
}));
|
|
}
|
|
for (z, up, rs) in &caps {
|
|
for (ri, r) in rs.regions.iter().enumerate() {
|
|
let mut loops = Vec::new();
|
|
for lp in std::iter::once(&r.outer).chain(&r.holes) {
|
|
let mut segs: Vec<Seg2> = Vec::new();
|
|
for sg in &lp.segs {
|
|
segs.extend(split_seg(sg, &pts, tol)?);
|
|
}
|
|
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
|
|
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
|
}
|
|
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
|
sources.push(None);
|
|
specs.push(lineage::Spec::Cap { section: rs.region(ri), z: *z, up: *up });
|
|
}
|
|
}
|
|
let sewn = crate::sew::sew_touching_with_sources(&faces, &sources);
|
|
Some(if coalesce {
|
|
sewn.and_then(|(body, _)| Ok((body.regularize_generated_planar_faces()?, specs, None)))
|
|
} else {
|
|
sewn.map(|(body, from)| (body, specs, from))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
|
simple_prism_indexed(a, b, op, [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)])
|
|
}
|
|
|
|
/// [`prism_boolean`] of single prisms, the operands' indexes given.
|
|
fn simple_prism_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2]) -> Option<Result<Option<Body>>> {
|
|
let size = a.size().max(b.size());
|
|
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
|
// Candidate directions: a's planar face normals (each line once).
|
|
let mut dirs: Vec<Vec3> = Vec::new();
|
|
let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2;
|
|
for f in a.solid.face_iter() {
|
|
remaining_direction_faces = remaining_direction_faces.checked_sub(1)?;
|
|
if let mt::Surface::Plane(p) = f.oriented_surface() {
|
|
let n = p.normal();
|
|
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized()
|
|
&& !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9)
|
|
{
|
|
dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n });
|
|
if dirs.len() == 8 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
|
|
let plane = Plane::from_normal(Vec3::ZERO, d)?;
|
|
let pa = prism_tagged(a, d, &plane, tol, ia)?;
|
|
let pb = prism_tagged(b, d, &plane, tol, ib)?;
|
|
Some((d, plane, pa, pb))
|
|
})?;
|
|
let history = lineage::Context { op, operands: [a, b], index: [ia.get(), ib.get()], plane, tol, heights: Some([(pa.lo, pa.hi), (pb.lo, pb.hi)]) };
|
|
let (blo, bhi) = heights_of(b, &plane);
|
|
let spans = blo <= pa.lo + tol && bhi >= pa.hi - tol;
|
|
let same = (pb.lo - pa.lo).abs() < tol && (pb.hi - pa.hi).abs() < tol;
|
|
let area =
|
|
|rs: &[Region2]| rs.iter().map(|r| r.outer.signed_area().abs() - r.holes.iter().map(|h| h.signed_area().abs()).sum::<f64>()).sum::<f64>();
|
|
// Prisms of one section overlapping or touching end to end: one longer prism.
|
|
let stacked = op == BoolOp::Union && !same && pb.lo <= pa.hi + tol && pa.lo <= pb.hi + tol;
|
|
let ok = match op {
|
|
BoolOp::Cut | BoolOp::Intersect => spans,
|
|
BoolOp::Union => same || stacked,
|
|
};
|
|
if !ok {
|
|
return None;
|
|
}
|
|
let section = boolean2d_tagged(&pa.section, &pb.section, op, tol)?;
|
|
let regions = §ion.regions;
|
|
if regions.is_empty() {
|
|
return Some(Ok(None));
|
|
}
|
|
let (lo, hi) = if stacked {
|
|
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
|
|
let (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions));
|
|
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
|
|
return sew_stack(&plane, d, &pa, &pb, tol, &history).map(|r| r.map(Some));
|
|
}
|
|
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
|
|
} else {
|
|
(pa.lo, pa.hi)
|
|
};
|
|
let base = plane.offset(lo);
|
|
let bodies = match crate::extrude(&base, regions, 0.0, hi - lo) {
|
|
Ok(b) => b,
|
|
Err(e) => return Some(Err(e)),
|
|
};
|
|
// Each body over its region.
|
|
let mut pieces: Vec<(Body, lineage::Built)> =
|
|
bodies.into_iter().enumerate().map(|(r, b)| (b, lineage::Built::Extruded { section: section.region(r), lo, hi })).collect();
|
|
let last = pieces.pop()?;
|
|
if !pieces.is_empty() {
|
|
// Several pieces: one body of several lumps (the last first).
|
|
pieces.push(last);
|
|
pieces.rotate_right(1);
|
|
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|(b, _)| b.solid.boundaries().iter().cloned()).collect();
|
|
return Some(
|
|
crate::body::Solid::try_new(shells)
|
|
.map_err(|e| crate::KernelError::Failed(e.to_string()))
|
|
.and_then(Body::new)
|
|
.map(|body| Some(history.attach(body, &pieces))),
|
|
);
|
|
}
|
|
Some(Ok(Some(history.attach(last.0.clone(), &[last]))))
|
|
}
|
|
|
|
/// A stepped extrusion (case floor plus walls), recovered from its cap events.
|
|
/// Downward caps add a section above their level; upward caps remove one.
|
|
#[cfg(test)]
|
|
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
|
|
stack_tagged(b, d, plane, tol, &LazyIndex::new(b, 0))
|
|
}
|
|
|
|
/// [`stack_of`], its sections' segments tagged from `index` (the body's).
|
|
fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) -> Option<Vec<Prism>> {
|
|
if let Some(p) = prism_tagged(b, d, plane, tol, index) {
|
|
return Some(vec![p]);
|
|
}
|
|
if b.face_count() > 2048 {
|
|
return None;
|
|
}
|
|
let mut events = Vec::new();
|
|
let mut walls = std::collections::HashMap::new();
|
|
let mut wall_work = 262_144;
|
|
let mut curve_work = 2_000_000;
|
|
let mut segments = usize::MAX;
|
|
for (fi, f) in b.solid.face_iter().enumerate() {
|
|
let surf = f.oriented_surface();
|
|
if let mt::Surface::Plane(p) = &surf {
|
|
let n = p.normal();
|
|
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
|
if n.dot(d).abs() > 1.0 - 1e-9 {
|
|
let section = Section::from_loops(index.get().loops(f, fi, plane, tol, &mut curve_work, &mut segments)?)?;
|
|
events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, section.oriented()));
|
|
continue;
|
|
}
|
|
if n.dot(d).abs() > 1e-9 {
|
|
return None;
|
|
}
|
|
} else if !wall_along(&surf, d, tol, &mut wall_work) {
|
|
return None;
|
|
}
|
|
walls.insert(fi, top_of(f, plane));
|
|
}
|
|
let mut levels: Vec<f64> = events.iter().map(|(z, _, _)| *z).collect();
|
|
levels.sort_by(f64::total_cmp);
|
|
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
|
if levels.len() < 2 || levels.len() > 64 || levels.iter().any(|z| !z.is_finite()) {
|
|
return None;
|
|
}
|
|
let stations = Stations::new(index.get(), walls, d, plane, tol);
|
|
let mut current = Section::default();
|
|
let mut stack: Vec<Prism> = Vec::new();
|
|
let mut budget = MAX_PLANAR_PAIRS;
|
|
for (i, z) in levels.iter().enumerate() {
|
|
// The caps here end the slab below: its walls meet them.
|
|
if let Some(below) = stack.last_mut() {
|
|
let caps: Vec<&Section> = events.iter().filter(|(h, _, _)| (*h - *z).abs() < tol).map(|(_, _, s)| s).collect();
|
|
below.section.mark_far(&caps, tol, &mut budget);
|
|
}
|
|
// The segments carried here run on past it, beside the walls there.
|
|
current.retag(|t| match t.wall {
|
|
Some(w) => t.beside(stations.past(w, *z, tol)),
|
|
None => t,
|
|
});
|
|
// Remove terminating material before adding any material starting here.
|
|
for up in [true, false] {
|
|
for (_, _, section) in events.iter().filter(|(h, u, _)| (*h - *z).abs() < tol && *u == up) {
|
|
current = match (current.regions.is_empty(), up) {
|
|
(true, false) => section.clone(),
|
|
(true, true) => return None,
|
|
(false, _) => boolean2d_tagged(¤t, section, if up { BoolOp::Cut } else { BoolOp::Union }, tol)?,
|
|
};
|
|
}
|
|
}
|
|
if let Some(next) = levels.get(i + 1)
|
|
&& !current.regions.is_empty()
|
|
{
|
|
current.drop_crossed(&stations, *z, *next, tol);
|
|
stack.push(Prism { lo: *z, hi: *next, section: current.oriented() });
|
|
}
|
|
}
|
|
(current.regions.is_empty() && !stack.is_empty()).then_some(stack)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
|
stack_indexed(a, b, op, [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)])
|
|
}
|
|
|
|
/// [`prism_boolean`] of stepped prisms, the operands' indexes given.
|
|
fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2]) -> Option<Result<Option<Body>>> {
|
|
// Common-cap joins and overlapping partial-depth cuts of aligned stepped prisms can
|
|
// retain their exact section boundaries instead of intersecting meshes.
|
|
// Nonaligned bodies retain their existing B-rep path.
|
|
if !matches!(op, BoolOp::Union | BoolOp::Cut) {
|
|
return None;
|
|
}
|
|
if op == BoolOp::Cut && a.face_count().saturating_add(b.face_count()) > 2048 {
|
|
return None;
|
|
}
|
|
fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> Section {
|
|
ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.section.clone()).unwrap_or_default()
|
|
}
|
|
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
|
|
let mut dirs: Vec<Vec3> = Vec::new();
|
|
for f in a.solid.face_iter() {
|
|
if let mt::Surface::Plane(p) = f.oriented_surface() {
|
|
let n = p.normal();
|
|
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized()
|
|
&& !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9)
|
|
{
|
|
dirs.push(n);
|
|
}
|
|
}
|
|
}
|
|
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
|
|
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
|
|
let plane = Plane::from_normal(Vec3::ZERO, d)?;
|
|
let pa = stack_tagged(a, d, &plane, tol, ia)?;
|
|
let pb = stack_tagged(b, d, &plane, tol, ib)?;
|
|
let (ea, eb) = (ends(&pa), ends(&pb));
|
|
match op {
|
|
BoolOp::Union if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol => return None,
|
|
// A cut tool may extend past a cap into empty space. Exact slab sections
|
|
// discard that space rather than intersecting coincident cylinder walls.
|
|
BoolOp::Cut if ea.1 <= eb.0 + tol || eb.1 <= ea.0 + tol => return None,
|
|
_ => {}
|
|
}
|
|
Some((d, plane, pa, pb))
|
|
})?;
|
|
let mut levels: Vec<f64> = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect();
|
|
levels.sort_by(f64::total_cmp);
|
|
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
|
let mut slabs = Vec::new();
|
|
let mut work = 0usize;
|
|
let mut output_edges = 0usize;
|
|
let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::<usize>();
|
|
for w in levels.windows(2) {
|
|
let (lo, hi) = (*w.first()?, *w.get(1)?);
|
|
let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol));
|
|
if op == BoolOp::Cut {
|
|
let count = edges(&x.regions).saturating_add(edges(&y.regions));
|
|
work = work.saturating_add(count.saturating_mul(count));
|
|
if work > 32_000_000 {
|
|
return None;
|
|
}
|
|
}
|
|
let s = match (x.regions.is_empty(), y.regions.is_empty(), op) {
|
|
(true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Section::default(),
|
|
(true, _, BoolOp::Union) => y,
|
|
(_, true, _) => x,
|
|
_ => boolean2d_tagged(&x, &y, op, tol)?,
|
|
};
|
|
if op == BoolOp::Cut {
|
|
output_edges = output_edges.saturating_add(edges(&s.regions));
|
|
if output_edges > 8192 {
|
|
return None;
|
|
}
|
|
}
|
|
slabs.push(s.oriented());
|
|
}
|
|
if slabs.iter().all(|s| s.regions.is_empty()) {
|
|
return Some(Ok(None));
|
|
}
|
|
let history = lineage::Context { op, operands: [a, b], index: [ia.get(), ib.get()], plane, tol, heights: None };
|
|
let coalesce = op == BoolOp::Cut
|
|
&& !a.regularization_protected
|
|
&& !b.regularization_protected
|
|
&& a.split_keep().is_empty()
|
|
&& b.split_keep().is_empty()
|
|
&& a.paint().is_none_or(|p| p.faces.is_empty())
|
|
&& b.paint().is_none_or(|p| p.faces.is_empty());
|
|
sew_sections(&plane, d, &levels, &slabs, tol, coalesce, &history).map(|r| r.map(Some))
|
|
}
|
|
|
|
/// Boolean of prisms along a common direction, in 2D (see the module notes); `None` when it
|
|
/// doesn't apply. The result carries its history over `[a, b]` (see [`lineage`]).
|
|
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
|
// Each operand's index is built once, and only if it is read as a prism.
|
|
let index = [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)];
|
|
crate::guard("prism boolean", || Ok(simple_prism_indexed(a, b, op, index).or_else(|| stack_indexed(a, b, op, index)))).ok().flatten()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod planar_tests {
|
|
use super::*;
|
|
fn rectangle(a: Vec2, b: Vec2) -> Region2 {
|
|
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
|
|
}
|
|
#[test]
|
|
fn simple_prism_retains_large_analytic_section_budget() {
|
|
let count = 2050;
|
|
let radius = 1000.;
|
|
let points: Vec<_> = (0..count)
|
|
.map(|i| {
|
|
let angle = std::f64::consts::TAU * i as f64 / count as f64;
|
|
Vec2::new(radius * angle.cos(), radius * angle.sin())
|
|
})
|
|
.collect();
|
|
let region = Region2 { outer: Loop2::polygon(&points), holes: vec![] };
|
|
let stock = crate::extrude(&Plane::XY, &[region], 0., 2.).unwrap().pop().unwrap();
|
|
assert_eq!(stock.face_count(), count + 2);
|
|
assert!(stock.face_count() > 2048 && stock.validity().is_empty());
|
|
let before: Vec<_> = stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect();
|
|
let recovered = prism_of(&stock, Vec3::Z, &Plane::XY, 1e-5).expect("large simple line prism remains supported");
|
|
assert_eq!(recovered.section.regions.len(), 1);
|
|
assert_eq!(recovered.section.regions[0].outer.segs.len(), count);
|
|
assert!(recovered.section.regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
|
|
let area = count as f64 * radius * radius * (std::f64::consts::TAU / count as f64).sin() / 2.;
|
|
assert!((recovered.section.regions[0].area() - area).abs() < area * 1e-12);
|
|
assert!((recovered.lo - 0.).abs() < 1e-8 && (recovered.hi - 2.).abs() < 1e-8);
|
|
let cutter = crate::box_solid(Vec3::new(-1001., -1001., -1.), Vec3::new(1001., 1001., 3.)).unwrap();
|
|
assert!(
|
|
simple_prism_boolean(&stock, &cutter, BoolOp::Cut)
|
|
.expect("large analytic section routes through the exact simple path")
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
assert_eq!(stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect::<Vec<_>>(), before);
|
|
}
|
|
#[test]
|
|
fn curved_stop_cut_constructs_fresh_current_cap_for_only_uniform_lump() {
|
|
let cylinder = crate::cylinder(Vec3::new(25., 25., 0.), Vec3::Z, 2., 20.).unwrap();
|
|
let stop = crate::box_solid(Vec3::new(19., 19., 1.), Vec3::new(31., 31., 2.)).unwrap();
|
|
let roof = crate::boolean(&cylinder, &stop, BoolOp::Union).unwrap().unwrap();
|
|
let original = rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.));
|
|
let tool = crate::extrude(&Plane::XY, &[original], 0., 25.).unwrap().pop().unwrap();
|
|
let before_tool = crate::step_export(&[&tool], "tool").unwrap();
|
|
let before_roof = crate::step_export(&[&roof], "roof").unwrap();
|
|
let trimmed = crate::boolean(&tool, &roof, BoolOp::Cut).unwrap().unwrap();
|
|
let lumps = trimmed.lumps().unwrap();
|
|
assert_eq!(lumps.len(), 2);
|
|
let mut lower = 0;
|
|
let mut upper = 0;
|
|
for lump in &lumps {
|
|
assert!(lump.validity().is_empty());
|
|
let mesh = lump.tessellate(1e-5).unwrap();
|
|
if mesh.contains(Vec3::new(21., 21., 0.5)) {
|
|
lower += 1;
|
|
assert!(!mesh.contains(Vec3::new(25., 25., 0.5)));
|
|
assert!(!mesh.contains(Vec3::new(21., 21., 1.5)));
|
|
assert!((mesh.measure().volume - (100. - 4. * std::f64::consts::PI)).abs() < 0.002);
|
|
let sources = lump.face_sources().expect("fresh actual extrusion has complete sources");
|
|
assert_eq!(sources.len(), lump.face_count());
|
|
assert!(sources.iter().all(Option::is_some));
|
|
let mut caps = 0;
|
|
for source in sources.iter().flatten() {
|
|
match source {
|
|
crate::FaceSource::ExtrudeCap { region, plane, .. } => {
|
|
assert_eq!(region.holes.len(), 1);
|
|
assert!((region.area() - (100. - 4. * std::f64::consts::PI)).abs() < 1e-8);
|
|
assert!(plane.normal().z.abs() > 1. - 1e-10);
|
|
assert!((plane.origin.z - if plane.normal().z > 0. { 1. } else { 0. }).abs() < 1e-8);
|
|
caps += 1;
|
|
}
|
|
crate::FaceSource::ExtrudeSide { .. } => {}
|
|
_ => panic!("old producer lineage must not survive a changed Boolean footprint"),
|
|
}
|
|
}
|
|
assert_eq!(caps, 2);
|
|
} else {
|
|
upper += 1;
|
|
assert!(mesh.contains(Vec3::new(21., 21., 3.)));
|
|
assert!(!mesh.contains(Vec3::new(25., 25., 3.)));
|
|
assert!(mesh.contains(Vec3::new(25., 25., 22.)));
|
|
assert!(
|
|
lump.face_sources().is_none_or(|s| s
|
|
.iter()
|
|
.all(|s| s.is_none() || matches!(s, Some(crate::FaceSource::ExtrudeSide { coalesced: true, plane: None, .. })))),
|
|
"genuinely stepped geometry has no durable certificate"
|
|
);
|
|
assert!((mesh.measure().volume - (18. * (100. - 4. * std::f64::consts::PI) + 500.)).abs() < 0.02);
|
|
}
|
|
}
|
|
assert_eq!((lower, upper), (1, 1));
|
|
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
|
|
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
|
|
}
|
|
fn stepped_tab_stock() -> Body {
|
|
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
|
|
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
|
|
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
|
|
}
|
|
|
|
fn coplanar_joins(body: &Body) -> usize {
|
|
let faces: Vec<_> = body.solid.face_iter().collect();
|
|
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
|
|
for (i, face) in faces.iter().enumerate() {
|
|
for edge in face.edge_iter() {
|
|
adjacency.entry(edge.id()).or_default().push(i);
|
|
}
|
|
}
|
|
adjacency
|
|
.values()
|
|
.filter(|faces| {
|
|
let [a, b] = faces.as_slice() else { return false };
|
|
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
|
|
let (na, nb) = (pa.normal(), pb.normal());
|
|
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
|
|
})
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
|
|
let stock = stepped_tab_stock();
|
|
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
|
|
assert_eq!(coplanar_joins(&stock), 0);
|
|
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
|
|
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
|
|
assert!(shell.validity().is_empty());
|
|
let mesh = shell.tessellate(1e-5).unwrap();
|
|
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
|
|
assert!((mesh.measure().volume - expected).abs() < 1e-6);
|
|
for (point, material) in [
|
|
(Vec3::new(0., 0., 0.5), true),
|
|
(Vec3::new(0., 0., 10.), false),
|
|
(Vec3::new(0., -14.5, 10.), true),
|
|
(Vec3::new(19.5, 0., 10.), true),
|
|
(Vec3::new(-8., -24.5, 12.), true),
|
|
(Vec3::new(-8., -20., 12.), false),
|
|
(Vec3::new(0., -18., 9.), false),
|
|
] {
|
|
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
|
|
}
|
|
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
|
|
let reopened = crate::step_import(&step).unwrap();
|
|
let body = &reopened.bodies[0].body;
|
|
assert_eq!(body.face_count(), shell.face_count());
|
|
assert_eq!(coplanar_joins(body), 0);
|
|
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
|
|
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
|
|
}
|
|
|
|
#[test]
|
|
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
|
|
for curved in [false, true] {
|
|
let outer = if curved {
|
|
Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] }
|
|
} else {
|
|
rectangle(Vec2::new(-10., -10.), Vec2::new(10., 10.))
|
|
};
|
|
let mut upper = outer.clone();
|
|
upper.holes.push(if curved {
|
|
Loop2::circle(Vec2::ZERO, 5.).reversed()
|
|
} else {
|
|
rectangle(Vec2::new(-5., -5.), Vec2::new(5., 5.)).outer.reversed()
|
|
});
|
|
let slabs = vec![Section::untagged(&[outer]), Section::untagged(&[upper])];
|
|
// Sections nothing is known of: no history, the construction alone is under test.
|
|
let operand = crate::extrude(&Plane::XY, &[rectangle(Vec2::new(-1., -1.), Vec2::new(1., 1.))], 0., 1.).unwrap().pop().unwrap();
|
|
let index = LazyIndex::new(&operand, 0);
|
|
let history = lineage::Context {
|
|
op: BoolOp::Cut,
|
|
operands: [&operand, &operand],
|
|
index: [index.get(), index.get()],
|
|
plane: Plane::XY,
|
|
tol: 1e-7,
|
|
heights: None,
|
|
};
|
|
let old = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, false, &history).unwrap().unwrap();
|
|
let merged = sew_sections(&Plane::XY, Vec3::Z, &[0., 2., 20.], &slabs, 1e-7, true, &history).unwrap().unwrap();
|
|
let count = if curved { 2 } else { 4 };
|
|
assert_eq!(old.face_count() - merged.face_count(), count);
|
|
let marked: Vec<_> = merged.solid.face_iter().enumerate().filter(|(i, _)| merged.face_was_coalesced(*i)).collect();
|
|
assert_eq!(marked.len(), count);
|
|
for (_, face) in marked {
|
|
assert_eq!(face.boundaries()[0].len(), 6, "all axial station vertices are retained");
|
|
let heights: Vec<_> = face.vertex_iter().map(|v| v.point().z).collect();
|
|
for z in [0., 2., 20.] {
|
|
assert!(heights.contains(&z));
|
|
}
|
|
}
|
|
assert!(old.face_sources().is_none_or(|s| s.iter().all(Option::is_none)));
|
|
let expected = if curved { std::f64::consts::PI * (100. * 20. - 25. * 18.) } else { 400. * 20. - 100. * 18. };
|
|
let mesh = merged.tessellate(1e-5).unwrap();
|
|
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
|
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
|
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
|
assert!(merged.validity().is_empty());
|
|
assert_ne!(wall_key(&slabs[0].regions[0].outer.segs[0]), wall_key(&slabs[0].regions[0].outer.segs[0].reversed()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cut_run_coalescing_respects_authored_splits_and_face_paint() {
|
|
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.), holes: vec![] };
|
|
let tool_region = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.), holes: vec![] };
|
|
let stock = crate::extrude(&Plane::XY, &[region], 0., 20.).unwrap().pop().unwrap();
|
|
let cutter = crate::extrude(&Plane::XY, &[tool_region], 2., 22.).unwrap().pop().unwrap();
|
|
let merged = stack_boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
|
let protected = [
|
|
stock.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(10., 0., 0.), Vec3::new(10., 0., 20.)]]))),
|
|
stock.with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] })),
|
|
];
|
|
for input in protected {
|
|
let output = stack_boolean(&input, &cutter, BoolOp::Cut).unwrap().unwrap().unwrap();
|
|
assert_eq!(output.face_count(), merged.face_count() + 2);
|
|
assert!((0..output.face_count()).all(|i| !output.face_was_coalesced(i)));
|
|
// Independent circular chord-volume bound: 2*pi*(10*20+5*18)*1e-5 < .019.
|
|
let expected = std::f64::consts::PI * (100. * 20. - 25. * 18.);
|
|
for body in [&output, &merged] {
|
|
let mesh = body.tessellate(1e-5).unwrap();
|
|
assert!((mesh.measure().volume - expected).abs() < 0.03);
|
|
assert!(mesh.contains(Vec3::new(0., 0., 1.)));
|
|
assert!(!mesh.contains(Vec3::new(0., 0., 10.)));
|
|
assert!(mesh.contains(Vec3::new(7., 0., 10.)));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn annular_inward_tool_recovers_exact_stacks_before_cap_policy() {
|
|
let plane = Plane::XY;
|
|
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
|
|
region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0));
|
|
let mut stock = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 20.0).unwrap().pop().unwrap();
|
|
for (lo, hi) in [(20.0, 30.0), (30.0, 35.0)] {
|
|
let positive = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap();
|
|
stock = crate::boolean(&stock, &positive, BoolOp::Union).unwrap().unwrap();
|
|
}
|
|
let mut grown = rectangle(Vec2::new(-0.3, -0.3), Vec2::new(40.3, 30.3));
|
|
grown.holes = region.holes;
|
|
let tool = crate::extrude(&plane, &[grown], 30.0, 35.6).unwrap().pop().unwrap();
|
|
let tol = stock.size().max(tool.size()) * 1e-6;
|
|
let a = stack_of(&stock, Vec3::Z, &plane, tol).expect("annular stock qualifies");
|
|
let b = stack_of(&tool, Vec3::Z, &plane, tol).expect("overrun tool qualifies");
|
|
assert_eq!(a.len(), 1);
|
|
assert_eq!(b.len(), 1);
|
|
assert!((a[0].lo - 0.0).abs() < tol && (a[0].hi - 35.0).abs() < tol);
|
|
assert!((b[0].lo - 30.0).abs() < tol && (b[0].hi - 35.6).abs() < tol);
|
|
assert_eq!(a[0].section.regions[0].holes.len(), 1);
|
|
assert_eq!(b[0].section.regions[0].holes.len(), 1);
|
|
assert!((a[0].section.regions[0].area() - (1200.0 - 25.0 * std::f64::consts::PI)).abs() < 1e-8);
|
|
assert!(simple_prism_boolean(&stock, &tool, BoolOp::Cut).is_none());
|
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("qualified overlapping stacks").unwrap().unwrap();
|
|
assert_eq!(result.shell_count(), 1);
|
|
assert!(result.validity().is_empty());
|
|
let mesh = result.tessellate(1e-5).unwrap();
|
|
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0;
|
|
// An inscribed circular mesh at radial chord error 1e-5 has volume
|
|
// deficit below 2*pi*R*height*error, conservatively 0.01 mm^3 here.
|
|
assert!((mesh.measure().volume - expected).abs() < 0.02);
|
|
for (point, present) in [
|
|
(Vec3::new(5.0, 5.0, 29.0), true),
|
|
(Vec3::new(5.0, 5.0, 31.0), false),
|
|
(Vec3::new(20.0, 15.0, 29.0), false),
|
|
(Vec3::new(24.8, 15.0, 29.0), false),
|
|
(Vec3::new(25.2, 15.0, 29.0), true),
|
|
] {
|
|
assert_eq!(mesh.contains(point), present, "{point:?}");
|
|
}
|
|
}
|
|
#[test]
|
|
fn annular_partial_cut_overruns_and_interior_lumps_preserve_material() {
|
|
let area = 1200.0 - 25.0 * std::f64::consts::PI;
|
|
for seam in [0.0, 0.37] {
|
|
for plane in [Plane::XY, Plane::new(Vec3::new(30.0, 20.0, 10.0), Vec3::X, Vec3::Z).unwrap()] {
|
|
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
|
|
region.holes.push(Loop2::circle_from(Vec2::new(20.0, 15.0), 5.0, seam));
|
|
let stock = crate::extrude(&plane, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap();
|
|
let before = crate::step_export(&[&stock], "source").unwrap();
|
|
for (lo, hi, retained, shells) in [(30.0, 35.6, 30.0, 1), (-0.6, 5.0, 30.0, 1), (10.0, 15.0, 30.0, 2), (30.0, 35.0, 30.0, 1)] {
|
|
let tool = crate::extrude(&plane, std::slice::from_ref(®ion), lo, hi).unwrap().pop().unwrap();
|
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).expect("exact aligned cut path").unwrap().unwrap();
|
|
assert_eq!(result.shell_count(), shells);
|
|
assert!(result.validity().is_empty());
|
|
let mesh = result.tessellate(1e-5).unwrap();
|
|
assert!((mesh.measure().volume - retained * area).abs() < 0.02);
|
|
let at = |x: f64, y: f64, z: f64| plane.to_world(Vec2::new(x, y)) + plane.normal() * z;
|
|
for z in [2.5, 12.5, 32.5] {
|
|
let material = !(lo < z && z < hi);
|
|
assert_eq!(mesh.contains(at(5.0, 5.0, z)), material);
|
|
assert!(!mesh.contains(at(20.0, 15.0, z)));
|
|
assert_eq!(mesh.contains(at(25.2, 15.0, z)), material);
|
|
}
|
|
let public = crate::boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap();
|
|
assert!((public.tessellate(1e-5).unwrap().measure().volume - retained * area).abs() < 0.02);
|
|
if lo == 30.0 && hi > 35.0 {
|
|
let imported = crate::step_import(&crate::step_export(&[&result], "inward annular cut").unwrap()).unwrap();
|
|
assert!(imported.warnings.is_empty());
|
|
let saved = &imported.bodies.first().unwrap().body;
|
|
assert!((saved.tessellate(1e-5).unwrap().measure().volume - 30.0 * area).abs() < 0.02);
|
|
let next = crate::extrude(&plane, std::slice::from_ref(®ion), 25.0, 31.0).unwrap().pop().unwrap();
|
|
let shortened = crate::boolean(saved, &next, BoolOp::Cut).unwrap().unwrap();
|
|
let next_volume = shortened.tessellate(1e-5).unwrap().measure().volume;
|
|
assert!(
|
|
(next_volume - 25.0 * area).abs() < 0.02,
|
|
"seam {seam}, plane {plane:?}, next cut volume {next_volume}, expected {}",
|
|
25.0 * area
|
|
);
|
|
assert!(shortened.contains(at(5.0, 5.0, 24.0)));
|
|
assert!(!shortened.contains(at(5.0, 5.0, 26.0)));
|
|
assert!(!shortened.contains(at(20.0, 15.0, 24.0)));
|
|
}
|
|
}
|
|
let clear = crate::extrude(&plane, std::slice::from_ref(®ion), 36.0, 40.0).unwrap().pop().unwrap();
|
|
assert!(stack_boolean(&stock, &clear, BoolOp::Cut).is_none());
|
|
let unchanged = crate::boolean(&stock, &clear, BoolOp::Cut).unwrap().unwrap();
|
|
assert!(std::sync::Arc::ptr_eq(&stock.solid, &unchanged.solid), "disjoint Cut must retain exact source topology");
|
|
let unchanged_mesh = unchanged.tessellate(1e-5).unwrap();
|
|
let source_mesh = stock.tessellate(1e-5).unwrap();
|
|
assert_eq!(unchanged_mesh.measure().volume, source_mesh.measure().volume);
|
|
// tessellate() floors tolerance at body.size()*1e-6. The arc
|
|
// polygon's area error is at most perimeter*radial chord error.
|
|
let bound = std::f64::consts::TAU * 5.0 * 35.0 * (stock.size() * 1e-6).max(1e-5) + 1e-5;
|
|
assert!((unchanged_mesh.measure().volume - 35.0 * area).abs() < bound);
|
|
assert!(!unchanged_mesh.contains(plane.to_world(Vec2::new(20.0, 15.0)) + plane.normal() * 12.5));
|
|
let all = crate::extrude(&plane, std::slice::from_ref(®ion), -1.0, 36.0).unwrap().pop().unwrap();
|
|
assert!(stack_boolean(&stock, &all, BoolOp::Cut).unwrap().unwrap().is_none());
|
|
assert_eq!(crate::step_export(&[&stock], "source").unwrap(), before);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn annular_partial_cut_preserves_unequal_radii_and_conservative_refusals() {
|
|
let mut region = rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0));
|
|
region.holes.push(Loop2::circle(Vec2::new(20.0, 15.0), 5.0));
|
|
let stock = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 35.0).unwrap().pop().unwrap();
|
|
let before = crate::step_export(&[&stock], "unchanged source").unwrap();
|
|
let mut larger_hole = region.clone();
|
|
larger_hole.holes = vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.1)];
|
|
let tool = crate::extrude(&Plane::XY, &[larger_hole], 30.0, 36.0).unwrap().pop().unwrap();
|
|
let result = stack_boolean(&stock, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
|
|
let expected = (1200.0 - 25.0 * std::f64::consts::PI) * 30.0 + std::f64::consts::PI * (5.1_f64.powi(2) - 25.0) * 5.0;
|
|
let mesh = result.tessellate(1e-5).unwrap();
|
|
assert!((mesh.measure().volume - expected).abs() < 0.02);
|
|
assert!(mesh.contains(Vec3::new(25.05, 15.0, 32.0)));
|
|
assert!(!mesh.contains(Vec3::new(25.2, 15.0, 32.0)));
|
|
assert!(!mesh.contains(Vec3::new(20.0, 15.0, 32.0)));
|
|
let interior = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 10.0, 15.0).unwrap().pop().unwrap();
|
|
for d in [Vec3::X, Vec3::Y] {
|
|
let plane = Plane::from_normal(Vec3::ZERO, d).unwrap();
|
|
let tol = stock.size() * 1e-6;
|
|
assert!(stack_of(&stock, d, &plane, tol).is_none(), "transverse cylinder axis must not fill the hole");
|
|
assert!(stack_of(&interior, d, &plane, tol).is_none());
|
|
}
|
|
assert!(stack_boolean(&stock, &interior, BoolOp::Union).is_none(), "Union retains its common-cap policy");
|
|
let tapered = crate::extrude_tapered(&Plane::XY.offset(30.0), &rectangle(Vec2::ZERO, Vec2::new(40.0, 30.0)), 6.0, 1.0, 0.05).unwrap();
|
|
assert!(stack_boolean(&stock, &tapered, BoolOp::Cut).is_none(), "slanted walls are not aligned prisms");
|
|
let curved_cap = crate::sphere(Vec3::new(20.0, 15.0, 35.0), 5.0).unwrap();
|
|
assert!(stack_boolean(&stock, &curved_cap, BoolOp::Cut).is_none());
|
|
let pieces =
|
|
(0..350).map(|i| crate::box_solid(Vec3::new(i as f64 * 3.0, 0.0, 0.0), Vec3::new(i as f64 * 3.0 + 1.0, 1.0, 1.0)).unwrap()).collect();
|
|
let over_budget = crate::ops::join_pieces(pieces).unwrap().unwrap();
|
|
assert!(over_budget.face_count() > 2048);
|
|
assert!(stack_boolean(&over_budget, &interior, BoolOp::Cut).is_none());
|
|
assert_eq!(crate::step_export(&[&stock], "unchanged source").unwrap(), before);
|
|
}
|
|
#[test]
|
|
fn curved_prism_wall_requires_positive_separable_rational_weights() {
|
|
let surface = |mixed: bool| {
|
|
let weight = if mixed { 2.0 } else { 1.0 };
|
|
let points = vec![
|
|
vec![mt::Vector4::new(0.0, 0.0, 0.0, 1.0), mt::Vector4::new(0.0, 0.0, 10.0, 1.0)],
|
|
vec![mt::Vector4::new(5.0, 0.0, 0.0, 1.0), mt::Vector4::new(5.0 * weight, 0.0, 10.0 * weight, weight)],
|
|
];
|
|
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
|
|
(mt::KnotVec::bezier_knot(1), mt::KnotVec::bezier_knot(1)),
|
|
points,
|
|
)))
|
|
};
|
|
assert!(wall_along(&surface(false), Vec3::Z, 1e-5, &mut 1000));
|
|
assert!(!wall_along(&surface(true), Vec3::Z, 1e-5, &mut 1000), "parallel Euclidean controls alone do not certify a rational sweep");
|
|
assert!(!wall_along(&surface(false), Vec3::Y, 1e-5, &mut 1000));
|
|
assert!(!wall_along(&surface(false), Vec3::Z, 1e-5, &mut 0));
|
|
}
|
|
#[test]
|
|
fn partial_depth_cuts_touching_a_through_hole_use_exact_stacked_sections() {
|
|
let plane = Plane::named("XY").unwrap();
|
|
let mut plate = rectangle(Vec2::ZERO, Vec2::new(100., 60.));
|
|
plate.holes.push(rectangle(Vec2::new(25., 20.), Vec2::new(30., 30.)).outer);
|
|
let stock = crate::extrude(&plane, &[plate], 0., 2.5).unwrap().pop().unwrap();
|
|
let low = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(30., 30.))], 0., 1.5).unwrap().pop().unwrap();
|
|
let high = crate::extrude(&plane, &[rectangle(Vec2::new(25., 20.), Vec2::new(35., 30.))], 1., 2.5).unwrap().pop().unwrap();
|
|
for tools in [[&low, &high], [&high, &low]] {
|
|
let mut result = stock.clone();
|
|
for tool in tools {
|
|
let target = result;
|
|
result = stack_boolean(&target, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap();
|
|
// The second cut's target has walls in pieces across its slabs, followed level by
|
|
// level: its history is exact.
|
|
let exact = crate::boolean_record::tests::Reach { made: 0., moved: 0. };
|
|
crate::boolean_record::tests::checked(&result, &[&target, tool], 1e-3, exact, &|_| false);
|
|
}
|
|
assert!(result.validity().is_empty());
|
|
assert_eq!(result.shell_count(), 1);
|
|
assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6);
|
|
let mesh = result.tessellate(1e-4).unwrap();
|
|
for (point, material) in [
|
|
(Vec3::new(22.5, 25., 0.5), false),
|
|
(Vec3::new(22.5, 25., 2.), true),
|
|
(Vec3::new(32.5, 25., 0.5), true),
|
|
(Vec3::new(32.5, 25., 2.), false),
|
|
(Vec3::new(27.5, 25., 1.25), false),
|
|
(Vec3::new(10., 10., 1.25), true),
|
|
] {
|
|
assert_eq!(mesh.contains(point), material, "{point:?}");
|
|
}
|
|
let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap();
|
|
assert!(imported.warnings.is_empty());
|
|
assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6);
|
|
let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap();
|
|
let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap();
|
|
assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6);
|
|
assert!(opened.validity().is_empty());
|
|
}
|
|
}
|
|
#[test]
|
|
fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() {
|
|
let plane = Plane::named("XY").unwrap();
|
|
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
|
|
let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0));
|
|
let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap();
|
|
assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6);
|
|
let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] };
|
|
let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap();
|
|
assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6);
|
|
let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] };
|
|
let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap();
|
|
let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0;
|
|
assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1);
|
|
assert_eq!(body.solid.boundaries().len(), 2);
|
|
let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
|
|
let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0));
|
|
assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err());
|
|
let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] });
|
|
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
|
|
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {
|
|
for reversed in [false, true] {
|
|
let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam);
|
|
let lp = if reversed { lp.reversed() } else { lp };
|
|
for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] {
|
|
assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}");
|
|
}
|
|
for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] {
|
|
assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}");
|
|
}
|
|
}
|
|
}
|
|
let half = Loop2 {
|
|
segs: vec![
|
|
Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
|
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) },
|
|
],
|
|
};
|
|
for lp in [half.clone(), half.reversed()] {
|
|
assert!(inside_loop(&lp, Vec2::new(5.0, 0.0)));
|
|
assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0)));
|
|
assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0)));
|
|
}
|
|
}
|
|
#[test]
|
|
fn tiny_tool_between_circle_and_display_chord_is_not_lost() {
|
|
let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
|
|
let sampled = circle.outer.polyline(1e-3);
|
|
let direction = (sampled[0] + sampled[1]).normalized().unwrap();
|
|
let at = direction * 9.9998;
|
|
let half = Vec2::new(0.000025, 0.000025);
|
|
let square = rectangle(at - half, at + half);
|
|
assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0));
|
|
assert!(!circle.contains(at), "fixture must lie outside the display chord");
|
|
let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap();
|
|
assert_eq!(cut.len(), 1);
|
|
assert_eq!(cut[0].holes.len(), 1);
|
|
assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13);
|
|
let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap();
|
|
assert_eq!(intersect.len(), 1);
|
|
assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13);
|
|
}
|
|
#[test]
|
|
fn planar_region_api_rejects_hostile_values_and_budgets() {
|
|
let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
|
|
for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
|
|
assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err());
|
|
}
|
|
let mut bad = plate.clone();
|
|
bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X };
|
|
assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err());
|
|
let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1];
|
|
assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err());
|
|
}
|
|
}
|