* claim: #1209 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz * Prism boolean: a curved edge at one level crosses the walls there only (#1209) The prism boolean sews its result slab by slab and keeps, for each section segment, the operand wall it was read beside; its history is built from those tags. `Stations::new` took every curved edge between two walls as crossing them at every level, so where two pieces of one cylinder are stacked (an arm's round end under a boss of that radius) the circle between them cleared the wall tags of every slab. `prism_lineage::sewn` then gave up, the join or cut came out with no history, and every face of the body was named from geometry (`exact: false`). A curve whose control points lie at one level (a B-spline, or a NURBS curve with positive weights, which lies in their convex hull) is at that level: it crosses the walls there only. Any other curve still spans every level. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: bherbruck <[email protected]>
3511 lines
176 KiB
Rust
3511 lines
176 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 std::borrow::Cow;
|
|
|
|
use solidcraft_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;
|
|
|
|
#[cfg(test)]
|
|
#[path = "prism_spline_tests.rs"]
|
|
mod spline_tests;
|
|
|
|
const MAX_PLANAR_SEGMENTS: usize = 8192;
|
|
const MAX_PLANAR_EVENTS: usize = 131_072;
|
|
const MAX_PLANAR_PAIRS: usize = 16_000_000;
|
|
|
|
/// Free-form boundary segments (conics, Béziers) take part in planar booleans as lines and
|
|
/// arcs within this distance (mm); loops that come out whole get their exact segments back.
|
|
pub const PLANAR_FIT_TOL: f64 = 1e-3;
|
|
|
|
/// Boolean of planar regions bounded by straight lines, circular arcs and free-form segments.
|
|
/// Lines and arcs stay analytic. Free-form loops are arranged as tangent arcs within
|
|
/// [`PLANAR_FIT_TOL`]: a loop the operation leaves whole comes back exact, one it cuts keeps
|
|
/// the arcs. 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 mut exact: Vec<(Loop2, Loop2)> = Vec::new();
|
|
let mut pieces = 0usize;
|
|
let (a, b) = (fit_free_form(a, &mut exact, &mut pieces)?, fit_free_form(b, &mut exact, &mut pieces)?);
|
|
let (a, b): (&[Region2], &[Region2]) = (&a, &b);
|
|
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()));
|
|
}
|
|
}
|
|
}
|
|
let out = boolean2d(a, b, op, tolerance)
|
|
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))?;
|
|
Ok(restore_exact_loops(out, &exact, tolerance))
|
|
})
|
|
}
|
|
|
|
/// Regions with their free-form loops fitted by lines and arcs; `exact` records each fitted
|
|
/// loop with the loop it stands for, `pieces` counts the fitted segments. Regions of lines and
|
|
/// arcs alone are passed on as they are.
|
|
fn fit_free_form<'a>(regions: &'a [Region2], exact: &mut Vec<(Loop2, Loop2)>, pieces: &mut usize) -> Result<Cow<'a, [Region2]>> {
|
|
if regions.iter().all(Region2::is_line_arc) {
|
|
return Ok(Cow::Borrowed(regions));
|
|
}
|
|
let mut fit = |lp: &Loop2| -> Result<Loop2> {
|
|
if lp.is_line_arc() {
|
|
return Ok(lp.clone());
|
|
}
|
|
let fitted = lp.fit_arcs(PLANAR_FIT_TOL).filter(|f| {
|
|
*pieces = pieces.saturating_add(f.segs.len());
|
|
*pieces <= MAX_PLANAR_SEGMENTS
|
|
});
|
|
let fitted = fitted.ok_or_else(|| {
|
|
KernelError::Invalid("planar boolean: a free-form boundary is not finite or takes more arcs than the geometry budget".into())
|
|
})?;
|
|
exact.push((fitted.clone(), lp.clone()));
|
|
Ok(fitted)
|
|
};
|
|
let fitted: Result<Vec<Region2>> =
|
|
regions.iter().map(|r| Ok(Region2 { outer: fit(&r.outer)?, holes: r.holes.iter().map(&mut fit).collect::<Result<_>>()? })).collect();
|
|
fitted.map(Cow::Owned)
|
|
}
|
|
|
|
/// Output loops that are a fitted free-form loop, whole (from any start, either way round), get
|
|
/// the exact loop back, running the same way.
|
|
fn restore_exact_loops(regions: Vec<Region2>, exact: &[(Loop2, Loop2)], tol: f64) -> Vec<Region2> {
|
|
if exact.is_empty() {
|
|
return regions;
|
|
}
|
|
let near = |p: Vec2, q: Vec2| p.dist(q) <= tol * 10.0;
|
|
let same_run = |l: &Loop2, f: &Loop2| -> bool {
|
|
let n = f.segs.len();
|
|
let Some(first) = l.segs.first() else { return false };
|
|
(0..n).any(|k| {
|
|
f.segs.get(k).is_some_and(|s| near(s.start(), first.start()))
|
|
&& l.segs.iter().enumerate().all(|(i, s)| {
|
|
f.segs.get((k + i) % n).is_some_and(|g| near(g.start(), s.start()) && near(g.end(), s.end()) && near(g.mid(), s.mid()))
|
|
})
|
|
})
|
|
};
|
|
let restore = |l: Loop2| -> Loop2 {
|
|
for (fitted, original) in exact {
|
|
if fitted.segs.len() != l.segs.len() {
|
|
continue;
|
|
}
|
|
if same_run(&l, fitted) {
|
|
return original.clone();
|
|
}
|
|
if same_run(&l, &fitted.reversed()) {
|
|
return original.reversed();
|
|
}
|
|
}
|
|
l
|
|
};
|
|
regions.into_iter().map(|r| Region2 { outer: restore(r.outer), holes: r.holes.into_iter().map(&restore).collect() }).collect()
|
|
}
|
|
|
|
/// 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()));
|
|
}
|
|
// The separation test knows lines and arcs: free-form loops take part as their arcs.
|
|
let fitted = fit_free_form(&normalized, &mut Vec::new(), &mut 0)?;
|
|
for (i, a) in fitted.iter().enumerate() {
|
|
for b in fitted.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(closest_param(t, *p)).dist(*p) <= 1e-7)
|
|
|| [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *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). A segment carried through levels
|
|
/// beside the same wall keeps the vertices it was read at, which may lie at another level: they
|
|
/// say which line two walls meet along, not which edge of it. `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>,
|
|
/// Inside [`boolean2d_tagged`] only: the free-form segment (by number) the arc this tags
|
|
/// was fitted to, so the result's pieces of it get the exact curve back.
|
|
pub free: Option<usize>,
|
|
}
|
|
|
|
/// A segment nothing is known of.
|
|
const BLANK: Tag = Tag { operand: 0, wall: None, edge: None, far: None, start: None, end: None, free: 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 of a slab with its `far` edge: the operand's edge where its wall meets
|
|
/// one of `caps` (the outlines of the operand's caps ending the slab).
|
|
///
|
|
/// The operand's topology settles it where `top` gives the wall's edge (the wall ends at the
|
|
/// caps in that one edge, [`Stations::top_edge`]): that edge is over every segment beside it.
|
|
///
|
|
/// Otherwise, for a wall meeting the caps in several edges (its top split at vertices an
|
|
/// earlier operation left, or beside a wall across it there) or carrying on past the caps
|
|
/// elsewhere, which edge is over a segment depends on where the segment lies along the wall,
|
|
/// and nothing in the topology says that. Only there is position compared: the segment takes
|
|
/// the edge of the cap segment beside the same wall it runs along end to end. 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], top: impl Fn(usize) -> Option<usize>, tol: f64, budget: &mut usize) {
|
|
let mut tops: std::collections::HashMap<usize, Option<usize>> = std::collections::HashMap::new();
|
|
// The caps' segments by wall, gathered only once a wall needs position.
|
|
let mut by_wall: Option<std::collections::HashMap<usize, Vec<(Seg2, Option<usize>)>>> = None;
|
|
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(w) = tag.wall else { continue };
|
|
if let Some(e) = *tops.entry(w).or_insert_with(|| top(w)) {
|
|
tag.far = Some(e);
|
|
continue;
|
|
}
|
|
let by_wall = by_wall.get_or_insert_with(|| {
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
by_wall
|
|
});
|
|
let Some(beside) = by_wall.get(&w) else { continue };
|
|
let Some(left) = budget.checked_sub(beside.len()) else { continue };
|
|
*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>,
|
|
/// Per edge (edge order) that is not a line: the points its curve lies within (see
|
|
/// [`control_hull`]); `None` where they are not known.
|
|
hulls: Vec<Option<Vec<Vec3>>>,
|
|
}
|
|
|
|
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 hulls = edges.iter().map(|e| control_hull(&e.curve())).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, hulls }
|
|
}
|
|
|
|
/// 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(),
|
|
free: None,
|
|
}
|
|
}
|
|
|
|
/// 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 = if FREE_FORM.with(|c| c.get()) {
|
|
crate::prism_curve::segment_fitted(e, plane, tol, PLANAR_FIT_TOL, work)?
|
|
} else {
|
|
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, unless it lies at one 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))
|
|
}
|
|
// A curve at one level (where a piece of a wall's surface stacked on another
|
|
// meets it: a boss's cylinder over the arc of the plate it stands on) crosses
|
|
// the walls there only. Any other spans every level.
|
|
_ => match index.hulls.get(i).and_then(Option::as_deref).map(|hull| level_span(hull, plane)) {
|
|
Some((lo, hi)) if hi - lo < tol => (lo, hi),
|
|
_ => (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)
|
|
}
|
|
|
|
/// The operand's edge (in `index`) where wall `w` ends at level `z`, when it ends there in
|
|
/// one edge: `z` is its top, no other wall crosses it there, and it meets the faces `caps`
|
|
/// (the caps at `z`) in exactly one edge. Found by topology alone, no position compared. The wall reaching no higher, every segment beside
|
|
/// it in a slab up to `z` ends on its edges at `z`, which are that one edge alone.
|
|
fn top_edge(&self, index: &Index, w: usize, caps: &[usize], z: f64, tol: f64) -> Option<usize> {
|
|
let ends = self.tops.get(&w).is_some_and(|t| (*t - z).abs() < tol);
|
|
if !ends || self.of(w).iter().any(|(_, l, h)| *l < z + tol && *h > z - tol) {
|
|
return None;
|
|
}
|
|
let mut edges = caps.iter().filter_map(|c| index.between.get(&(w.min(*c), w.max(*c)))).flatten();
|
|
match (edges.next(), edges.next()) {
|
|
(Some(e), None) => Some(*e),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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 most control points [`control_hull`] reads of one curve.
|
|
const MAX_HULL_POINTS: usize = 4096;
|
|
|
|
/// The points curve `c` lies within: a B-spline's control points, or a NURBS curve's where its
|
|
/// weights are positive (the curve then lies in their convex hull). `None` for a line, an
|
|
/// intersection curve, other weights or too many points.
|
|
fn control_hull(c: &mt::Curve) -> Option<Vec<Vec3>> {
|
|
match c {
|
|
mt::Curve::BSplineCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => Some(c.control_points().iter().map(|p| from_p3(*p)).collect()),
|
|
mt::Curve::NurbsCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => {
|
|
c.control_points().iter().map(|p| (p.w > 0.0 && p.w.is_finite()).then(|| Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w))).collect()
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The lowest and highest levels of `points` (infinite where there are none, or one isn't
|
|
/// finite).
|
|
fn level_span(points: &[Vec3], plane: &Plane) -> (f64, f64) {
|
|
let span = points
|
|
.iter()
|
|
.map(|p| plane.height(*p))
|
|
.try_fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| h.is_finite().then(|| (lo.min(h), hi.max(h))));
|
|
match span {
|
|
Some((lo, hi)) if lo <= hi => (lo, hi),
|
|
_ => (f64::NEG_INFINITY, f64::INFINITY),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) 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(closest_param(t, 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.
|
|
///
|
|
/// Curves that touch within the tolerance (a line tangent to an arc, two circles tangent inside
|
|
/// or outside) meet at one point, taken from the centres: the foot of the centre on the line,
|
|
/// or the point on the line of centres. The intersection formulas' two roots there are rounding
|
|
/// noise spread along the curves by its square root (a 1e-12 mm error in a distance puts them
|
|
/// ±2e-6 mm from a 2 mm arc's tangent point), close enough to both curves to pass for two
|
|
/// crossings; when the noise falls the other way there is no root and the contact is missed.
|
|
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);
|
|
// Lines lying on one another within the tolerance do not cross: their ends split
|
|
// them. A hair from parallel they still meet at a point, anywhere along the overlap,
|
|
// and a split there leaves a piece shorter than the tolerance or a vertex that
|
|
// neither outline has.
|
|
let off = |p: Vec2, o: Vec2, dir: Vec2| (p - o).cross(dir).abs() / dir.len().max(1e-300);
|
|
let collinear = off(c, a, r) < tol && off(d, a, r) < tol || off(a, c, q) < tol && off(b, c, q) < tol;
|
|
if den.abs() > 1e-300 && !collinear {
|
|
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 {
|
|
let foot = a + dir * (-qb / (2.0 * qa));
|
|
if (foot.dist(center) - radius).abs() < tol {
|
|
pts.push(foot);
|
|
} else if 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 {
|
|
// Circles of nearly one radius about nearly one centre lie on each other for a
|
|
// long way rather than touch at a point (centres 1000 tolerances apart: within
|
|
// the tolerance of each other over 5° of arc): those keep the general formula.
|
|
let inside = (dd - (r0 - r1).abs()).abs() < tol && dd > 1e3 * tol;
|
|
if (dd - (r0 + r1)).abs() < tol || inside {
|
|
pts.push(c0 + (c1 - c0) * (if inside && r1 > r0 { -r0 } else { r0 } / dd));
|
|
} else {
|
|
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) = (closest_param(s, p), closest_param(t, 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.
|
|
/// Loops are closed only within the chaining tolerance (an arrangement's own output joins its
|
|
/// pieces within `10 · tol`), so a gap where one line ends and the next begins is closed by a
|
|
/// straight connector: a ray through the gap would otherwise slip out of the loop without a
|
|
/// crossing and flip the parity of points level with the corner. Arcs decide their own ends
|
|
/// within an angular tolerance (below), so joints with an arc keep that rule.
|
|
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
|
|
// A ray through a vertex or grazing an arc's top or bottom is decided by the half-open
|
|
// rule, which needs both curves meeting there to agree on that point exactly. Arcs read
|
|
// back from a solid (a patterned hole's two halves) can disagree by a few ulps, so one half
|
|
// counts the touch and the other misses it. Move the ray off such points: a shift far below
|
|
// any modelling tolerance cannot change the answer for a point that is not on the loop.
|
|
let scale = 1.0 + p.x.abs().max(p.y.abs());
|
|
// A point clear of the loop's box (by more than the ray shifts below) is outside it: skip
|
|
// the costly tests, which a region with a thousand cut-outs runs for every probe (#732).
|
|
if let Some((lo, hi)) = loop_box(lp) {
|
|
let m = scale * 1e-6;
|
|
if p.x < lo.x - m || p.x > hi.x + m || p.y < lo.y - m || p.y > hi.y + m {
|
|
return false;
|
|
}
|
|
}
|
|
// A point on the loop keeps the half-open answer.
|
|
if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
|
|
return inside_loop_ray(lp, p);
|
|
}
|
|
let grazes = |q: Vec2| {
|
|
let near = |y: f64| (y - q.y).abs() < scale * 1e-9;
|
|
lp.segs.iter().any(|s| {
|
|
near(s.start().y)
|
|
|| match *s {
|
|
Seg2::Arc { center, radius, .. } => near(center.y + radius) || near(center.y - radius),
|
|
_ => false,
|
|
}
|
|
})
|
|
};
|
|
let ray = [0.0, 1.0, -1.0, 2.0, -2.0, 3.0, -3.0].into_iter().map(|k| Vec2::new(p.x, p.y + k * scale * 1e-7)).find(|q| !grazes(*q)).unwrap_or(p);
|
|
inside_loop_ray(lp, ray)
|
|
}
|
|
|
|
/// A box holding a loop of lines and arcs (an arc's whole circle); None for other curves.
|
|
fn loop_box(lp: &Loop2) -> Option<(Vec2, Vec2)> {
|
|
let mut lo = Vec2::new(f64::MAX, f64::MAX);
|
|
let mut hi = Vec2::new(f64::MIN, f64::MIN);
|
|
let mut add = |a: Vec2, b: Vec2| {
|
|
lo = Vec2::new(lo.x.min(a.x), lo.y.min(a.y));
|
|
hi = Vec2::new(hi.x.max(b.x), hi.y.max(b.y));
|
|
};
|
|
for seg in &lp.segs {
|
|
match *seg {
|
|
Seg2::Line { a, b } => add(Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y))),
|
|
Seg2::Arc { center, radius, .. } => add(center - Vec2::new(radius.abs(), radius.abs()), center + Vec2::new(radius.abs(), radius.abs())),
|
|
_ => return None,
|
|
}
|
|
}
|
|
(lo.x <= hi.x && lo.y <= hi.y && [lo.x, lo.y, hi.x, hi.y].iter().all(|v| v.is_finite())).then_some((lo, hi))
|
|
}
|
|
|
|
/// [`inside_loop`] with the ray along +x from `p` taken as given.
|
|
fn inside_loop_ray(lp: &Loop2, p: Vec2) -> bool {
|
|
let mut odd = false;
|
|
let nexts = lp.segs.iter().skip(1).chain(lp.segs.first());
|
|
for (seg, next) in lp.segs.iter().zip(nexts) {
|
|
if let (Seg2::Line { b: a, .. }, Seg2::Line { a: b, .. }) = (*seg, *next)
|
|
&& (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;
|
|
}
|
|
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)))
|
|
}
|
|
|
|
/// Call only after proving the point is at least `tol` from the other boundary.
|
|
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
|
|
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
|
|
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
|
|
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
|
|
}
|
|
|
|
/// Analytic projections avoid iterative searches at every planar boundary probe.
|
|
/// Other curve types keep the shared solver introduced on main.
|
|
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
|
|
match *segment {
|
|
Seg2::Line { a, b } => {
|
|
let direction = b - a;
|
|
let length2 = direction.dot(direction);
|
|
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
|
|
}
|
|
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
|
|
let offset = point - center;
|
|
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
|
|
if delta <= sweep.abs() {
|
|
(delta / sweep.abs()).clamp(0.0, 1.0)
|
|
} else if segment.start().dist(point) <= segment.end().dist(point) {
|
|
0.0
|
|
} else {
|
|
1.0
|
|
}
|
|
}
|
|
_ => segment.closest_param(point),
|
|
}
|
|
}
|
|
|
|
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(closest_param(s, p)).dist(p))
|
|
.fold(f64::INFINITY, f64::min)
|
|
}
|
|
|
|
/// The bounding box of a line or an arc, grown by twice the tolerance (and by the rounding of
|
|
/// its own corners): (min, max). Whatever lies within the tolerance of the segment lies in the
|
|
/// box. `None` for other segments and for values that are not finite: no box, and nothing is
|
|
/// skipped for it.
|
|
type SegBox = (Vec2, Vec2);
|
|
|
|
fn seg_box(s: &Seg2, tol: f64) -> Option<SegBox> {
|
|
let mut pts = vec![s.start(), s.end()];
|
|
match *s {
|
|
Seg2::Line { .. } => {}
|
|
Seg2::Arc { center, radius, start, sweep } => {
|
|
// The axis extremes the arc passes.
|
|
for k in 0..4 {
|
|
let ang = k as f64 * std::f64::consts::FRAC_PI_2;
|
|
let delta =
|
|
if sweep >= 0.0 { (ang - start).rem_euclid(std::f64::consts::TAU) } else { (start - ang).rem_euclid(std::f64::consts::TAU) };
|
|
if delta <= sweep.abs() {
|
|
pts.push(center + Vec2::from_angle(ang) * radius);
|
|
}
|
|
}
|
|
}
|
|
_ => return None,
|
|
}
|
|
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
|
|
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
|
|
let g = tol * 2.0 + 1e-12 * pts.iter().fold(0.0, |m: f64, p| m.max(p.x.abs()).max(p.y.abs()));
|
|
(pts.iter().all(|p| p.is_finite()) && g.is_finite()).then_some((Vec2::new(lo.x - g, lo.y - g), Vec2::new(hi.x + g, hi.y + g)))
|
|
}
|
|
|
|
fn boxes_meet(a: Option<SegBox>, b: Option<SegBox>) -> bool {
|
|
match (a, b) {
|
|
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
|
|
b.is_none_or(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y)
|
|
}
|
|
|
|
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
|
|
/// segment is within the tolerance of `p` then).
|
|
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
|
|
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
|
|
}
|
|
|
|
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
|
|
fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Option<Vec2> {
|
|
let (s, t) = segs
|
|
.iter()
|
|
.zip(boxes)
|
|
.filter(|(_, b)| box_holds(**b, p))
|
|
.map(|(s, _)| (s, closest_param(s, p)))
|
|
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
|
|
s.derivative(t).normalized()
|
|
}
|
|
|
|
/// 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). Free-form segments (a font's curves) take part as arcs within
|
|
/// [`PLANAR_FIT_TOL`]; each piece of one in the result is the exact curve again, ending where
|
|
/// it exactly meets the segment next to it (see [`exact_again`]).
|
|
fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Section> {
|
|
let line_arc = |s: &Section| s.regions.iter().all(Region2::is_line_arc);
|
|
if line_arc(a) && line_arc(b) {
|
|
return boolean2d_arcs(a, b, op, tol);
|
|
}
|
|
let mut sources = Vec::new();
|
|
let (fa, fb) = (fitted(a, &mut sources, PLANAR_FIT_TOL)?, fitted(b, &mut sources, PLANAR_FIT_TOL)?);
|
|
let out = boolean2d_arcs(&fa, &fb, op, tol)?;
|
|
Some(exact_again(out, &sources, tol))
|
|
}
|
|
|
|
/// `s` with each free-form segment replaced by lines and arcs within `fit`, tagged with its
|
|
/// number in `sources` (where it is put).
|
|
fn fitted(s: &Section, sources: &mut Vec<Seg2>, fit: f64) -> Option<Section> {
|
|
let mut out = Section::default();
|
|
let mut pieces = 0usize;
|
|
for (r, reg) in s.regions.iter().enumerate() {
|
|
let mut loops = Vec::new();
|
|
let mut tags = Vec::new();
|
|
for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
|
let (mut segs, mut ts) = (Vec::new(), Vec::new());
|
|
for (i, seg) in lp.segs.iter().enumerate() {
|
|
let tag = s.tag(r, l, i);
|
|
if seg.is_line_or_arc() {
|
|
segs.push(*seg);
|
|
ts.push(tag);
|
|
continue;
|
|
}
|
|
let arcs = seg.fit_arcs(fit)?;
|
|
pieces = pieces.checked_add(arcs.len()).filter(|n| *n <= MAX_PLANAR_SEGMENTS)?;
|
|
let n = arcs.len();
|
|
ts.extend((0..n).map(|k| Tag { free: Some(sources.len()), ..tag.piece(k, n) }));
|
|
segs.extend(arcs);
|
|
sources.push(*seg);
|
|
}
|
|
loops.push(Loop2 { segs });
|
|
tags.push(ts);
|
|
}
|
|
let mut loops = loops.into_iter();
|
|
out.regions.push(Region2 { outer: loops.next()?, holes: loops.collect() });
|
|
out.tags.push(tags);
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// A loop's piece in [`exact_again`]: a run of arcs fitted to free-form segment `src`, standing
|
|
/// for its exact curve from parameter `t0` to `t1` (`t1 < t0`: run backwards), from `a` to `b`;
|
|
/// or a line or arc as it is.
|
|
#[derive(Clone, Copy)]
|
|
enum Piece {
|
|
Exact { src: usize, t0: f64, t1: f64, a: Vec2, b: Vec2, tag: Tag },
|
|
Plain(Seg2, Tag),
|
|
}
|
|
|
|
impl Piece {
|
|
fn end(&self) -> Vec2 {
|
|
match self {
|
|
Piece::Exact { b, .. } => *b,
|
|
Piece::Plain(s, _) => s.end(),
|
|
}
|
|
}
|
|
|
|
/// The piece ending at `at` (an exact curve: at its parameter `t`, when given).
|
|
fn set_end(&mut self, t: Option<f64>, at: Vec2) {
|
|
match self {
|
|
Piece::Exact { t1, b, .. } => {
|
|
if let Some(t) = t {
|
|
*t1 = t;
|
|
}
|
|
*b = at;
|
|
}
|
|
Piece::Plain(s, _) => *s = seg_with_ends(s, s.start(), at),
|
|
}
|
|
}
|
|
|
|
/// The piece starting at `at` (an exact curve: at its parameter `t`, when given).
|
|
fn set_start(&mut self, t: Option<f64>, at: Vec2) {
|
|
match self {
|
|
Piece::Exact { t0, a, .. } => {
|
|
if let Some(t) = t {
|
|
*t0 = t;
|
|
}
|
|
*a = at;
|
|
}
|
|
Piece::Plain(s, _) => *s = seg_with_ends(s, at, s.end()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `s` running from `a` to `b` instead (points on it or a hair off it): a line between them, an
|
|
/// arc of the same circle, a free-form segment with those end control points.
|
|
fn seg_with_ends(s: &Seg2, a: Vec2, b: Vec2) -> Seg2 {
|
|
match *s {
|
|
Seg2::Line { .. } => Seg2::Line { a, b },
|
|
Seg2::Arc { center, radius, start, sweep } => {
|
|
let new_start = (a - center).angle();
|
|
let end = start + sweep;
|
|
// The same way round, through the same part of the circle.
|
|
let shift = |x: f64, near: f64| x + ((near - x) / std::f64::consts::TAU).round() * std::f64::consts::TAU;
|
|
let s0 = shift(new_start, start);
|
|
let s1 = shift((b - center).angle(), end);
|
|
Seg2::Arc { center, radius, start: s0, sweep: s1 - s0 }
|
|
}
|
|
Seg2::Cubic { p1, p2, .. } => Seg2::Cubic { p0: a, p1, p2, p3: b },
|
|
Seg2::Conic { apex, w, .. } => Seg2::Conic { a, apex, b, w },
|
|
Seg2::Bezier { n, mut p } => {
|
|
if let Some(x) = p.first_mut() {
|
|
*x = a;
|
|
}
|
|
if let Some(x) = p.get_mut(n as usize) {
|
|
*x = b;
|
|
}
|
|
Seg2::Bezier { n, p }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The exact piece of `src` from parameter `t0` to `t1` (either way round), from `a` to `b`.
|
|
fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
|
|
let (lo, hi) = (t0.min(t1), t0.max(t1));
|
|
let mut s = *src;
|
|
if lo > 1e-12 {
|
|
s = s.split_at(lo).1;
|
|
}
|
|
if hi < 1.0 - 1e-12 {
|
|
// Splitting a conic renormalises its weights, which moves its parameters: the second
|
|
// split is found on the piece.
|
|
let at = closest_param(&s, src.point_at(hi));
|
|
if at < 1.0 - 1e-12 {
|
|
s = s.split_at(at).0;
|
|
}
|
|
}
|
|
if t0 <= t1 { seg_with_ends(&s, a, b) } else { seg_with_ends(&s.reversed(), a, b) }
|
|
}
|
|
|
|
/// Where curve `p` near its parameter `t` meets `q` near its parameter `u`, by Newton's method:
|
|
/// their parameters there, or `None` when the crossing doesn't settle within `near` of `p(t)`.
|
|
fn meet(p: &Seg2, t: f64, q: &Seg2, u: f64, near: f64) -> Option<(f64, f64)> {
|
|
let (mut t, mut u) = (t, u);
|
|
let start = p.point_at(t);
|
|
let scale = 1.0 + start.x.abs().max(start.y.abs());
|
|
for _ in 0..50 {
|
|
let f = p.point_at(t) - q.point_at(u);
|
|
if f.len() < 1e-14 * scale {
|
|
break;
|
|
}
|
|
// P(t) - Q(u) = f; the step solves [P'(t), -Q'(u)] (dt, du) = -f.
|
|
let (dp, dq) = (p.derivative(t), q.derivative(u));
|
|
let det = -dp.cross(dq);
|
|
if !(det.abs() > 1e-300 && det.is_finite()) {
|
|
return None;
|
|
}
|
|
t = (t + f.cross(dq) / det).clamp(0.0, 1.0);
|
|
u = (u + f.cross(dp) / det).clamp(0.0, 1.0);
|
|
}
|
|
let (x, y) = (p.point_at(t), q.point_at(u));
|
|
(x.dist(y) < 1e-9 * scale && x.dist(start) < near).then_some((t, u))
|
|
}
|
|
|
|
/// The whole line or circle a line or arc lies on (as a segment reaching past it), what an exact
|
|
/// crossing with a curve is looked for on.
|
|
fn carrier(s: &Seg2) -> Option<Seg2> {
|
|
match *s {
|
|
Seg2::Line { a, b } => Some(Seg2::Line { a: a - (b - a), b: b + (b - a) }),
|
|
Seg2::Arc { center, radius, start, sweep } => {
|
|
let turn = sweep.signum() * std::f64::consts::PI;
|
|
Some(Seg2::Arc { center, radius, start: start + sweep * 0.5 - turn, sweep: 2.0 * turn })
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The exact curves back in the result of a boolean of [`fitted`] sections: each run of arcs
|
|
/// fitted to one free-form segment becomes that segment's exact piece, and where a run ends on
|
|
/// another segment (a cut across the curve) both end at their exact crossing. Where that
|
|
/// crossing can't be found they end where the arrangement put them, within
|
|
/// [`PLANAR_FIT_TOL`] of it.
|
|
fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section {
|
|
let near = (PLANAR_FIT_TOL + tol) * 10.0;
|
|
let mut res = Section::default();
|
|
for (r, reg) in out.regions.iter().enumerate() {
|
|
let tags = out.region_tags(r);
|
|
let mut loops = Vec::new();
|
|
let mut loop_tags = Vec::new();
|
|
for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
|
let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near);
|
|
// A sliver the arrangement found where a curve's arcs nearly touch another part of
|
|
// the outline (at the waist of a 3) closes up to nothing on the exact curves: it
|
|
// encloses nothing and would make edges with no length, so it is left out.
|
|
if segs.iter().map(Seg2::length).sum::<f64>() <= near {
|
|
if l == 0 {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
loops.push(Loop2 { segs });
|
|
loop_tags.push(ts);
|
|
}
|
|
let mut loops = loops.into_iter();
|
|
let Some(outer) = loops.next() else { continue };
|
|
res.regions.push(Region2 { outer, holes: loops.collect() });
|
|
res.tags.push(loop_tags);
|
|
}
|
|
res
|
|
}
|
|
|
|
/// One loop of [`exact_again`]: its segments and their tags.
|
|
fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg2>, Vec<Tag>) {
|
|
let n = lp.segs.len();
|
|
let tag = |i: usize| Tag { free: None, ..tags.get(i).copied().unwrap_or(BLANK) };
|
|
let free = |i: usize| tags.get(i).and_then(|t| t.free);
|
|
let plain = || (lp.segs.clone(), (0..n).map(tag).collect::<Vec<_>>());
|
|
// Start at the first segment of a run (a loop that is one run stays as it is).
|
|
let Some(first) = (0..n).find(|&i| free(i).is_none() || free(i) != free((i + n - 1) % n)) else { return plain() };
|
|
let mut pieces: Vec<Piece> = Vec::new();
|
|
let mut k = 0;
|
|
while k < n {
|
|
let i = (first + k) % n;
|
|
let Some(seg) = lp.segs.get(i) else { return plain() };
|
|
let Some((f, src)) = free(i).and_then(|f| sources.get(f).map(|s| (f, s))) else {
|
|
pieces.push(Piece::Plain(*seg, tag(i)));
|
|
k += 1;
|
|
continue;
|
|
};
|
|
let mut j = k;
|
|
while j + 1 < n && free((first + j + 1) % n) == Some(f) {
|
|
j += 1;
|
|
}
|
|
let Some(last) = lp.segs.get((first + j) % n) else { return plain() };
|
|
// A run reaching the curve's end is the curve to its end, exactly: the same curve the
|
|
// walls over it are read from, which a parameter found by projection (a hair short of 1)
|
|
// would cut short into a different one.
|
|
let at = |p: Vec2| {
|
|
if p.dist(src.start()) < near {
|
|
0.0
|
|
} else if p.dist(src.end()) < near {
|
|
1.0
|
|
} else {
|
|
closest_param(src, p)
|
|
}
|
|
};
|
|
let (t0, t1) = (at(seg.start()), at(last.end()));
|
|
if (t1 - t0).abs() < 1e-9 {
|
|
// A sliver of the curve: its arcs as they are, or, shorter than the tolerance (where
|
|
// a curve lies on another, as when the same letters are cut again), nothing: its
|
|
// neighbours meet where it was, and as an edge it would have no length.
|
|
let length: f64 = (k..=j).filter_map(|m| lp.segs.get((first + m) % n)).map(Seg2::length).sum();
|
|
if length > near {
|
|
for m in k..=j {
|
|
let q = (first + m) % n;
|
|
pieces.push(Piece::Plain(*lp.segs.get(q).unwrap_or(seg), tag(q)));
|
|
}
|
|
}
|
|
} else {
|
|
let t = Tag { end: tag((first + j) % n).end, ..tag(i) };
|
|
pieces.push(Piece::Exact { src: f, t0, t1, a: src.point_at(t0), b: src.point_at(t1), tag: t });
|
|
}
|
|
k = j + 1;
|
|
}
|
|
// Each joint at an exact curve: both sides end at the same point, the exact crossing where
|
|
// the curve is cut, its own end point where it ends.
|
|
if pieces.is_empty() {
|
|
return plain();
|
|
}
|
|
let m = pieces.len();
|
|
let inner = |t: f64| t > 1e-9 && t < 1.0 - 1e-9;
|
|
for i in 0..m {
|
|
let (Some(p), Some(q)) = (pieces.get(i).copied(), pieces.get((i + 1) % m).copied()) else { continue };
|
|
let joint = p.end();
|
|
let src = |f: usize| sources.get(f);
|
|
let found = match (p, q) {
|
|
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
|
|
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
|
|
}
|
|
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
|
|
.zip(carrier(&s))
|
|
.and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
|
|
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
|
|
.zip(carrier(&s))
|
|
.and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
|
|
_ => None,
|
|
};
|
|
// No crossing to refine (or none found): both end where the curve does.
|
|
let found = found.or(match (p, q) {
|
|
(Piece::Exact { b, .. }, _) => Some((None, None, b)),
|
|
(_, Piece::Exact { a, .. }) => Some((None, None, a)),
|
|
_ => None,
|
|
});
|
|
let Some((x, y, at)) = found else { continue };
|
|
if let Some(p) = pieces.get_mut(i) {
|
|
p.set_end(x, at);
|
|
}
|
|
if let Some(q) = pieces.get_mut((i + 1) % m) {
|
|
q.set_start(y, at);
|
|
}
|
|
}
|
|
let mut segs = Vec::with_capacity(m);
|
|
let mut ts = Vec::with_capacity(m);
|
|
for p in pieces {
|
|
match p {
|
|
Piece::Exact { src, t0, t1, a, b, tag } => match sources.get(src) {
|
|
Some(s) => {
|
|
segs.push(trimmed(s, t0, t1, a, b));
|
|
ts.push(tag);
|
|
}
|
|
None => return plain(),
|
|
},
|
|
Piece::Plain(s, t) => {
|
|
segs.push(s);
|
|
ts.push(t);
|
|
}
|
|
}
|
|
}
|
|
// The loop closed exactly: where a segment ends a hair from where the next starts (a font's
|
|
// nearly round curve read as an arc ends a few hundredths of a micron off the curve after
|
|
// it), the one that is not an arc moves its end onto the arc's (an arc's ends stay on its
|
|
// circle). Two arcs are left as they meet, as a boolean of lines and arcs leaves them.
|
|
let m = segs.len();
|
|
for i in 0..m {
|
|
let j = (i + 1) % m;
|
|
let (Some(x), Some(y)) = (segs.get(i).copied(), segs.get(j).copied()) else { continue };
|
|
let gap = x.end().dist(y.start());
|
|
if gap == 0.0 || gap > near {
|
|
continue;
|
|
}
|
|
let arc = |s: &Seg2| matches!(s, Seg2::Arc { .. });
|
|
if !arc(&y) {
|
|
if let Some(s) = segs.get_mut(j) {
|
|
*s = seg_with_ends(&y, x.end(), y.end());
|
|
}
|
|
} else if !arc(&x)
|
|
&& let Some(s) = segs.get_mut(i)
|
|
{
|
|
*s = seg_with_ends(&x, x.start(), y.start());
|
|
}
|
|
}
|
|
(segs, ts)
|
|
}
|
|
|
|
/// The boolean of sections of lines and arcs alone ([`boolean2d_tagged`]).
|
|
fn boolean2d_arcs(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;
|
|
}
|
|
// Only segments whose bounding boxes meet can cross, touch or lie on each other: the other
|
|
// pairs skip the exact tests (a closest-point search each).
|
|
let (box_a, box_b): (Vec<_>, Vec<_>) = (sa.iter().map(|s| seg_box(s, tol)).collect(), sb.iter().map(|s| seg_box(s, tol)).collect());
|
|
let apart = |i: usize, j: usize| !boxes_meet(box_a.get(i).copied().flatten(), box_b.get(j).copied().flatten());
|
|
// 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() {
|
|
if apart(i, j) {
|
|
continue;
|
|
}
|
|
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 (j, t) in sb.iter().enumerate() {
|
|
if apart(i, j) {
|
|
continue;
|
|
}
|
|
for p in [t.start(), t.end()] {
|
|
let u = closest_param(s, 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 (i, s) in sa.iter().enumerate() {
|
|
if apart(i, j) {
|
|
continue;
|
|
}
|
|
for p in [s.start(), s.end()] {
|
|
let v = closest_param(t, 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The loops below are chained by joining pieces whose ends lie within `near` of each other,
|
|
// so to them a piece shorter than that runs from a point back to the same point: a cut that
|
|
// would leave one, at either end of what is left of the segment, is not made. Two edges
|
|
// 0.57° apart that share a corner up to rounding cross a hundred times that rounding short
|
|
// of it; split there, the sliver between the crossing and the corner was taken for a
|
|
// second way out of the corner and closed as a loop of its own.
|
|
let near = tol * 10.0;
|
|
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() > near && y.length() > near {
|
|
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 = near_dist(&sb, &box_b, m) < tol;
|
|
let keep = if on {
|
|
let same = near_dir(&sb, &box_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_clear(&b, m, tol);
|
|
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 near_dist(&sa, &box_a, m) < tol {
|
|
continue;
|
|
}
|
|
let ins = inside_clear(&a, m, tol);
|
|
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. Where several kept pieces leave the point a loop has reached (outlines
|
|
// pinching at a vertex on an edge, a circle tangent to a side, two corners touching), the
|
|
// loop takes the one keeping the material on its left most tightly, so that pieces of
|
|
// material meeting only at that point close as loops of their own; returning to its start,
|
|
// a loop closes only when that is the tightest turn too. A loop that still passes a point
|
|
// twice (a hole touching the outline) is split there into simple 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 last = *segs.last()?;
|
|
let end = 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));
|
|
let closes = end.dist(first) < near && (segs.len() > 1 || full_circle);
|
|
let leaving: Vec<usize> =
|
|
(0..kept.len()).filter(|k| !used.get(*k).copied().unwrap_or(true) && kept[*k].0.start().dist(end) < near).collect();
|
|
let next = match leaving.as_slice() {
|
|
[] if end.dist(first) < near => break,
|
|
[] => return None,
|
|
[only] if !closes => *only,
|
|
_ => {
|
|
// The loop's own first piece stands for closing it.
|
|
let mut out: Vec<Seg2> = leaving.iter().map(|k| kept[*k].0).collect();
|
|
if closes {
|
|
out.push(*segs.first()?);
|
|
}
|
|
let pick = tightest_left(&last, &out)?;
|
|
match leaving.get(pick) {
|
|
Some(k) => *k,
|
|
None => break,
|
|
}
|
|
}
|
|
};
|
|
let (seg, tag) = *kept.get(next)?;
|
|
if let Some(u) = used.get_mut(next) {
|
|
*u = true;
|
|
}
|
|
segs.push(seg);
|
|
tags.push(tag);
|
|
}
|
|
for (l, t) in simple_loops(Loop2 { segs }, tags, near) {
|
|
if l.signed_area().abs() > tol * tol {
|
|
loops.push((l, t));
|
|
}
|
|
}
|
|
}
|
|
// 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).
|
|
/// Of the pieces in `out` leaving the point where `incoming` ends, the one turning most to the
|
|
/// left: the largest counter-clockwise angle from the way back along `incoming`. A loop with
|
|
/// its material on its left that always takes it encloses the least, so material meeting
|
|
/// another piece only at a point closes there as a loop of its own.
|
|
///
|
|
/// Directions are read a short way along each piece (the same distance on all, a twentieth of
|
|
/// the shortest), not from tangents: a circle tangent to a line leaves the point along the
|
|
/// line, and only its curvature says which side it turns to. The pieces of an arrangement
|
|
/// don't cross, and none turns more than a twentieth of its sweep within that distance, so the
|
|
/// order the points lie in is the order the pieces leave in.
|
|
fn tightest_left(incoming: &Seg2, out: &[Seg2]) -> Option<usize> {
|
|
let shortest = out.iter().map(Seg2::length).fold(incoming.length(), f64::min);
|
|
let h = shortest * 0.05;
|
|
if !(h > 0.0) || !h.is_finite() {
|
|
return None;
|
|
}
|
|
let at = incoming.end();
|
|
let back = incoming.point_at(1.0 - h / incoming.length()) - at;
|
|
let turn = |s: &Seg2| {
|
|
let d = s.point_at(h / s.length()) - at;
|
|
let a = back.cross(d).atan2(back.dot(d));
|
|
if a < 0.0 { a + std::f64::consts::TAU } else { a }
|
|
};
|
|
(0..out.len()).max_by(|x, y| turn(&out[*x]).total_cmp(&turn(&out[*y])))
|
|
}
|
|
|
|
/// A closed loop split where it passes a point twice (within `near`) into loops that pass
|
|
/// every point once, with their segments' tags. Counter-clockwise pieces are material, the
|
|
/// others holes in it: a hole touching the outline at one point, or two pieces of material
|
|
/// meeting at a corner, come out as two loops sharing that point.
|
|
fn simple_loops(lp: Loop2, tags: Vec<Tag>, near: f64) -> Vec<(Loop2, Vec<Tag>)> {
|
|
let mut todo = vec![(lp.segs, tags)];
|
|
let mut out = Vec::new();
|
|
// Each split leaves two shorter loops, so the work is bounded by the segment count.
|
|
while let Some((segs, tags)) = todo.pop() {
|
|
let n = segs.len();
|
|
let repeat = (0..n).find_map(|i| {
|
|
let p = segs.get(i)?.start();
|
|
(i + 1..n).find(|j| segs.get(*j).is_some_and(|s| s.start().dist(p) < near)).map(|j| (i, j))
|
|
});
|
|
match repeat {
|
|
Some((i, j)) if j > i => {
|
|
let part = |v: &[Seg2], r: std::ops::Range<usize>| v.get(r).unwrap_or(&[]).to_vec();
|
|
let part_t = |v: &[Tag], r: std::ops::Range<usize>| v.get(r).unwrap_or(&[]).to_vec();
|
|
let inner = (part(&segs, i..j), part_t(&tags, i..j));
|
|
let outer = ([part(&segs, j..n), part(&segs, 0..i)].concat(), [part_t(&tags, j..n), part_t(&tags, 0..i)].concat());
|
|
todo.push(inner);
|
|
todo.push(outer);
|
|
}
|
|
_ => out.push((Loop2 { segs }, tags)),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
|
let mut ts: Vec<f64> = pts
|
|
.iter()
|
|
.filter_map(|p| {
|
|
// A point within `tol` of an arc is within `tol` of its circle: the others need no
|
|
// (sampled) projection.
|
|
if let Seg2::Arc { center, radius, .. } = seg
|
|
&& (p.dist(*center) - radius).abs() >= tol
|
|
{
|
|
return None;
|
|
}
|
|
// A point within `tol` of an end is that end (the sewing welds them into one): a cut
|
|
// there would leave a piece with no length.
|
|
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
|
|
return None;
|
|
}
|
|
let t = closest_param(seg, *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) },
|
|
_ => trimmed(seg, t0, t1, seg.point_at(t0), seg.point_at(t1)),
|
|
});
|
|
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. With `coalesce`,
|
|
/// walls over one segment through successive slabs are one face.
|
|
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()))?;
|
|
// Each slab's section, as read there: the same outline, but its walls
|
|
// read beside whichever operand is there.
|
|
let layers =
|
|
(first..end).map(|k| Some((slabs.get(k)?.region(r), *levels.get(k)?, *levels.get(k + 1)?))).collect::<Option<Vec<_>>>();
|
|
let layers = layers.ok_or_else(|| KernelError::Failed("missing prism section".into()))?;
|
|
pieces.push((piece, lineage::Built::Extruded { layers }));
|
|
}
|
|
} else {
|
|
let (piece, built) = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol, coalesce, history)
|
|
.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, built));
|
|
}
|
|
}
|
|
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)?]),
|
|
// Its ends and a point between them: free-form segments with the same ones are the same
|
|
// curve as far as a wall over it goes.
|
|
_ => {
|
|
let (a, m, b) = (s.start(), s.point_at(0.5), s.end());
|
|
let h = |x: f64, y: f64| bits(x).zip(bits(y)).map(|(x, y)| x ^ y.rotate_left(17));
|
|
Some([2, h(a.x, a.y)?, h(m.x, m.y)?, h(b.x, b.y)?, bits(m.x)?, bits(m.y)?])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A free-form segment as the control points of the cubic Bézier it is (a quadratic raised to
|
|
/// a cubic); `None` for those no cubic is exactly (rational conics, higher degrees).
|
|
fn cubic_of(s: &Seg2) -> Option<[Vec2; 4]> {
|
|
match *s {
|
|
Seg2::Cubic { p0, p1, p2, p3 } => Some([p0, p1, p2, p3]),
|
|
Seg2::Conic { a, apex, b, w } if (w - 1.0).abs() < 1e-12 => Some([a, a + (apex - a) * (2.0 / 3.0), b + (apex - b) * (2.0 / 3.0), b]),
|
|
Seg2::Bezier { n: 3, p } => Some([p[0], p[1], p[2], p[3]]),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A genuinely stepped connected axial block has no single extrusion footprint.
|
|
fn sew_connected_sections(
|
|
plane: &Plane,
|
|
d: Vec3,
|
|
levels: &[f64],
|
|
slabs: &[Section],
|
|
tol: f64,
|
|
coalesce: bool,
|
|
history: &lineage::Context,
|
|
) -> Option<Result<(Body, lineage::Built)>> {
|
|
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());
|
|
}
|
|
}
|
|
// Ends within `tol` of each other are one point. The 2D boolean closes its loops within
|
|
// `tol`, not exactly: where a font's nearly round curve was read as an arc, the arc ends a
|
|
// few hundredths of a micron from the curve next to it. Sewing joins only ends at the same
|
|
// point, so such a loop would not close.
|
|
let cell = |u: Vec2| ((u.x / tol).floor() as i64, (u.y / tol).floor() as i64);
|
|
let mut ends: Vec<Vec2> = Vec::new();
|
|
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
|
|
let weld = |ends: &[Vec2], grid: &std::collections::HashMap<(i64, i64), Vec<usize>>, u: Vec2| -> Option<Vec2> {
|
|
let (cx, cy) = cell(u);
|
|
(cx - 1..=cx + 1)
|
|
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
|
|
.filter_map(|c| grid.get(&c))
|
|
.flatten()
|
|
.filter_map(|&i| ends.get(i).copied())
|
|
.find(|e| e.dist(u) <= tol)
|
|
};
|
|
for &u in &pts {
|
|
if weld(&ends, &grid, u).is_none() {
|
|
grid.entry(cell(u)).or_default().push(ends.len());
|
|
ends.push(u);
|
|
}
|
|
}
|
|
// Welding above keeps its original encounter order. Side splitting needs
|
|
// only the distinct stations, whose resulting parameters are sorted anyway.
|
|
// Deduplicate in place, with bounded sorting work and no extra point list.
|
|
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
|
|
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
|
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
|
|
}
|
|
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
|
|
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
|
|
// are left as they were: such a piece of one fails the sewing, as before.)
|
|
let short = |s: &Seg2| {
|
|
!s.is_line_or_arc()
|
|
&& s.length() <= 2.0 * tol
|
|
&& matches!((weld(&ends, &grid, s.start()), weld(&ends, &grid, s.end())), (Some(p), Some(q)) if p == q)
|
|
};
|
|
// One curve between two ends, as the walls and the caps beside them each read it: their
|
|
// sections come from different booleans, which split it from different pieces, so the
|
|
// control points can differ by a hair, and sewing tells edges apart by them. The first
|
|
// reading stands for all of them.
|
|
let chains: std::cell::RefCell<Vec<[Vec3; 4]>> = std::cell::RefCell::new(Vec::new());
|
|
let same_chain = |c: [Vec3; 4]| -> [Vec3; 4] {
|
|
let mid = |k: &[Vec3; 4]| (k[0] + (k[1] + k[2]) * 3.0 + k[3]) * 0.125;
|
|
let mut known = chains.borrow_mut();
|
|
for k in known.iter() {
|
|
if mid(k).dist(mid(&c)) <= tol * 10.0 {
|
|
if k[0] == c[0] && k[3] == c[3] {
|
|
return *k;
|
|
}
|
|
if k[0] == c[3] && k[3] == c[0] {
|
|
return [k[3], k[2], k[1], k[0]];
|
|
}
|
|
}
|
|
}
|
|
known.push(c);
|
|
c
|
|
};
|
|
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
|
|
let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z);
|
|
// One edge between two ends, whichever way a wall or a cap reads the curve there: a piece of
|
|
// a font's curve so short that one section reads it as an arc within the tolerance, where
|
|
// another has the curve itself (the same letters cut again), is one edge. The first reading
|
|
// stands for all of them.
|
|
let readings: std::cell::RefCell<Vec<(Vec3, Vec3, Vec3, EdgeSpec)>> = std::cell::RefCell::new(Vec::new());
|
|
let same_edge = |spec: EdgeSpec, a: Vec3, b: Vec3, mid: Vec3| -> EdgeSpec {
|
|
let mut known = readings.borrow_mut();
|
|
for (ka, kb, km, k) in known.iter() {
|
|
if km.dist(mid) <= tol * 10.0 {
|
|
if *ka == a && *kb == b {
|
|
return k.clone();
|
|
}
|
|
if *ka == b && *kb == a {
|
|
return k.reversed();
|
|
}
|
|
}
|
|
}
|
|
known.push((a, b, mid, spec.clone()));
|
|
spec
|
|
};
|
|
let edge = |sg: &Seg2, z: f64| -> Option<EdgeSpec> {
|
|
Some(match sg {
|
|
Seg2::Line { .. } => EdgeSpec::Line { a: welded(sg.start(), z), b: welded(sg.end(), z) },
|
|
Seg2::Arc { .. } => {
|
|
let (a, b, mid) = (welded(sg.start(), z), welded(sg.end(), z), at(sg.point_at(0.5), z));
|
|
same_edge(EdgeSpec::Arc { a, b, mid }, a, b, mid)
|
|
}
|
|
_ => {
|
|
let [p0, p1, p2, p3] = cubic_of(sg)?;
|
|
let c = same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]);
|
|
let mid = (c[0] + (c[1] + c[2]) * 3.0 + c[3]) * 0.125;
|
|
same_edge(EdgeSpec::Chain { degree: 3, ctrl: c.to_vec().into() }, c[0], c[3], mid)
|
|
}
|
|
})
|
|
};
|
|
// Exact oriented generated segments alone define a run. No support fitting,
|
|
// proximity matching, or old face lineage is used here.
|
|
// Each run, with what its wall is over in each of its slabs (for the result's history).
|
|
type Run = (usize, usize, Seg2, Vec<lineage::Slab>);
|
|
let mut runs: Vec<Run> = Vec::new();
|
|
let mut last = std::collections::BTreeMap::new();
|
|
let mut remaining = MAX_PLANAR_SEGMENTS * 2;
|
|
for (i, slab) in slabs.iter().enumerate() {
|
|
for (r, reg) in slab.regions.iter().enumerate() {
|
|
for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() {
|
|
// The loop's walls in order: the run each joins, its tag and where it ends.
|
|
let mut around: Vec<(usize, Tag, Vec2)> = Vec::new();
|
|
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() {
|
|
if short(&sg) {
|
|
continue;
|
|
}
|
|
remaining = remaining.checked_sub(1)?;
|
|
let key = wall_key(&sg)?;
|
|
let prior = if coalesce { last.get(&key).copied() } else { None };
|
|
let run = match prior.map(|index| runs.get_mut(index).map(|run: &mut Run| (index, run))) {
|
|
Some(None) => return None,
|
|
Some(Some((index, run))) if run.1 == i => {
|
|
run.1 = i + 1;
|
|
index
|
|
}
|
|
// Duplicate oriented boundaries in one slab are not
|
|
// a valid reason to reconstruct a larger wall.
|
|
Some(Some((_, run))) if run.1 == i + 1 => return None,
|
|
_ => {
|
|
if coalesce {
|
|
last.insert(key, runs.len());
|
|
}
|
|
runs.push((i, i + 1, sg, Vec::new()));
|
|
runs.len() - 1
|
|
}
|
|
};
|
|
around.push((run, tag.piece(k, n), sg.end()));
|
|
}
|
|
}
|
|
// Each wall runs into the next one of its loop, the last into the first.
|
|
for (k, (run, tag, at)) in around.iter().enumerate() {
|
|
let next = around.get((k + 1) % around.len())?.0;
|
|
runs.get_mut(*run)?.3.push(lineage::Slab { tag: *tag, next, at: *at });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut faces: Vec<FaceSpec> = Vec::new();
|
|
let mut sources = Vec::new();
|
|
let mut specs: Vec<lineage::Spec> = Vec::with_capacity(runs.len());
|
|
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, over) 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: welded(b, levels[k]), b: welded(b, levels[k + 1]) });
|
|
}
|
|
lp3.push(edge(&sg.reversed(), z1)?);
|
|
for k in (first..end).rev() {
|
|
lp3.push(EdgeSpec::Line { a: welded(a, levels[k + 1]), b: welded(a, levels[k]) });
|
|
}
|
|
let outward = plane.dir_to_world(sg.derivative(0.5)).cross(d).normalized()?;
|
|
let surface = match sg {
|
|
Seg2::Line { .. } => SurfSpec::Plane,
|
|
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
|
// A free-form wall: the lines along `d` joining the curve at both levels.
|
|
_ => SurfSpec::Ruled { a: edge(&sg, z0)?, b: edge(&sg, z1)? },
|
|
};
|
|
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,
|
|
}));
|
|
specs.push(lineage::Spec::Wall { levels: levels.get(first..=end)?.to_vec(), slabs: over });
|
|
}
|
|
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)?.into_iter().filter(|s| !short(s)));
|
|
}
|
|
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::<Option<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).map(|(body, from)| (body, lineage::Built::Sewn { specs, faces: from }));
|
|
// Generated walls joined on their common supports: the piece's history over the operands,
|
|
// carried through the joining.
|
|
Some(if coalesce {
|
|
sewn.and_then(|(body, built)| {
|
|
let body = history.attach(body.clone(), &[(&body, built)]).regularize_generated_planar_faces()?;
|
|
Ok((body, lineage::Built::Traced))
|
|
})
|
|
} else {
|
|
sewn
|
|
})
|
|
}
|
|
|
|
#[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 (each line once, pointing up).
|
|
let dirs: Vec<Vec3> = directions(a, b)?.into_iter().map(|n| if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }).collect();
|
|
let (d, plane, pa, pb) = dirs.into_iter().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::new(op, [a, b], [ia.get(), ib.get()], plane, tol, 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 (u, x, y) = (area(regions), area(&pa.section.regions), area(&pb.section.regions));
|
|
let alike = |v: f64| (u - v).abs() <= tol * (1.0 + u);
|
|
// A tool that misses its target (or an intersection that is one operand whole) leaves it
|
|
// as it is: the B-rep boolean reuses that operand itself, where this would rebuild it.
|
|
if matches!(op, BoolOp::Cut | BoolOp::Intersect) && (same_area(u, x, tol) || (op == BoolOp::Intersect && same_area(u, y, tol))) {
|
|
return None;
|
|
}
|
|
let (lo, hi) = if stacked {
|
|
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
|
|
if !alike(x) || !alike(y) {
|
|
return sew_stack(&plane, d, &pa, &pb, tol, &history).map(|r| r.map(Some));
|
|
}
|
|
// One outline end to end: also slab by slab, so the history follows each operand's
|
|
// walls (one longer prism, whose walls are made from both).
|
|
if let Some(Ok(body)) = sew_stack(&plane, d, &pa, &pb, tol, &history)
|
|
&& body.history().is_some()
|
|
{
|
|
return Some(Ok(Some(body)));
|
|
}
|
|
(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 { layers: vec![(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]))))
|
|
}
|
|
|
|
/// The directions to try the operands as prisms along: the first eight plane directions of the
|
|
/// target (in its face order), then those of the tool's caps (a pair of opposite plane faces)
|
|
/// that the target has further on. A target cut many times over (a line of text, a group of
|
|
/// letters at a time) has more plane directions than eight, its walls' among them: in face
|
|
/// order its caps' direction can come too late. `None` past the face budget.
|
|
fn directions(a: &Body, b: &Body) -> Option<Vec<Vec3>> {
|
|
if a.face_count().saturating_add(b.face_count()) > MAX_PLANAR_SEGMENTS * 2 {
|
|
return None;
|
|
}
|
|
let normals = |x: &Body| -> Vec<Vec3> {
|
|
x.solid
|
|
.face_iter()
|
|
.filter_map(|f| match f.oriented_surface() {
|
|
mt::Surface::Plane(p) => {
|
|
let n = p.normal();
|
|
Vec3::new(n.x, n.y, n.z).normalized()
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
};
|
|
let mut target: Vec<Vec3> = Vec::new();
|
|
for n in normals(a) {
|
|
if !target.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) {
|
|
target.push(n);
|
|
}
|
|
}
|
|
let tool = normals(b);
|
|
let capped = |d: &Vec3| tool.iter().any(|n| n.dot(*d) > 1.0 - 1e-9) && tool.iter().any(|n| n.dot(*d) < -1.0 + 1e-9);
|
|
let mut dirs: Vec<Vec3> = target.iter().take(8).copied().collect();
|
|
dirs.extend(target.iter().skip(8).filter(|d| capped(d)).take(8));
|
|
Some(dirs)
|
|
}
|
|
|
|
/// The direction `b` is a prism (or a stack of prisms) along: the first of its planar faces'
|
|
/// normals along which it is one. `None` when there is none.
|
|
pub(crate) fn prism_axis(b: &Body) -> Option<Vec3> {
|
|
if b.face_count() > MAX_PLANAR_SEGMENTS {
|
|
return None;
|
|
}
|
|
let tol = (b.size() * 1e-7).max(1e-9) * 10.0;
|
|
let index = LazyIndex::new(b, 1);
|
|
let mut tried: Vec<Vec3> = Vec::new();
|
|
for f in b.solid.face_iter() {
|
|
let mt::Surface::Plane(p) = f.oriented_surface() else { continue };
|
|
let n = p.normal();
|
|
let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() else { continue };
|
|
if tried.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) {
|
|
continue;
|
|
}
|
|
tried.push(n);
|
|
if tried.len() > 16 {
|
|
return None;
|
|
}
|
|
let plane = Plane::from_normal(Vec3::ZERO, n)?;
|
|
if stack_tagged(b, n, &plane, tol, &index).is_some() {
|
|
return Some(n);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether a boolean of sections left one of area `v` as it was (area `u` after): the same to
|
|
/// within rounding, as the very same outline gives (a change is at least a sliver `tol` wide).
|
|
fn same_area(u: f64, v: f64, tol: f64) -> bool {
|
|
(u - v).abs() <= 1e-9 * u.abs().max(v.abs()) + tol * tol
|
|
}
|
|
|
|
/// 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(), fi));
|
|
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().filter(|p| (p.hi - *z).abs() < tol) {
|
|
let (faces, caps): (Vec<usize>, Vec<&Section>) =
|
|
events.iter().filter(|(h, _, _, _)| (*h - *z).abs() < tol).map(|(_, _, s, f)| (*f, s)).unzip();
|
|
below.section.mark_far(&caps, |w| stations.top_edge(index.get(), w, &faces, *z, tol), 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)], true)
|
|
}
|
|
|
|
/// [`prism_boolean`] of stepped prisms, the operands' indexes given. A union is taken of
|
|
/// operands that end at a common level when `common_cap` is set, else of those that don't (see
|
|
/// [`prism_union_between_caps`]).
|
|
fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], common_cap: bool) -> 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 dirs = directions(a, b)?;
|
|
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().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));
|
|
let shared = (ea.0 - eb.0).abs() < tol || (ea.1 - eb.1).abs() < tol;
|
|
match op {
|
|
BoolOp::Union if shared != common_cap => 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 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>();
|
|
// Whether some slab's section came out other than the target's (else the tool missed it).
|
|
let mut changed = false;
|
|
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));
|
|
let before = area(&x.regions);
|
|
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)?,
|
|
};
|
|
changed |= !same_area(area(&s.regions), before, 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));
|
|
}
|
|
// A cut that takes nothing leaves the target as it is: the B-rep boolean reuses it itself,
|
|
// where this would rebuild it.
|
|
if op == BoolOp::Cut && !changed {
|
|
return None;
|
|
}
|
|
let history = lineage::Context::new(op, [a, b], [ia.get(), ib.get()], plane, tol, 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))
|
|
}
|
|
|
|
thread_local! {
|
|
/// Set while [`prism_boolean_free_form`] runs: spline outlines are read as arcs within
|
|
/// [`PLANAR_FIT_TOL`] instead of refusing the prism.
|
|
static FREE_FORM: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
|
}
|
|
|
|
/// [`prism_boolean`] for prisms whose walls are splines (swept along one): the outlines are
|
|
/// fitted by arcs within [`PLANAR_FIT_TOL`], so the result is exact to that, and the walls it
|
|
/// keeps are those arcs. Only for bodies the caller knows are such sweeps.
|
|
pub(crate) fn prism_boolean_free_form(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
|
/// Clears the flag however the boolean ends, a caught panic included.
|
|
struct Reset;
|
|
impl Drop for Reset {
|
|
fn drop(&mut self) {
|
|
FREE_FORM.with(|c| c.set(false));
|
|
}
|
|
}
|
|
FREE_FORM.with(|c| c.set(true));
|
|
let _reset = Reset;
|
|
prism_boolean(a, b, op)
|
|
}
|
|
|
|
/// 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, true)))).ok().flatten()
|
|
}
|
|
|
|
/// [`prism_boolean`] of a union of stepped prisms along one direction that end at different
|
|
/// levels (a barrel joined between two faces of a wider part, its ends flush with them), which
|
|
/// that route leaves to the B-rep boolean: for when that boolean has failed. `None` for other
|
|
/// operations and operands.
|
|
pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
|
if op != BoolOp::Union {
|
|
return None;
|
|
}
|
|
let index = [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)];
|
|
crate::guard("prism boolean", || Ok(stack_indexed(a, b, op, index, false))).ok().flatten()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod planar_tests {
|
|
use super::*;
|
|
#[test]
|
|
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
|
|
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
|
|
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
|
for epsilon in [-4e-14, 4e-14] {
|
|
let b = Region2 {
|
|
outer: Loop2 {
|
|
segs: vec![
|
|
line((1.15, epsilon), (-1.15, -epsilon)),
|
|
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
|
|
line((-1.35, -0.2), (-2.4, -0.2)),
|
|
line((-2.4, -0.2), (-2.4, -1.)),
|
|
line((-2.4, -1.), (2.4, -1.)),
|
|
line((2.4, -1.), (2.4, -0.2)),
|
|
line((2.4, -0.2), (1.35, -0.2)),
|
|
Seg2::Arc {
|
|
center: Vec2::new(1.35, -epsilon),
|
|
radius: 0.2,
|
|
start: -std::f64::consts::FRAC_PI_2,
|
|
sweep: -std::f64::consts::FRAC_PI_2,
|
|
},
|
|
],
|
|
},
|
|
holes: vec![],
|
|
};
|
|
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
|
|
assert_eq!(result.len(), 1);
|
|
assert!(result[0].outer.is_closed(1e-4));
|
|
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
|
|
}
|
|
}
|
|
#[test]
|
|
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
|
|
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
|
|
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
|
|
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
|
|
}
|
|
for start in [-7., -0.5, 3., 7.] {
|
|
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
|
|
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
|
|
for index in 0..128 {
|
|
let angle = index as f64 * std::f64::consts::TAU / 128.;
|
|
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
|
|
let nearest = arc.point_at(closest_param(&arc, point));
|
|
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
|
|
for sample in 0..=128 {
|
|
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
|
|
}
|
|
}
|
|
for t in [0., 0.25, 0.5, 0.75, 1.] {
|
|
let point = arc.point_at(t);
|
|
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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::new(BoolOp::Cut, [&operand, &operand], [index.get(), index.get()], Plane::XY, 1e-7, 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 far_edges_of_a_stepped_slab_come_from_the_operand_topology() {
|
|
// A block with a notch in its back, read along -y: the slab from the notch floor to the
|
|
// front has three segments beside the top face, read off the back and the notch floor.
|
|
// The top face ends at the front in one edge: the far edge of all three, found with no
|
|
// position compared (no pair budget).
|
|
let notched = Loop2::polygon(&[
|
|
Vec2::new(0., 0.),
|
|
Vec2::new(40., 0.),
|
|
Vec2::new(40., 30.),
|
|
Vec2::new(25., 30.),
|
|
Vec2::new(25., 25.),
|
|
Vec2::new(15., 25.),
|
|
Vec2::new(15., 30.),
|
|
Vec2::new(0., 30.),
|
|
]);
|
|
let block = crate::extrude(&Plane::named("XY").unwrap(), &[Region2 { outer: notched, holes: vec![] }], 0., 20.).unwrap().pop().unwrap();
|
|
let (d, tol) = (-Vec3::Y, 1e-6);
|
|
let plane = Plane::from_normal(Vec3::ZERO, d).unwrap();
|
|
let normal = |f: &mt::Face| crate::blend::plane_normal(f).unwrap_or(Vec3::ZERO);
|
|
let faces: Vec<(usize, &mt::Face)> = block.solid.face_iter().enumerate().collect();
|
|
let find = |n: Vec3| faces.iter().find(|(_, f)| normal(f).dot(n) > 1. - 1e-9).map(|(i, _)| *i).unwrap();
|
|
let (top, front) = (find(Vec3::Z), find(-Vec3::Y));
|
|
let walls = faces.iter().filter(|(_, f)| normal(f).dot(d).abs() < 1e-9).map(|(i, f)| (*i, top_of(f, &plane))).collect();
|
|
let index = Index::new(&block, 0);
|
|
let stations = Stations::new(&index, walls, d, &plane, tol);
|
|
let cap = Section::from_loops(index.loops(faces[front].1, front, &plane, tol, &mut 1000, &mut 1000).unwrap()).unwrap().oriented();
|
|
let edge = match index.between.get(&(top.min(front), top.max(front))).map(Vec::as_slice) {
|
|
Some([e]) => *e,
|
|
other => panic!("one edge between the top and the front: {other:?}"),
|
|
};
|
|
let mut slab = stack_of(&block, d, &plane, tol).unwrap().pop().unwrap();
|
|
assert!((slab.lo + 25.).abs() < tol && slab.hi.abs() < tol);
|
|
let beside_top = |s: &Section| s.segments().1.into_iter().filter(|t| t.wall == Some(top)).map(|t| t.far).collect::<Vec<_>>();
|
|
assert_eq!(beside_top(&slab.section), vec![Some(edge); 3], "read as a stack");
|
|
slab.section.retag(|t| Tag { far: None, ..t });
|
|
slab.section.mark_far(&[&cap], |w| stations.top_edge(&index, w, &[front], 0., tol), tol, &mut 0);
|
|
assert_eq!(beside_top(&slab.section), vec![Some(edge); 3], "by topology alone");
|
|
}
|
|
#[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());
|
|
}
|
|
|
|
/// An ellipse about `c` with semi-axes `a` (along x) and `b`, as four exact conics.
|
|
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
|
|
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
|
|
let w = std::f64::consts::FRAC_1_SQRT_2;
|
|
Loop2 {
|
|
segs: (0..4).map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w }).collect(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn free_form_cut_outs_stay_exact_when_whole_and_follow_arcs_when_cut() {
|
|
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
|
|
let hole = Region2 { outer: ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0), holes: vec![] };
|
|
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Cut, 1e-7).unwrap();
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].holes.len(), 1);
|
|
// The whole ellipse comes back as its four exact conics, running as a hole (clockwise):
|
|
// the plate less π·15·8.
|
|
let h = &out[0].holes[0];
|
|
assert_eq!(h.segs.len(), 4);
|
|
assert!(h.segs.iter().all(|s| hole.outer.segs.contains(&s.reversed())), "{h:?}");
|
|
assert!(h.signed_area() < 0.0);
|
|
let exact = 6000.0 - std::f64::consts::PI * 15.0 * 8.0;
|
|
assert!((out[0].area() - exact).abs() < 1e-9, "{} vs {exact}", out[0].area());
|
|
// What the cut takes is the ellipse, whole and exact, counter-clockwise.
|
|
let taken = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Intersect, 1e-7).unwrap();
|
|
assert!(taken.len() == 1 && taken[0].outer.segs.iter().all(|s| hole.outer.segs.contains(s)), "{taken:?}");
|
|
// An ellipse centred on the plate's edge makes a notch of half the ellipse: its part
|
|
// inside follows arcs within the fit tolerance (times half the perimeter, 37 mm).
|
|
let over = Region2 { outer: ellipse(Vec2::new(100.0, 30.0), 15.0, 8.0), holes: vec![] };
|
|
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&over), BoolOp::Cut, 1e-7).unwrap();
|
|
assert_eq!(out.len(), 1);
|
|
assert!(out[0].holes.is_empty() && out[0].outer.is_line_arc());
|
|
let notched = 6000.0 - std::f64::consts::PI * 15.0 * 8.0 / 2.0;
|
|
assert!((out[0].area() - notched).abs() < PLANAR_FIT_TOL * 37.0, "{} vs {notched}", out[0].area());
|
|
// Two ellipses that overlap join into one outline of arcs; apart, both stay exact.
|
|
let beside = |dx: f64| Region2 { outer: ellipse(Vec2::new(40.0 + dx, 30.0), 15.0, 8.0), holes: vec![] };
|
|
let joined = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(20.0)], BoolOp::Union, 1e-7).unwrap();
|
|
assert!(joined.len() == 1 && joined[0].outer.is_line_arc(), "{joined:?}");
|
|
let apart = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(40.0)], BoolOp::Union, 1e-7).unwrap();
|
|
assert!(apart.len() == 2 && apart.iter().all(|r| r.outer.segs.len() == 4 && !r.outer.is_line_arc()), "{apart:?}");
|
|
// The flat material extrudes with the exact elliptic wall: four conic faces, two caps.
|
|
let body = extrude_planar_regions(&Plane::XY, &planar_regions_boolean(&[plate], &[hole], BoolOp::Cut, 1e-7).unwrap(), 0.0, 2.0).unwrap();
|
|
assert_eq!(body.face_count(), 4 + 4 + 2);
|
|
let v = crate::measure(&body).unwrap().volume;
|
|
assert!((v - exact * 2.0).abs() < 1e-3 * exact, "{v}");
|
|
}
|
|
|
|
#[test]
|
|
fn free_form_loops_that_cannot_be_followed_are_refused() {
|
|
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
|
|
let mut bad = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
|
|
bad.segs[1] = Seg2::Conic { a: bad.segs[1].start(), apex: Vec2::new(f64::NAN, 0.0), b: bad.segs[1].end(), w: 1.0 };
|
|
let e = planar_regions_boolean(std::slice::from_ref(&plate), &[Region2 { outer: bad, holes: vec![] }], BoolOp::Cut, 1e-7).unwrap_err();
|
|
assert!(e.to_string().contains("free-form"), "{e}");
|
|
// More fitted arcs than the arrangement's segment budget.
|
|
let many: Vec<Region2> = (0..400).map(|i| Region2 { outer: ellipse(Vec2::new(40.0 * i as f64, 0.0), 15.0, 8.0), holes: vec![] }).collect();
|
|
assert!(planar_regions_boolean(&[plate], &many, BoolOp::Cut, 1e-7).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn segment_boxes_hold_what_lies_within_the_tolerance() {
|
|
// An arc of radius 10 about the origin from 45° to 135° passes the top of its circle:
|
|
// x within ±10/√2, y from 10/√2 to 10, grown by twice the tolerance.
|
|
let r = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
|
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: std::f64::consts::FRAC_PI_4, sweep: std::f64::consts::FRAC_PI_2 };
|
|
for s in [arc, arc.reversed()] {
|
|
let (lo, hi) = seg_box(&s, 1e-3).unwrap();
|
|
assert!(lo.dist(Vec2::new(-r - 2e-3, r - 2e-3)) < 1e-9 && hi.dist(Vec2::new(r + 2e-3, 10.0 + 2e-3)) < 1e-9, "{lo:?} {hi:?}");
|
|
}
|
|
let (lo, hi) = seg_box(&Seg2::Line { a: Vec2::new(3.0, -1.0), b: Vec2::new(-2.0, 4.0) }, 0.5).unwrap();
|
|
assert!(lo.dist(Vec2::new(-3.0, -2.0)) < 1e-9 && hi.dist(Vec2::new(4.0, 5.0)) < 1e-9, "{lo:?} {hi:?}");
|
|
// A line 3 tolerances above the arc's top meets its box (each is grown by two); one
|
|
// 5 tolerances above does not. No box (a free-form segment, a value that is not
|
|
// finite) hides nothing.
|
|
let level = |y: f64| seg_box(&Seg2::Line { a: Vec2::new(-1.0, y), b: Vec2::new(1.0, y) }, 1e-3);
|
|
assert!(boxes_meet(seg_box(&arc, 1e-3), level(10.003)) && !boxes_meet(seg_box(&arc, 1e-3), level(10.005)));
|
|
let free = ellipse(Vec2::ZERO, 2.0, 1.0).segs[0];
|
|
assert!(seg_box(&free, 1e-3).is_none() && seg_box(&Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }, 1e-3).is_none());
|
|
assert!(boxes_meet(None, level(500.0)) && box_holds(None, Vec2::new(500.0, 500.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn many_small_cut_outs_far_apart_cut_as_before() {
|
|
// 400 round holes in a strip: almost every pair of segments is far apart.
|
|
let plate = rectangle(Vec2::ZERO, Vec2::new(2000.0, 20.0));
|
|
let holes: Vec<Region2> =
|
|
(0..400).map(|i| Region2 { outer: Loop2::circle(Vec2::new(2.5 + 5.0 * i as f64, 10.0), 1.8), holes: vec![] }).collect();
|
|
let out = planar_regions_boolean(&[plate], &holes, BoolOp::Cut, 1e-7).unwrap();
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].holes.len(), 400);
|
|
let exact = 40_000.0 - 400.0 * std::f64::consts::PI * 1.8 * 1.8;
|
|
assert!((out[0].area() - exact).abs() < 1e-6, "{}", out[0].area());
|
|
}
|
|
|
|
#[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)));
|
|
}
|
|
// #307: a circle read back as two halves whose centres disagree by a few ulps, split
|
|
// at its bottom. A ray grazing that bottom must not count one half's touch only.
|
|
let halves = Loop2 {
|
|
segs: vec![
|
|
Seg2::Arc { center: Vec2::new(0.0, 25.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
|
Seg2::Arc {
|
|
center: Vec2::new(2.9e-15, 25.000000000000004),
|
|
radius: 4.99999999999999,
|
|
start: std::f64::consts::FRAC_PI_2,
|
|
sweep: std::f64::consts::PI,
|
|
},
|
|
],
|
|
};
|
|
for lp in [halves.clone(), halves.reversed()] {
|
|
for x in [-30.0, -5.0, -0.1] {
|
|
assert!(!inside_loop(&lp, Vec2::new(x, 20.0)), "{x}");
|
|
assert!(!inside_loop(&lp, Vec2::new(x, 30.0)), "{x}");
|
|
}
|
|
assert!(inside_loop(&lp, Vec2::new(0.0, 25.0)));
|
|
}
|
|
}
|
|
|
|
/// Loops are closed only within the chaining tolerance. A ray level with one end of such a
|
|
/// gap slipped through it without a crossing, so a point beside the loop counted as inside:
|
|
/// a sheet shrunk by a parameter change (sketch solver residuals of 1e-13 mm) lost a bend
|
|
/// zone that way and its unfold failed.
|
|
#[test]
|
|
fn ray_parity_closes_the_gaps_chaining_leaves() {
|
|
let gap = 1e-13;
|
|
let square = Loop2 {
|
|
segs: vec![
|
|
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) },
|
|
Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 10.0) },
|
|
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0) },
|
|
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, 5.0 + gap) },
|
|
Seg2::Line { a: Vec2::new(0.0, 5.0), b: Vec2::new(0.0, 0.0) },
|
|
],
|
|
};
|
|
for lp in [square.clone(), square.reversed()] {
|
|
assert!(!inside_loop(&lp, Vec2::new(-5.0, 5.0)));
|
|
assert!(inside_loop(&lp, Vec2::new(5.0, 5.0)));
|
|
assert!(!inside_loop(&lp, Vec2::new(15.0, 5.0)));
|
|
}
|
|
// A neighbour with an edge level with the gap joins the square instead of being
|
|
// dropped as if it lay inside it (which left the outline open: no result at all).
|
|
let a = Region2 { outer: square, holes: vec![] };
|
|
let b = rectangle(Vec2::new(-4.0, 2.0), Vec2::new(0.0, 5.0));
|
|
let union = planar_regions_boolean(&[a], &[b], BoolOp::Union, 1e-7).unwrap();
|
|
assert_eq!(union.len(), 1);
|
|
assert!((union[0].area().abs() - 112.0).abs() < 1e-9, "{}", union[0].area());
|
|
}
|
|
/// Two edges lying on one another within the tolerance, a hair from parallel, meet at a
|
|
/// point somewhere along their overlap. Taken for a crossing, that point split the square's
|
|
/// edge once more: 5e-8 mm before its end, which left an edge shorter than the tolerance in
|
|
/// the result, or in the middle of a straight edge. The ends of an overlap are what split
|
|
/// the edges.
|
|
#[test]
|
|
fn overlapping_lines_a_hair_from_parallel_do_not_cross() {
|
|
let square = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0));
|
|
// A quadrilateral from x = 4 to x = 14 with one edge along the square's bottom edge,
|
|
// tilted by 1e-9 rad about (meet, 0), and the opposite edge at y = far.
|
|
let quad = |meet: f64, far: f64| {
|
|
let on = |x: f64| Vec2::new(x, (meet - x) * 1e-9);
|
|
let mut corners = [on(4.0), on(14.0), Vec2::new(14.0, far), Vec2::new(4.0, far)];
|
|
if far < 0.0 {
|
|
corners.reverse();
|
|
}
|
|
Region2 { outer: Loop2::polygon(&corners), holes: vec![] }
|
|
};
|
|
let lengths = |r: &Region2| {
|
|
let mut all: Vec<f64> = r.outer.segs.iter().map(Seg2::length).collect();
|
|
all.sort_by(f64::total_cmp);
|
|
all
|
|
};
|
|
let same = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6);
|
|
for meet in [10.0 - 5e-8, 7.0] {
|
|
// Below the square, against its bottom edge: cut away, it leaves the square, the
|
|
// bottom edge split only at x = 4, where the quadrilateral's corner lies on it.
|
|
let below = quad(meet, -5.0);
|
|
let cut = planar_regions_boolean(std::slice::from_ref(&square), std::slice::from_ref(&below), BoolOp::Cut, 1e-7).unwrap();
|
|
assert_eq!(cut.len(), 1, "{meet}");
|
|
assert!(same(&lengths(&cut[0]), &[4.0, 6.0, 10.0, 10.0, 10.0]), "{meet}: cut edges {:?}", lengths(&cut[0]));
|
|
assert!((cut[0].area() - 100.0).abs() < 1e-6, "{meet}: {}", cut[0].area());
|
|
// Over the square, flush with that edge and reaching past it to x = 14: the
|
|
// union's outline runs along y = 0 once, in the pieces 0..4, 4..10 and 10..14.
|
|
let flush = quad(meet, 5.0);
|
|
let union = planar_regions_boolean(std::slice::from_ref(&square), std::slice::from_ref(&flush), BoolOp::Union, 1e-7)
|
|
.unwrap_or_else(|e| panic!("{meet}: union {e}"));
|
|
assert_eq!(union.len(), 1, "{meet}");
|
|
assert!(same(&lengths(&union[0]), &[4.0, 4.0, 4.0, 5.0, 5.0, 6.0, 10.0, 10.0]), "{meet}: union edges {:?}", lengths(&union[0]));
|
|
assert!((union[0].area() - 120.0).abs() < 1e-6, "{meet}: {}", union[0].area());
|
|
// The two edges themselves, either way round.
|
|
let (edge, tilted) = (square.outer.segs[0], flush.outer.segs[0]);
|
|
assert!(crossings(&edge, &tilted, 1e-7).is_empty() && crossings(&tilted, &edge, 1e-7).is_empty(), "{meet}");
|
|
}
|
|
// Lines further apart than the tolerance at their ends still cross: tilted by 1e-3 rad
|
|
// about (7, 0), the edge from (4, 3e-3) to (14, -7e-3) meets the bottom edge at x = 7.
|
|
let (edge, steep) = (square.outer.segs[0], Seg2::Line { a: Vec2::new(4.0, 3e-3), b: Vec2::new(14.0, -7e-3) });
|
|
let hits = crossings(&edge, &steep, 1e-7);
|
|
assert!(hits.len() == 1 && (hits[0].0 - 0.7).abs() < 1e-12 && (hits[0].1 - 0.3).abs() < 1e-12, "{hits:?}");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
/// A 200 x 37 plate turned by `theta` about the origin, and the fillet region of one of its
|
|
/// corners: an arc of radius `r` tangent to both edges there, closed by two lines that meet
|
|
/// `reach` out past the corner.
|
|
fn plate_and_corner_fillet(theta: f64, r: f64, reach: f64) -> (Region2, Region2) {
|
|
let (sn, cs) = theta.sin_cos();
|
|
let rot = |x: f64, y: f64| Vec2::new(x * cs - y * sn, x * sn + y * cs);
|
|
let (w, lo, hi) = (200.0, -40.25, -3.25);
|
|
let plate = Region2 { outer: Loop2::polygon(&[rot(0.0, hi), rot(0.0, lo), rot(w, lo), rot(w, hi)]), holes: vec![] };
|
|
let arc = Seg2::Arc { center: rot(w - r, lo + r), radius: r, start: theta, sweep: -std::f64::consts::FRAC_PI_2 };
|
|
let out = rot(w + reach, lo - reach);
|
|
let cut =
|
|
Region2 { outer: Loop2 { segs: vec![arc, Seg2::Line { a: arc.end(), b: out }, Seg2::Line { a: out, b: arc.start() }] }, holes: vec![] };
|
|
(plate, cut)
|
|
}
|
|
|
|
/// A contact between a line and an arc that touch is one point. The quadratic's two roots
|
|
/// there are rounding noise: a 1e-12 mm error in the centre's distance from the line moves
|
|
/// them 2e-6 mm either way along it, and the cut then ran that far along the plate's edge
|
|
/// past the arc's end before turning onto the arc: an edge a few millionths of a millimetre
|
|
/// long in the result. Which turns of the plate do that is a matter of rounding, so every
|
|
/// one is tried.
|
|
#[test]
|
|
fn an_arc_tangent_to_the_edges_it_ends_on_leaves_no_hairline_edges() {
|
|
for k in 0..=360 {
|
|
let theta = (k as f64 * 0.25).to_radians();
|
|
for r in [2.0, 0.5, 25.0] {
|
|
let (plate, cut) = plate_and_corner_fillet(theta, r, 0.4);
|
|
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&cut), BoolOp::Cut, 1e-7)
|
|
.unwrap_or_else(|e| panic!("{k} r {r}: {e}"));
|
|
assert_eq!(out.len(), 1, "{k} r {r}");
|
|
let lp = &out[0].outer;
|
|
let shortest = lp.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min);
|
|
let arcs = lp.segs.iter().filter(|s| matches!(s, Seg2::Arc { .. })).count();
|
|
assert!(
|
|
out[0].holes.is_empty() && lp.segs.len() == 5 && arcs == 1 && shortest > 0.5,
|
|
"{k} r {r}: {} edges, shortest {shortest:e}",
|
|
lp.segs.len()
|
|
);
|
|
let want = 200.0 * 37.0 - r * r * (1.0 - std::f64::consts::FRAC_PI_4);
|
|
assert!((out[0].area() - want).abs() < 1e-9, "{k} r {r}: {} vs {want}", out[0].area());
|
|
// What the cut takes is the corner's fillet region, whole.
|
|
let taken = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&cut), BoolOp::Intersect, 1e-7).unwrap();
|
|
assert!(taken.len() == 1 && taken[0].outer.segs.len() == 3, "{k} r {r}: {taken:?}");
|
|
assert!((taken[0].area() - r * r * (1.0 - std::f64::consts::FRAC_PI_4)).abs() < 1e-9, "{k} r {r}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A 55 x 30 plate whose bottom edge bends up by `slope` over its last 5 mm before the
|
|
/// corner at (100, 50), and a strip 3 wide below the straight 50 mm, as a sheet's bend zone
|
|
/// lies against its base: the strip's corners on that edge rounded to the nanometre, as a
|
|
/// flange keeps them, and moved by `off`. Both turned by `theta` about the origin.
|
|
fn plate_and_strip_by_a_bent_edge(theta: f64, slope: f64, off: Vec2) -> (Region2, Region2) {
|
|
let (sn, cs) = theta.sin_cos();
|
|
let rot = |x: f64, y: f64| Vec2::new(x * cs - y * sn, x * sn + y * cs);
|
|
let nm = |p: Vec2| Vec2::new((p.x * 1e9).round() / 1e9, (p.y * 1e9).round() / 1e9) + off;
|
|
let plate = [rot(95.0, 50.0 + 5.0 * slope), rot(100.0, 50.0), rot(150.0, 50.0), rot(150.0, 80.0), rot(95.0, 80.0)];
|
|
let strip = [nm(rot(100.0, 50.0)), rot(100.0, 47.0), rot(150.0, 47.0), nm(rot(150.0, 50.0))];
|
|
(Region2 { outer: Loop2::polygon(&plate), holes: vec![] }, Region2 { outer: Loop2::polygon(&strip), holes: vec![] })
|
|
}
|
|
|
|
/// The bent piece of the plate's edge, a hair from the strip's edge, crosses it short of
|
|
/// the corner they share: the corners a nanometre apart at most, the crossing up to a
|
|
/// hundred times that before it at a slope of 1 in 100, a thousand at 1 in 1000. A split
|
|
/// there left a sliver of edge shorter than the distance within which the loops join their
|
|
/// pieces; the chaining took it for a second way out of the corner, and a loop of that one
|
|
/// sliver is open, its area not zero away from the origin: a hole no outline held (no
|
|
/// result at all) or a region of its own. The union is the plate and the strip, whole.
|
|
#[test]
|
|
fn a_bent_edge_crossing_its_neighbour_by_their_corner_leaves_no_sliver() {
|
|
let union_is_whole = |(plate, strip): (Region2, Region2), what: &str| {
|
|
let want = plate.area() + strip.area();
|
|
for (a, b) in [(&plate, &strip), (&strip, &plate)] {
|
|
let out = planar_regions_boolean(std::slice::from_ref(a), std::slice::from_ref(b), BoolOp::Union, 1e-7)
|
|
.unwrap_or_else(|e| panic!("{what}: {e}"));
|
|
assert_eq!(out.len(), 1, "{what}: {out:?}");
|
|
let lp = &out[0].outer;
|
|
let shortest = lp.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min);
|
|
assert!(out[0].holes.is_empty() && lp.segs.len() == 7 && shortest > 2.9, "{what}: {} edges, shortest {shortest:e}", lp.segs.len());
|
|
assert!((out[0].area() - want).abs() < 1e-6, "{what}: {} vs {want}", out[0].area());
|
|
}
|
|
};
|
|
for k in 0..360 {
|
|
for slope in [0.01, 0.001] {
|
|
union_is_whole(plate_and_strip_by_a_bent_edge((k as f64 * 0.25).to_radians(), slope, Vec2::ZERO), &format!("{k} slope {slope}"));
|
|
}
|
|
}
|
|
// Corners a few nanometres apart, any way round: the sliver is then longer than the
|
|
// tolerance (1e-7 with the strip 1e-9 into the plate) but shorter than the joining
|
|
// distance (1e-6).
|
|
for k in 0..16 {
|
|
for d in [1e-9, 4e-9] {
|
|
let off = Vec2::from_angle(k as f64 * std::f64::consts::TAU / 16.0) * d;
|
|
union_is_whole(plate_and_strip_by_a_bent_edge(0.0, 0.01, off), &format!("off {off:?}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Touching circles meet at one point on the line of their centres, inside and outside.
|
|
#[test]
|
|
fn touching_lines_and_arcs_meet_at_one_point() {
|
|
let full = |c: Vec2, r: f64| Seg2::Arc { center: c, radius: r, start: 0.3, sweep: std::f64::consts::TAU - 1e-3 };
|
|
for k in 0..=360 {
|
|
let dir = Vec2::from_angle((k as f64 * 0.25).to_radians());
|
|
let c = Vec2::new(151.3, -37.7);
|
|
for (r0, r1, d) in [(2.0, 3.0, 5.0), (7.0, 3.0, 4.0), (3.0, 7.0, 4.0), (250.0, 2.0, 248.0)] {
|
|
let hits = crossings(&full(c, r0), &full(c + dir * d, r1), 1e-7);
|
|
let touch = c + dir * if r1 > r0 && d < r1 { -r0 } else { r0 };
|
|
let pts: Vec<Vec2> = hits.iter().map(|(u, _)| full(c, r0).point_at(*u)).collect();
|
|
assert!(!pts.is_empty() && pts.iter().all(|p| p.dist(touch) < 1e-9), "{k} {r0} {r1}: {pts:?} vs {touch:?}");
|
|
}
|
|
// A line touching a circle, the circle's side of it and the far side by rounding.
|
|
for r in [2.0, 250.0] {
|
|
let foot = c + dir * r;
|
|
let line = Seg2::Line { a: foot - dir.perp() * 120.0, b: foot + dir.perp() * 78.0 };
|
|
let hits = crossings(&line, &full(c, r), 1e-7);
|
|
let pts: Vec<Vec2> = hits.iter().map(|(u, _)| line.point_at(*u)).collect();
|
|
assert!(!pts.is_empty() && pts.iter().all(|p| p.dist(foot) < 1e-9), "{k} {r}: {pts:?} vs {foot:?}");
|
|
}
|
|
}
|
|
// Real crossings stay two points: a chord 0.01 mm deep in a 2 mm circle.
|
|
let line = Seg2::Line { a: Vec2::new(-5.0, 1.99), b: Vec2::new(5.0, 1.99) };
|
|
let hits = crossings(&line, &full(Vec2::ZERO, 2.0), 1e-7);
|
|
let half = (4.0f64 - 1.99 * 1.99).sqrt();
|
|
assert_eq!(hits.len(), 2);
|
|
assert!(hits.iter().all(|(u, _)| (line.point_at(*u).x.abs() - half).abs() < 1e-12), "{hits:?}");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|