Files
solvecraft/crates/kernel/src/prism.rs
T
jyviko 562a5bd337 Formatting: rustfmt over the rebased branch
cargo fmt --all, nothing else: code from the branch's commits and from the rebase's
resolutions that was not yet in rustfmt's layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoZgkCdAVrscuw2kjQTWDs
2026-10-27 02:28:53 -04:00

612 lines
35 KiB
Rust

//! Booleans of planar regions (lines, circular arcs and free-form curves) and the extrusion of
//! planar material from them, both on SMLib's 2D boolean: each operand a sheet of planar faces
//! built from its loops' own curves, combined as regions of the plane, its faces read back. The
//! result's boundaries are the operands' curves, cut at their crossings and never refitted.
//! Also the test of whether a record surface is a wall swept along a direction.
use solidcraft_geom::{Plane, Region2, Seg2, Vec2};
use crate::body::Body;
use crate::{BoolOp, KernelError, Result};
const MAX_PLANAR_SEGMENTS: usize = 8192;
/// How far a free-form boundary may be replaced by lines and arcs where a caller needs lines and
/// arcs only (sheet metal's flat patterns, mm). The booleans here keep free-form curves exact.
pub const PLANAR_FIT_TOL: f64 = 1e-3;
/// Boolean of planar regions bounded by straight lines, circular arcs and free-form segments,
/// exact: every boundary of the result is a piece of a boundary of `a` or `b`, cut where it
/// crosses the other (SMLib's 2D boolean). Disjoint results and retained islands are returned as
/// separate regions. `tolerance` (mm, 1e-9 to 1) is how far apart a loop's segments may end and
/// still close it.
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()));
}
check_regions(a.iter().chain(b), tolerance)?;
match (a.is_empty(), b.is_empty(), op) {
(true, _, BoolOp::Union) => return Ok(b.to_vec()),
(true, _, _) | (false, true, BoolOp::Intersect) => return Ok(Vec::new()),
(false, true, _) => return Ok(a.to_vec()),
_ => {}
}
let r = sheets::combine(&sheets::sheet(a, tolerance)?, &sheets::sheet(b, tolerance)?, op)?;
Ok(match r {
Some(sheet) => sheets::regions(&sheet, tolerance)?.regions,
None => Vec::new(),
})
})
}
/// Each loop closed within ten times `tol` and within the segment budget, each segment finite
/// and of some length (a line's ends apart, an arc of a positive radius turning at most once).
fn check_regions<'a>(regions: impl Iterator<Item = &'a Region2>, tol: f64) -> Result<()> {
let mut edges = 0usize;
for lp in regions.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tol * 10.0) {
return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into()));
}
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
for s in &lp.segs {
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
}
Seg2::Cubic { p0, p1, p2, p3 } => [p0, p1, p2, p3].into_iter().all(finite) && p0.dist(p3) > 1e-12,
Seg2::Conic { a, apex, b, w } => [a, apex, b].into_iter().all(finite) && w.is_finite() && w > 1e-9 && a.dist(b) > 1e-12,
Seg2::Bezier { n, ref p } => {
p.get(..=usize::from(n)).is_some_and(|p| p.iter().copied().all(finite)) && s.start().dist(s.end()) > 1e-12
}
};
if !valid {
let what = if s.is_line_or_arc() { "lines and circular arcs" } else { "free-form segments" };
return Err(KernelError::Invalid(format!("planar boolean supports finite nondegenerate {what}")));
}
}
}
Ok(())
}
/// Extrude normalized planar material as one logical compound, retaining every disjoint
/// region and its holes. The regions are united in the plane first (overlapping ones merge),
/// and pieces of the union that touch each other are refused: apart,
/// each is extruded on its own and the solids joined as one body.
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
crate::guard("planar material extrusion", || {
check_regions(regions.iter(), 1e-7)?;
if regions.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
// One sheet of all the regions: their material united, so overlapping ones merge.
let union = sheets::sheet(regions, 1e-7)?;
let united = sheets::regions(&union, 1e-7)?;
if united.touching {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
}
if united.regions.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
let pieces = crate::extrude(plane, &united.regions, start, end)?;
crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into()))
})
}
/// The regions of a planar sheet, and whether two of its faces touch (share a vertex).
struct Regions {
regions: Vec<Region2>,
touching: bool,
}
#[cfg(feature = "smlib")]
mod sheets {
use solidcraft_geom::Loop2;
use solidcraft_geom::Vec3;
use solidcraft_geom::brep::Loop;
use solidcraft_geom::brep::Surface;
use solidcraft_smlib::{BooleanOp, Brep};
use super::*;
use crate::prism_curve::{Run, projected};
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib planar regions: {what}: {e}"))
}
/// `regions` (in the plane's own coordinates) as one sheet on z = 0. SMLib's face builder
/// refuses curves of which more than two meet at one end point (`CreatePlanarFacesWith3DCurves`,
/// SmBrep.cpp: "spine points"), as where pieces of material touch at a corner or an outline
/// passes a point twice. So each loop, oriented (outer counter-clockwise, holes clockwise), is
/// split where it passes a point twice into simple loops, and each simple loop is a face of its
/// own; those running counter-clockwise are material, the others holes. The sheet is the
/// material united less the holes united (SMLib's 2D boolean, which takes touching faces).
/// `tol` (mm): how close a loop's passes through one point are.
pub(super) fn sheet(regions: &[Region2], tol: f64) -> Result<Brep> {
let (mut material, mut holes) = (Vec::new(), Vec::new());
for r in regions {
let loops = std::iter::once(r.outer.ccw()).chain(r.holes.iter().map(|h| h.ccw().reversed()));
for piece in loops.flat_map(|lp| simple_loops(lp, tol * 10.0)) {
let face = face(&piece)?;
if piece.signed_area() > 0.0 { material.push(face) } else { holes.push(face) }
}
}
let material = united(material)?.ok_or_else(|| failed("sheet", "the regions hold no material"))?;
match united(holes)? {
Some(holes) => combine(&material, &holes, BoolOp::Cut)?.ok_or_else(|| failed("sheet", "the holes take all the material")),
None => Ok(material),
}
}
/// A simple closed loop as one planar face.
fn face(lp: &Loop2) -> Result<Brep> {
let curves = lp.segs.iter().map(|s| crate::smlib_sweeps::curve(&Plane::XY, s)).collect::<Result<Vec<_>>>()?;
Brep::planar_faces(&curves).map_err(|e| failed("sheet", e))
}
/// `sheets` united in balanced rounds (each union of two sheets of about the same size);
/// `None` for none.
fn united(mut sheets: Vec<Brep>) -> Result<Option<Brep>> {
while sheets.len() > 1 {
let mut next = Vec::with_capacity(sheets.len().div_ceil(2));
let mut iter = sheets.into_iter();
while let Some(a) = iter.next() {
next.push(match iter.next() {
Some(b) => combine(&a, &b, BoolOp::Union)?.ok_or_else(|| failed("sheet", "a union of faces is empty"))?,
None => a,
});
}
sheets = next;
}
Ok(sheets.pop())
}
/// A closed loop split where it passes a point twice (its segments' starts within `near`)
/// into loops that pass every point once.
fn simple_loops(lp: Loop2, near: f64) -> Vec<Loop2> {
let mut todo = vec![lp.segs];
let mut out = Vec::new();
// Each split leaves two shorter loops, so the work is bounded by the segment count.
while let Some(segs) = todo.pop() {
let starts: Vec<Vec2> = segs.iter().map(Seg2::start).collect();
let repeat = (0..starts.len()).find_map(|i| (i + 1..starts.len()).find(|&j| starts[i].dist(starts[j]) < near).map(|j| (i, j)));
match repeat {
Some((i, j)) => {
let (inner, rest) = (segs[i..j].to_vec(), [&segs[j..], &segs[..i]].concat());
todo.push(inner);
todo.push(rest);
}
None => out.push(Loop2 { segs }),
}
}
out
}
/// `a` and `b` combined by `op`; `None` when nothing is left.
pub(super) fn combine(a: &Brep, b: &Brep, op: BoolOp) -> Result<Option<Brep>> {
let op = match op {
BoolOp::Union => BooleanOp::Union,
BoolOp::Cut => BooleanOp::Subtract,
BoolOp::Intersect => BooleanOp::Intersect,
};
a.boolean2d(b, op).map_err(|e| failed("2D boolean", e))
}
/// The faces of `sheet` as regions: each face's loops read off its edges exactly
/// ([`projected`]), the one running counter-clockwise about the face's normal its outer loop,
/// turned to run counter-clockwise in the plane and its holes clockwise. `tol` (mm, at least
/// the record's) is how far an edge's curve may end from its vertices.
pub(super) fn regions(sheet: &Brep, tol: f64) -> Result<Regions> {
let record = sheet.to_native().map_err(|e| failed("export", e))?;
let tol = tol.max(record.tolerance);
let mut work = 2_000_000usize;
let mut owner: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
let mut touching = false;
let mut out = Vec::with_capacity(record.faces.len());
for (f, face) in record.faces.iter().enumerate() {
let up = natural_normal(&face.surface).ok_or_else(|| failed("face", format!("face {f} is not on a plane")))?.z;
if up.abs() < 1.0 - 1e-9 {
return Err(failed("face", format!("face {f} is not in the plane of the regions")));
}
// Each loop as it runs in the plane, then split where it passes a point twice (a
// cut-out touching the outline, material meeting at a corner): counter-clockwise
// pieces are material, the others holes in the material that holds them.
let mut pieces = Vec::new();
for lp in &face.loops {
let Loop::Edges { edges } = lp else { return Err(failed("face", format!("face {f} has a vertex loop"))) };
let mut segs = Vec::new();
for e in edges {
let run = Run::of(&record, e.edge as usize, e.forward).ok_or_else(|| failed("edge", format!("edge {} is missing", e.edge)))?;
let read = projected(&run, &Plane::XY, tol, &mut work)
.ok_or_else(|| failed("edge", format!("edge {} of face {f} is not read exactly in the plane", e.edge)))?;
segs.extend(read);
}
for e in edges.iter().filter_map(|e| record.edges.get(e.edge as usize)) {
for v in e.vertices {
touching |= *owner.entry(v).or_insert(f) != f;
}
}
// Seen from the face's normal, the outer loop runs counter-clockwise and holes clockwise.
let lp = if up > 0.0 { Loop2 { segs } } else { Loop2 { segs }.reversed() };
pieces.extend(simple_loops(lp, tol * 10.0));
}
let (outers, holes): (Vec<Loop2>, Vec<Loop2>) = pieces.into_iter().partition(|lp| lp.signed_area() > 0.0);
let first = out.len();
out.extend(outers.into_iter().map(|outer| Region2 { outer, holes: Vec::new() }));
for hole in holes {
// The smallest material piece of this face holding the hole's middle.
let mid = hole.segs.first().map(|s| s.point_at(0.5)).ok_or_else(|| failed("face", format!("face {f} has an empty loop")))?;
let owner = out[first..]
.iter_mut()
.filter(|r| r.outer.contains(mid))
.min_by(|a, b| a.outer.signed_area().total_cmp(&b.outer.signed_area()))
.ok_or_else(|| failed("face", format!("face {f} has a hole outside its material")))?;
owner.holes.push(hole);
}
}
Ok(Regions { regions: out, touching })
}
/// The natural normal of a planar surface record.
fn natural_normal(s: &Surface) -> Option<Vec3> {
let v = |p: [f64; 3]| Vec3::new(p[0], p[1], p[2]);
match s {
Surface::Plane { u_axis, v_axis, .. } => v(*u_axis).cross(v(*v_axis)).normalized(),
Surface::PlaneThrough { origin, u_point, v_point } => (v(*u_point) - v(*origin)).cross(v(*v_point) - v(*origin)).normalized(),
_ => None,
}
}
}
#[cfg(not(feature = "smlib"))]
mod sheets {
use super::*;
fn refused<T>() -> Result<T> {
Err(KernelError::Failed("not supported yet: planar region booleans run on SMLib (build with the feature `smlib`)".into()))
}
/// A sheet stands for nothing without SMLib.
pub(super) enum Sheet {}
pub(super) fn sheet(_: &[Region2], _: f64) -> Result<Sheet> {
refused()
}
pub(super) fn combine(a: &Sheet, _: &Sheet, _: BoolOp) -> Result<Option<Sheet>> {
match *a {}
}
pub(super) fn regions(sheet: &Sheet, _: f64) -> Result<Regions> {
match *sheet {}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod planar_tests {
use super::*;
use solidcraft_geom::Loop2;
#[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);
}
}
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 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.shell_count(), 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_whole_or_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 is the ellipse's own conics, cut where they cross the edge, so the notch takes
// exactly half the ellipse.
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() < 1e-6, "{} vs {notched}", out[0].area());
// Two ellipses that overlap join into one outline of their conics; apart, both stay whole.
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.segs.iter().all(|s| !s.is_line_or_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_are_not_finite_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}");
}
#[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());
}
/// 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) },
],
};
// 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());
}
}
#[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:?}"));
}
}
}
#[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());
}
}