//! Booleans of prisms in 2D: when the target is a prism (two caps square to one direction, //! walls of planes and cylinders along it) and the tool a prism along the same direction //! spanning the target's height, the result is the prism over the 2D boolean of their //! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh //! intersection's trouble with walls meeting along lines. //! Stepped extrusions (case floors and walls) use sections between every cap level, so a //! circular boss can meet the rim without shifting either operand off its exact plane. use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, from_p3}; use crate::{BoolOp, KernelError, Result}; const MAX_PLANAR_SEGMENTS: usize = 8192; const MAX_PLANAR_EVENTS: usize = 131_072; const MAX_PLANAR_PAIRS: usize = 16_000_000; /// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay /// analytic; disjoint results and retained islands are returned as separate regions. pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result> { crate::guard("planar region boolean", || { if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) { return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into())); } let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)); let mut edges = 0usize; let mut samples = 0usize; for lp in loops() { edges = edges.saturating_add(lp.segs.len()); if edges > MAX_PLANAR_SEGMENTS || lp.segs.is_empty() || !lp.is_closed(tolerance * 10.0) { return Err(KernelError::Invalid("planar boolean needs closed loops within its geometry budget".into())); } for s in &lp.segs { let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; let valid = match *s { Seg2::Line { a, b } => finite(a) && finite(b) && a.dist(b) > 1e-12, Seg2::Arc { center, radius, start, sweep } => { finite(center) && radius.is_finite() && radius > 0.0 && radius < 1e9 && start.is_finite() && sweep.is_finite() && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 } _ => false, }; if !valid { return Err(KernelError::Invalid("planar boolean supports finite nondegenerate lines and circular arcs".into())); } samples = samples.saturating_add(s.polyline(1e-3).len()); if samples > 65_536 { return Err(KernelError::Invalid("planar boolean sampled geometry exceeds its budget".into())); } } } boolean2d(a, b, op, tolerance) .ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into())) }) } /// Extrude normalized planar material as one logical compound, retaining every disjoint /// region and its holes. Planar arrangement certifies separation before shells are joined. pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result { 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> = regions.iter().cloned().map(|r| vec![r]).collect(); let mut remaining_pairs = 32_000_000usize; while groups.len() > 1 { let mut next = Vec::new(); let mut iter = groups.into_iter(); while let Some(a) = iter.next() { if let Some(b) = iter.next() { let count = |rs: &[Region2]| { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).fold(0usize, |n, l| n.saturating_add(l.segs.len())) }; remaining_pairs = remaining_pairs .checked_sub(count(&a).saturating_mul(count(&b))) .ok_or_else(|| KernelError::Invalid("planar extrusion arrangement exceeds its work budget".into()))?; next.push(planar_regions_boolean(&a, &b, BoolOp::Union, 1e-7)?); } else { next.push(a); } } groups = next; } let normalized = groups.pop().unwrap_or_default(); if normalized.is_empty() { return Err(KernelError::Invalid("empty planar material extrusion".into())); } for (i, a) in normalized.iter().enumerate() { for b in normalized.iter().skip(i + 1) { for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) { for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) { remaining_pairs = remaining_pairs .checked_sub(1) .ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?; let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7) || [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7); if endpoints_touch || !crossings(s, t, 1e-7).is_empty() { return Err(KernelError::Invalid("planar material pieces touch at a boundary".into())); } } } } } let pieces = crate::extrude(plane, &normalized, start, end)?; crate::ops::join_pieces(pieces)?.ok_or_else(|| KernelError::Failed("empty planar material extrusion".into())) }) } /// A prism: the plane of its bottom cap (normal along the walls), its height and section. struct Prism { lo: f64, hi: f64, regions: Vec, } /// Certify that a curved wall is swept along `d` from its surface representation. /// Seam vertices alone cannot establish this: a half-cylinder's seam normals can /// both be perpendicular to a transverse direction, silently filling its hole. fn wall_along(surface: &mt::Surface, d: Vec3, tol: f64, remaining: &mut usize) -> bool { if let mt::Surface::RevolutedCurve(processor) = surface { let revolution = processor.entity(); let mt::Curve::Line(line) = revolution.entity_curve() else { return false }; let axis = revolution.axis(); let meridian = line.1 - line.0; let local_axis = Vec3::new(axis.x, axis.y, axis.z); let local_meridian = Vec3::new(meridian.x, meridian.y, meridian.z); let (Some(a), Some(m)) = (local_axis.normalized(), local_meridian.normalized()) else { return false }; if a.cross(m).len() > 1e-9 { return false; // A conical meridian is not a cylindrical wall. } let placed = *processor.transform() * mt::Vector4::new(axis.x, axis.y, axis.z, 0.0); return placed.w.abs() < 1e-12 && Vec3::new(placed.x, placed.y, placed.z).normalized().is_some_and(|a| a.cross(d).len() < 1e-9); } let dimensions = match surface { mt::Surface::BSplineSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)), mt::Surface::NurbsSurface(s) => (s.control_points().len(), s.control_points().first().map_or(0, Vec::len)), _ => return false, }; let (rows, cols) = dimensions; let count = rows.saturating_mul(cols); let rectangular = match surface { mt::Surface::BSplineSurface(s) => s.control_points().iter().all(|r| r.len() == cols), mt::Surface::NurbsSurface(s) => s.control_points().iter().all(|r| r.len() == cols), _ => false, }; if rows < 2 || cols < 2 || count > 16_384 || !rectangular { return false; } let Some(left) = remaining.checked_sub(count.saturating_mul(2)) else { return false }; *remaining = left; let points: Vec> = 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`). fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option { // 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<&mt::Face> = Vec::new(); let mut top_h = Vec::new(); let mut bottom_h = Vec::new(); let mut wall_work = 262_144; let mut curve_work = 2_000_000usize; for f in b.solid.face_iter() { 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(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; } continue; } if !wall_along(&surf, d, tol, &mut wall_work) { return None; } } // 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 regions = Vec::new(); let mut remaining_segments = MAX_PLANAR_SEGMENTS; let mut remaining_loops = MAX_PLANAR_SEGMENTS; for f in tops { let mut loops = Vec::new(); for w in f.boundaries() { remaining_loops = remaining_loops.checked_sub(1)?; let mut segs = Vec::new(); for e in w.edge_iter() { let segments = crate::prism_curve::segment(e, plane, tol, &mut curve_work)?; remaining_segments = remaining_segments.checked_sub(segments.len())?; segs.extend(segments); } loops.push(Loop2 { segs }); } let (outer, holes) = split_outer(loops)?; regions.push(Region2 { outer, holes }); } Some(Prism { lo, hi, regions }) } /// The loop with the largest area is the outer one. fn split_outer(mut loops: Vec) -> Option<(Loop2, Vec)> { let i = (0..loops.len()).max_by(|a, b| loops[*a].signed_area().abs().total_cmp(&loops[*b].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))) } /// Points where two segments cross (parameters on each), lines and arcs only. fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> { let mut pts: Vec = Vec::new(); match (*s, *t) { (Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => { let (r, q) = (b - a, d - c); let den = r.cross(q); if den.abs() > 1e-300 { let u = (c - a).cross(q) / den; pts.push(a + r * u); } } (Seg2::Line { a, b }, Seg2::Arc { center, radius, .. }) | (Seg2::Arc { center, radius, .. }, Seg2::Line { a, b }) => { let dir = b - a; let f = a - center; let (qa, qb, qc) = (dir.dot(dir), 2.0 * f.dot(dir), f.dot(f) - radius * radius); let disc = qb * qb - 4.0 * qa * qc; if qa > 1e-300 && disc >= -tol * tol * qa { let sd = disc.max(0.0).sqrt(); for u in [(-qb - sd) / (2.0 * qa), (-qb + sd) / (2.0 * qa)] { pts.push(a + dir * u); } } } (Seg2::Arc { center: c0, radius: r0, .. }, Seg2::Arc { center: c1, radius: r1, .. }) => { let dd = c0.dist(c1); if dd > 1e-12 && dd <= r0 + r1 + tol && dd >= (r0 - r1).abs() - tol { let a = (r0 * r0 - r1 * r1 + dd * dd) / (2.0 * dd); let h = (r0 * r0 - a * a).max(0.0).sqrt(); let m = c0 + (c1 - c0) * (a / dd); let perp = (c1 - c0).perp() * (1.0 / dd); pts.push(m + perp * h); pts.push(m - perp * h); } } _ => {} } let mut out = Vec::new(); for p in pts { let (u, v) = (s.closest_param(p), t.closest_param(p)); if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol { out.push((u, v)); } } out } /// Ray parity against analytic lines/arcs. Fragment classification must not depend on a /// display chord tolerance: a tiny tool can sit between a circular boundary and its chords. fn inside_loop(lp: &Loop2, p: Vec2) -> bool { let mut odd = false; for seg in &lp.segs { match *seg { Seg2::Line { a, b } => { if (a.y > p.y) != (b.y > p.y) { let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y); if x > p.x { odd = !odd; } } } Seg2::Arc { center, radius, start, sweep } => { let y = (p.y - center.y) / radius; if !(-1.0..=1.0).contains(&y) { continue; } let a = y.asin(); let root = (1.0 - y * y).max(0.0).sqrt(); let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-12; for (i, (angle, x)) in [(a, center.x + radius * root), (std::f64::consts::PI - a, center.x - radius * root)].into_iter().enumerate() { // A tangent has one geometric root. Interior tangencies contribute zero // parity; a minimum at an endpoint follows the same half-open rule as lines. if root == 0.0 && i == 1 { continue; } if x <= p.x { continue; } let mut delta = if sweep > 0.0 { angle - start } else { start - angle }; delta = delta.rem_euclid(std::f64::consts::TAU); if (delta - std::f64::consts::TAU).abs() < 1e-12 { delta = 0.0; } if delta > sweep.abs() + 1e-12 { continue; } let dy = angle.cos() * sweep; let tangent = dy.abs() < sweep.abs() * 1e-12; let at_start = delta < 1e-12; let at_end = (delta - sweep.abs()).abs() < 1e-12; let crosses = if full { !tangent } else if at_start { dy > 0.0 && !tangent || tangent && y < 0.0 } else if at_end { dy < 0.0 && !tangent || tangent && y < 0.0 } else { !tangent }; if crosses { odd = !odd; } } } _ => { // Private prism callers only supply lines/arcs. Keep a controlled fallback // for future internal callers; the public helper rejects other segment kinds. let points = seg.polyline(1e-7); for pair in points.windows(2) { if let [a, b] = pair && (a.y > p.y) != (b.y > p.y) && a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x { odd = !odd; } } } } } odd } fn inside(regions: &[Region2], p: Vec2) -> bool { regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p))) } fn dist_to(regions: &[Region2], p: Vec2) -> f64 { regions .iter() .flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) .flat_map(|l| l.segs.iter()) .map(|s| s.point_at(s.closest_param(p)).dist(p)) .fold(f64::INFINITY, f64::min) } /// Direction of the boundary of `regions` through `p` (on it), as the boundary runs. fn dir_at(regions: &[Region2], p: Vec2, tol: f64) -> Option { for r in regions { for l in std::iter::once(&r.outer).chain(&r.holes) { for s in &l.segs { let t = s.closest_param(p); if s.point_at(t).dist(p) < tol { return s.derivative(t).normalized(); } } } } None } /// Loops oriented: outers counter-clockwise, holes clockwise. fn oriented(regions: &[Region2]) -> Vec { regions .iter() .map(|r| Region2 { outer: if r.outer.signed_area() < 0.0 { r.outer.reversed() } else { r.outer.clone() }, holes: r.holes.iter().map(|h| if h.signed_area() > 0.0 { h.reversed() } else { h.clone() }).collect(), }) .collect() } /// 2D boolean of regions bounded by lines and arcs. fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option> { 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), count(b)); if na.saturating_add(nb) > MAX_PLANAR_SEGMENTS || na.saturating_mul(nb) > MAX_PLANAR_PAIRS { return None; } let (a, b) = (oriented(a), oriented(b)); let segs = |rs: &[Region2]| -> Vec { rs.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| l.segs.clone()).collect() }; let (sa, sb) = (segs(&a), segs(&b)); if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) { return None; } // Split every segment where it crosses the other side's boundary. let mut cut_a: Vec> = vec![Vec::new(); sa.len()]; let mut cut_b: Vec> = vec![Vec::new(); sb.len()]; let mut events = 0usize; for (i, s) in sa.iter().enumerate() { for (j, t) in sb.iter().enumerate() { for (u, v) in crossings(s, t, tol) { events += 2; if events > MAX_PLANAR_EVENTS { return None; } cut_a.get_mut(i)?.push(u); cut_b.get_mut(j)?.push(v); } } } // Ends of the other side's segments lying on a segment split it too (touching corners). for (i, s) in sa.iter().enumerate() { for t in &sb { for p in [t.start(), t.end()] { let u = s.closest_param(p); if s.point_at(u).dist(p) < tol { events += 1; if events > MAX_PLANAR_EVENTS { return None; } cut_a.get_mut(i)?.push(u); } } } } for (j, t) in sb.iter().enumerate() { for s in &sa { for p in [s.start(), s.end()] { let v = t.closest_param(p); if t.point_at(v).dist(p) < tol { events += 1; if events > MAX_PLANAR_EVENTS { return None; } cut_b.get_mut(j)?.push(v); } } } } let pieces = |s: &Seg2, cuts: &mut Vec| -> Vec { cuts.sort_by(f64::total_cmp); let mut out = Vec::new(); let mut rest = *s; let mut done = 0.0; for &c in cuts.iter() { if c <= done + 1e-9 || c >= 1.0 - 1e-9 { continue; } let local = (c - done) / (1.0 - done); let (x, y) = rest.split_at(local); if x.length() > tol { out.push(x); rest = y; done = c; } } out.push(rest); out }; let mut kept: Vec = Vec::new(); // A's pieces. for (i, s) in sa.iter().enumerate() { if cut_a.get(i)?.len() > MAX_PLANAR_SEGMENTS { return None; } for p in pieces(s, cut_a.get_mut(i)?) { let m = p.point_at(0.5); let on = dist_to(&b, m) < tol; let keep = if on { let same = dir_at(&b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0); match op { BoolOp::Union | BoolOp::Intersect => same, BoolOp::Cut => !same, } } else { let ins = inside(&b, m); match op { BoolOp::Union | BoolOp::Cut => !ins, BoolOp::Intersect => ins, } }; if keep { if kept.len() >= MAX_PLANAR_SEGMENTS { return None; } kept.push(p); } } } // 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; } for p in pieces(t, cut_b.get_mut(j)?) { let m = p.point_at(0.5); if dist_to(&a, m) < tol { continue; } let ins = inside(&a, m); if kept.len() >= MAX_PLANAR_SEGMENTS { return None; } match op { BoolOp::Union if !ins => kept.push(p), BoolOp::Intersect if ins => kept.push(p), BoolOp::Cut if ins => kept.push(p.reversed()), _ => {} } } } // Chain into loops. let mut loops: Vec = 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 mut segs = vec![*kept.get(start)?]; if let Some(u) = used.get_mut(start) { *u = true; } let first = kept.get(start)?.start(); let mut guard = 0; loop { guard += 1; if guard > kept.len() + 2 { return None; } let end = segs.last()?.end(); let full_circle = segs.first().is_some_and(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9)); if end.dist(first) < tol * 10.0 && (segs.len() > 1 || full_circle) { break; } let next = (0..kept.len()) .filter(|k| !used.get(*k).copied().unwrap_or(true)) .min_by(|x, y| kept[*x].start().dist(end).total_cmp(&kept[*y].start().dist(end)))?; if kept.get(next)?.start().dist(end) > tol * 10.0 { if end.dist(first) < tol * 10.0 { break; } return None; } if let Some(u) = used.get_mut(next) { *u = true; } segs.push(*kept.get(next)?); } let l = Loop2 { segs }; if l.signed_area().abs() > tol * tol { loops.push(l); } } // Outers (counter-clockwise) take the holes they contain, the smallest first. let (mut outers, holes): (Vec, Vec) = loops.into_iter().partition(|l| l.signed_area() > 0.0); outers.sort_by(|x, y| x.signed_area().abs().total_cmp(&y.signed_area().abs())); let mut regions: Vec = outers.into_iter().map(|o| Region2 { outer: o, holes: Vec::new() }).collect(); for h in holes { let p = h.segs.first()?.point_at(0.5); let r = regions.iter_mut().find(|r| inside_loop(&r.outer, p) && r.outer.signed_area().abs() > h.signed_area().abs())?; r.holes.push(h); } Some(regions) } /// The boolean done on prism sections, or `None` when it doesn't apply. /// The pieces of a segment between the given points (those lying on it, inside it). fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { return None; } let mut ts: Vec = pts .iter() .filter_map(|p| { let t = seg.closest_param(*p); (t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t) }) .collect(); ts.sort_by(f64::total_cmp); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9); let mut out = Vec::new(); let mut t0 = 0.0; for t1 in ts.into_iter().chain(std::iter::once(1.0)) { out.push(match *seg { Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) }, Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) }, _ => return None, }); t0 = t1; } Some(out) } /// The union of two prisms along `d` stacked end to end (or overlapping) with different /// sections, sewn from its faces: each slab between the prisms' end levels has walls over its /// section's outline; each level has a face up over the section below less the one above, and /// a face down over the one above less the one below. Every outline is split where any other /// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a /// line or an arc. fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option> { 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> = 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(&pa.regions, &pb.regions, BoolOp::Union, tol)?, (true, false) => pa.regions.clone(), (false, true) => pb.regions.clone(), (false, false) => return None, }; slabs.push(oriented(&s)); } sew_sections(plane, d, &levels, &slabs, tol) } /// Sew the exact sections between successive levels, including empty gaps. fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], tol: f64) -> Option> { if levels.len() != slabs.len() + 1 || slabs.len() > 128 || plane.normal().dot(d) < 1. - 1e-12 { return None; } let mut blocks = Vec::new(); let mut start = None; for (i, slab) in slabs.iter().enumerate() { if slab.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 section = slabs.get(first).ok_or_else(|| KernelError::Failed("missing prism section".into()))?; if slabs[first..end].iter().all(|s| s == 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 piece in crate::extrude(plane, section, levels[first], levels[end])? { remaining_faces = remaining_faces.checked_sub(piece.face_count()).ok_or_else(|| KernelError::Failed("prism output face budget".into()))?; pieces.push(piece); } } else { let piece = sew_connected_sections(plane, d, &levels[first..=end], &slabs[first..end], tol) .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); } } 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(); Ok(body.with_face_sources(Some(std::sync::Arc::new(sources)))) })()) } /// A genuinely stepped connected axial block has no single extrusion footprint. fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], tol: f64) -> Option> { 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: &[Region2], y: &[Region2]| -> Option> { match (x.is_empty(), y.is_empty()) { (true, _) => Some(Vec::new()), (false, true) => Some(x.to_vec()), (false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)), } }; let mut caps: Vec<(f64, bool, Vec)> = Vec::new(); for (k, z) in levels.iter().enumerate() { let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? }; let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]); 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 { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() }; let mut pts: Vec = Vec::new(); for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) { for sg in &lp.segs { pts.push(sg.start()); pts.push(sg.end()); } } let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; let edge = |sg: &Seg2, z: f64| -> EdgeSpec { match sg { Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) }, _ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) }, } }; let mut faces: Vec = Vec::new(); for (i, s) in slabs.iter().enumerate() { let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?); for lp in loops_of(s) { for sg0 in &lp.segs { for sg in split_seg(sg0, &pts, tol)? { let (a, b) = (sg.start(), sg.end()); let lp3 = vec![ edge(&sg, z0), EdgeSpec::Line { a: at(b, z0), b: at(b, z1) }, edge(&sg.reversed(), z1), EdgeSpec::Line { a: at(a, z1), b: at(a, z0) }, ]; let tangent = plane.dir_to_world(sg.derivative(0.5)); let outward = tangent.cross(d).normalized()?; let surface = match sg { Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) }, _ => SurfSpec::Plane, }; faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) }); } } } } for (z, up, rs) in &caps { for r in rs { let mut loops = Vec::new(); for lp in std::iter::once(&r.outer).chain(&r.holes) { let mut segs: Vec = Vec::new(); for sg in &lp.segs { segs.extend(split_seg(sg, &pts, tol)?); } let segs: Vec = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() }; loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::>()); } faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None }); } } Some(crate::sew::sew_touching(&faces)) } fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; // Candidate directions: a's planar face normals (each line once). let mut dirs: Vec = Vec::new(); let mut remaining_direction_faces = MAX_PLANAR_SEGMENTS * 2; for f in a.solid.face_iter() { remaining_direction_faces = remaining_direction_faces.checked_sub(1)?; if let mt::Surface::Plane(p) = f.oriented_surface() { let n = p.normal(); if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { dirs.push(if n.dot(Vec3::new(1e-3, 1e-6, 1.0)) < 0.0 { -n } else { n }); if dirs.len() == 8 { break; } } } } let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = prism_of(a, d, &plane, tol)?; let pb = prism_of(b, d, &plane, tol)?; Some((d, plane, pa, pb)) })?; let _ = d; 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::()).sum::(); // 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 regions = boolean2d(&pa.regions, &pb.regions, op, tol)?; if regions.is_empty() { return Some(Ok(None)); } let (lo, hi) = if stacked { // Different sections (a boss hanging over a plate's edge): sewn slab by slab. let (u, x, y) = (area(®ions), area(&pa.regions), area(&pb.regions)); if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) { return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some)); } (pa.lo.min(pb.lo), pa.hi.max(pb.hi)) } else { (pa.lo, pa.hi) }; let base = plane.offset(lo); let mut bodies = match crate::extrude(&base, ®ions, 0.0, hi - lo) { Ok(b) => b, Err(e) => return Some(Err(e)), }; let first = bodies.pop()?; if !bodies.is_empty() { // Several pieces: one body of several lumps. let mut shells = first.solid.boundaries().clone(); for b in bodies { shells.extend(b.solid.boundaries().iter().cloned()); } return Some(crate::body::Solid::try_new(shells).map_err(|e| crate::KernelError::Failed(e.to_string())).and_then(Body::new).map(Some)); } Some(Ok(Some(first))) } /// A stepped extrusion (case floor plus walls), recovered from its cap events. /// Downward caps add a section above their level; upward caps remove one. fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option> { if let Some(p) = prism_of(b, d, plane, tol) { return Some(vec![p]); } if b.face_count() > 2048 { return None; } let mut events = Vec::new(); let mut wall_work = 262_144; let mut curve_work = 2_000_000; for f in b.solid.face_iter() { 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 mut loops = Vec::new(); for w in f.boundaries() { let mut segs = Vec::new(); for e in w.edge_iter() { segs.extend(crate::prism_curve::segment(e, plane, tol, &mut curve_work)?); } loops.push(Loop2 { segs }); } let (outer, holes) = split_outer(loops)?; let region = Region2 { outer, holes }; events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, oriented(&[region]))); continue; } if n.dot(d).abs() > 1e-9 { return None; } } else { if !wall_along(&surf, d, tol, &mut wall_work) { return None; } } } let mut levels: Vec = 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 mut current = Vec::new(); let mut stack = Vec::new(); for (i, z) in levels.iter().enumerate() { // 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.is_empty(), up) { (true, false) => section.clone(), (true, true) => return None, (false, _) => boolean2d(¤t, section, if up { BoolOp::Cut } else { BoolOp::Union }, tol)?, }; } } if let Some(next) = levels.get(i + 1) && !current.is_empty() { stack.push(Prism { lo: *z, hi: *next, regions: oriented(¤t) }); } } (current.is_empty() && !stack.is_empty()).then_some(stack) } fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { // 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) -> &[Region2] { ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.regions.as_slice()).unwrap_or(&[]) } let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0; let mut dirs: Vec = Vec::new(); for f in a.solid.face_iter() { if let mt::Surface::Plane(p) = f.oriented_surface() { let n = p.normal(); if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) { dirs.push(n); } } } let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { let plane = Plane::from_normal(Vec3::ZERO, d)?; let pa = stack_of(a, d, &plane, tol)?; let pb = stack_of(b, d, &plane, tol)?; let (ea, eb) = (ends(&pa), ends(&pb)); match op { BoolOp::Union if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol => return None, // A cut tool may extend past a cap into empty space. Exact slab sections // discard that space rather than intersecting coincident cylinder walls. BoolOp::Cut if ea.1 <= eb.0 + tol || eb.1 <= ea.0 + tol => return None, _ => {} } Some((d, plane, pa, pb)) })?; let mut levels: Vec = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect(); levels.sort_by(f64::total_cmp); levels.dedup_by(|x, y| (*x - *y).abs() < tol); let mut slabs = Vec::new(); let mut work = 0usize; let mut output_edges = 0usize; let edges = |regions: &[Region2]| regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).sum::(); for w in levels.windows(2) { let (lo, hi) = (*w.first()?, *w.get(1)?); let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); if op == BoolOp::Cut { let count = edges(x).saturating_add(edges(y)); work = work.saturating_add(count.saturating_mul(count)); if work > 32_000_000 { return None; } } let s = match (x.is_empty(), y.is_empty(), op) { (true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(), (true, _, BoolOp::Union) => y.to_vec(), (_, true, _) => x.to_vec(), _ => boolean2d(x, y, op, tol)?, }; if op == BoolOp::Cut { output_edges = output_edges.saturating_add(edges(&s)); if output_edges > 8192 { return None; } } slabs.push(oriented(&s)); } if slabs.iter().all(Vec::is_empty) { return Some(Ok(None)); } sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some)) } pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten() } #[cfg(test)] mod planar_tests { use super::*; fn rectangle(a: Vec2, b: Vec2) -> Region2 { Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } } #[test] fn simple_prism_retains_large_analytic_section_budget() { let count = 2050; let radius = 1000.; let points: Vec<_> = (0..count) .map(|i| { let angle = std::f64::consts::TAU * i as f64 / count as f64; Vec2::new(radius * angle.cos(), radius * angle.sin()) }) .collect(); let region = Region2 { outer: Loop2::polygon(&points), holes: vec![] }; let stock = crate::extrude(&Plane::XY, &[region], 0., 2.).unwrap().pop().unwrap(); assert_eq!(stock.face_count(), count + 2); assert!(stock.face_count() > 2048 && stock.validity().is_empty()); let before: Vec<_> = stock.solid.vertex_iter().map(|v| (v.id(), v.point())).collect(); let recovered = prism_of(&stock, Vec3::Z, &Plane::XY, 1e-5).expect("large simple line prism remains supported"); assert_eq!(recovered.regions.len(), 1); assert_eq!(recovered.regions[0].outer.segs.len(), count); assert!(recovered.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.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::>(), 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(Option::is_none)), "genuinely stepped geometry remains uncertified"); 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); } #[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].regions[0].holes.len(), 1); assert_eq!(b[0].regions[0].holes.len(), 1); assert!((a[0].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 { result = stack_boolean(&result, tool, BoolOp::Cut).expect("exact stack path").unwrap().unwrap(); } assert!(result.validity().is_empty()); assert_eq!(result.shell_count(), 1); assert!((crate::measure(&result).unwrap().volume - 14_725.).abs() < 1e-6); let mesh = result.tessellate(1e-4).unwrap(); for (point, material) in [ (Vec3::new(22.5, 25., 0.5), false), (Vec3::new(22.5, 25., 2.), true), (Vec3::new(32.5, 25., 0.5), true), (Vec3::new(32.5, 25., 2.), false), (Vec3::new(27.5, 25., 1.25), false), (Vec3::new(10., 10., 1.25), true), ] { assert_eq!(mesh.contains(point), material, "{point:?}"); } let imported = crate::step_import(&crate::step_export(&[&result], "layered cut").unwrap()).unwrap(); assert!(imported.warnings.is_empty()); assert!((crate::measure(&imported.bodies.first().unwrap().body).unwrap().volume - 14_725.).abs() < 1e-6); let next = crate::extrude(&plane, &[rectangle(Vec2::new(20., 20.), Vec2::new(25., 30.))], 1.5, 2.5).unwrap().pop().unwrap(); let opened = crate::boolean(&imported.bodies.first().unwrap().body, &next, BoolOp::Cut).unwrap().unwrap(); assert!((crate::measure(&opened).unwrap().volume - 14_675.).abs() < 1e-6); assert!(opened.validity().is_empty()); } } #[test] fn compound_planar_extrusion_normalizes_overlap_and_retains_islands() { let plane = Plane::named("XY").unwrap(); let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); let b = rectangle(Vec2::new(5.0, 0.0), Vec2::new(15.0, 10.0)); let body = extrude_planar_regions(&plane, &[a.clone(), b], 0.0, 2.0).unwrap(); assert!((crate::measure(&body).unwrap().volume - 300.0).abs() < 1e-6); let inner = Region2 { outer: Loop2::circle(Vec2::new(5.0, 5.0), 2.0), holes: vec![] }; let body = extrude_planar_regions(&plane, &[a.clone(), inner.clone()], 0.0, 2.0).unwrap(); assert!((crate::measure(&body).unwrap().volume - 200.0).abs() < 1e-6); let annulus = Region2 { outer: a.outer, holes: vec![Loop2::circle(Vec2::new(5.0, 5.0), 3.0)] }; let body = extrude_planar_regions(&plane, &[annulus, inner], 0.0, 2.0).unwrap(); let expected = (100.0 - std::f64::consts::PI * 5.0) * 2.0; assert!((crate::measure(&body).unwrap().volume - expected).abs() < 0.1); assert_eq!(body.solid.boundaries().len(), 2); let a = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); let b = rectangle(Vec2::new(10.0, 10.0), Vec2::new(20.0, 20.0)); assert!(extrude_planar_regions(&plane, &[a, b], 0.0, 2.0).is_err()); let circles = [Vec2::ZERO, Vec2::new(10.0, 0.0)].map(|center| Region2 { outer: Loop2::circle(center, 5.0), holes: vec![] }); assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err()); } #[test] fn analytic_circle_classification_respects_signed_sweeps_and_seams() { for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] { for reversed in [false, true] { let lp = Loop2::circle_from(Vec2::ZERO, 10.0, seam); let lp = if reversed { lp.reversed() } else { lp }; for p in [Vec2::ZERO, Vec2::new(9.9998, 0.0), Vec2::new(0.0, 9.9998), Vec2::new(0.0, -9.9998)] { assert!(inside_loop(&lp, p), "{seam} {reversed} {p:?}"); } for p in [Vec2::new(11.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(0.0, -10.0)] { assert!(!inside_loop(&lp, p), "{seam} {reversed} {p:?}"); } } } let half = Loop2 { segs: vec![ Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, -10.0) }, ], }; for lp in [half.clone(), half.reversed()] { assert!(inside_loop(&lp, Vec2::new(5.0, 0.0))); assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0))); assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0))); } } #[test] fn tiny_tool_between_circle_and_display_chord_is_not_lost() { let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] }; let sampled = circle.outer.polyline(1e-3); let direction = (sampled[0] + sampled[1]).normalized().unwrap(); let at = direction * 9.9998; let half = Vec2::new(0.000025, 0.000025); let square = rectangle(at - half, at + half); assert!(square.outer.segs.iter().all(|s| s.start().len() < 10.0)); assert!(!circle.contains(at), "fixture must lie outside the display chord"); let cut = planar_regions_boolean(std::slice::from_ref(&circle), std::slice::from_ref(&square), BoolOp::Cut, 1e-7).unwrap(); assert_eq!(cut.len(), 1); assert_eq!(cut[0].holes.len(), 1); assert!((cut[0].holes[0].signed_area().abs() - 2.5e-9).abs() < 1e-13); let intersect = planar_regions_boolean(&[circle], &[square], BoolOp::Intersect, 1e-7).unwrap(); assert_eq!(intersect.len(), 1); assert!((intersect[0].area() - 2.5e-9).abs() < 1e-13); } #[test] fn planar_region_api_rejects_hostile_values_and_budgets() { let plate = rectangle(Vec2::ZERO, Vec2::new(10.0, 10.0)); for tolerance in [f64::NAN, f64::INFINITY, 0.0, -1.0] { assert!(planar_regions_boolean(std::slice::from_ref(&plate), &[], BoolOp::Union, tolerance).is_err()); } let mut bad = plate.clone(); bad.outer.segs[0] = Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }; assert!(planar_regions_boolean(&[bad], &[], BoolOp::Union, 1e-7).is_err()); let many = vec![plate; MAX_PLANAR_SEGMENTS / 4 + 1]; assert!(planar_regions_boolean(&many, &[], BoolOp::Union, 1e-7).is_err()); } }