Files
solidcraft-upstream/crates/kernel/src/surface_untrim.rs
T
Irakli Kourtis 16c49c3e86 Untrim: holes removed and a face restored to its natural bounds (#1150)
Adds surface.untrim (#761); the SurfaceUntrim feature is first saved in format 21.
2026-10-14 20:29:25 -04:00

615 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Untrim (Surface › Untrim): a face restored towards the surface it lies on. Picked inner
//! loops (holes), or all of them, are taken away; optionally the outer boundary goes back to
//! the surface's natural parameter bounds: a free-form surface's own parameter box, and for an
//! analytic surface (a plane, cylinder, cone, sphere or torus, which has no natural bounds of
//! its own, or bounds that go all the way round) the box of the face's own span. The restored
//! boundary runs along the surface's parameter lines, each edge exact: a line, an arc about a
//! revolved surface's axis, a rotated profile, or a B-spline iso-curve.
//!
//! Which loop is the outer one is worked out from the face's parameters (the loop enclosing the
//! most area), never from the order the loops are stored in: an imported face can list its holes
//! first.
use mt::{BoundedCurve, Cut, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface, SearchNearestParameter, Transformed};
use solidcraft_geom::Vec3;
use solidcraft_geom::tess::EvalSurface;
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::record::{Rebuild, rebuilt_body};
use crate::surfaces::surface_body;
use crate::tess::uv::{self, Domain};
use crate::{KernelError, Result, guard};
/// Samples along each boundary edge when working out the face's parameter loops.
const EDGE_SAMPLES: usize = 32;
/// Golden-section steps that place an extreme of the face's span on an edge.
const EXTREME_STEPS: usize = 60;
/// The inner loops Untrim takes away.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum UntrimHoles<'a> {
/// Every inner loop of the face.
All,
/// The inner loops nearest these points (on or near their edges); none keeps every hole.
At(&'a [Vec3]),
}
/// Face `face` of `body` (in its face order) untrimmed: the inner loops `holes` names taken
/// away and, with `outer`, the outer boundary restored to the surface's natural bounds (an
/// analytic surface's: the face's own span). The result is a surface body: the untrimmed face on
/// its own, or, with `in_place` and a surface `body`, the body with that face untrimmed (a
/// solid's face is always copied out). Its history is over `[body]`: the untrimmed face is the
/// face it came from, trimmed differently, so it keeps that face's name.
pub fn untrim(body: &Body, face: usize, holes: UntrimHoles, outer: bool, in_place: bool) -> Result<Body> {
body.require_brep("untrim")?;
if let UntrimHoles::At(points) = holes {
if points.len() > 10_000 {
return Err(KernelError::Invalid("too many holes picked".into()));
}
if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) {
return Err(KernelError::Invalid("a hole point is not finite".into()));
}
}
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let original = *faces.get(face).ok_or_else(|| KernelError::Invalid(format!("the body has no face {face}")))?;
let size = body.size().max(1e-6);
let untrimmed = guard("untrim", || untrimmed_face(original, holes, outer, size))?;
let mut rec = Rebuild::default();
rec.face(&untrimmed, original);
let result = if in_place && body.is_surface() {
surface_body(faces.iter().map(|f| if f.id() == original.id() { untrimmed.clone() } else { (*f).clone() }).collect())
} else {
surface_body(vec![untrimmed])
};
Ok(rebuilt_body(result, &[&body.solid], &rec))
}
/// A boundary loop of a face sampled in 3D and in the surface's parameters: for each of its
/// edges (as the loop runs) the curve parameters, points and parameters of its samples, ends
/// included. Periodic parameters are unwrapped along the loop.
struct LoopSamples {
edges: Vec<(mt::Curve, Vec<(f64, Vec3, [f64; 2])>)>,
}
impl LoopSamples {
/// The loop as a polygon in parameters (each edge's samples but its last, which the next
/// edge starts on).
fn polygon(&self) -> Vec<[f64; 2]> {
self.edges.iter().flat_map(|(_, s)| s.iter().take(s.len().saturating_sub(1)).map(|x| x.2)).collect()
}
/// Signed area of the loop in parameters (positive: counter-clockwise).
fn area(&self) -> f64 {
let poly = self.polygon();
let n = poly.len();
(0..n)
.filter_map(|i| {
let (a, b) = (poly.get(i)?, poly.get((i + 1) % n)?);
Some(0.5 * (a[0] * b[1] - b[0] * a[1]))
})
.sum()
}
/// How far `p` is from the loop (from its samples).
fn dist(&self, p: Vec3) -> f64 {
self.edges.iter().flat_map(|(_, s)| s.iter().map(|x| x.1.dist(p))).fold(f64::INFINITY, f64::min)
}
}
/// Sample a boundary loop (absolute: as it runs on the surface) in the surface's parameters.
fn sample_loop(s: &EvalSurface, dom: &Domain, wire: &mt::Wire, accept: f64) -> Result<LoopSamples> {
let mut edges = Vec::new();
let mut prev: Option<[f64; 2]> = None;
for e in wire.edge_iter() {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
let mut samples = Vec::with_capacity(EDGE_SAMPLES + 1);
for k in 0..=EDGE_SAMPLES {
let t = t0 + (t1 - t0) * k as f64 / EDGE_SAMPLES as f64;
let p = from_p3(c.subs(t));
let q = parameters(s, dom, p, prev, accept)?;
samples.push((t, p, q));
prev = Some(q);
}
edges.push((c, samples));
}
Ok(LoopSamples { edges })
}
/// The parameters of `p` on the surface, unwrapped to lie near `near` along periodic directions.
fn parameters(s: &EvalSurface, dom: &Domain, p: Vec3, near: Option<[f64; 2]>, accept: f64) -> Result<[f64; 2]> {
let mut q = uv::project(s, dom, p, near, accept).ok_or_else(|| KernelError::Failed("the face's boundary does not lie on its surface".into()))?;
if let Some(n) = near {
for d in 0..2 {
q[d] = uv::unwrap(q[d], n[d], dom.period[d]);
}
}
Ok(q)
}
/// The face untrimmed (see [`untrim`]).
fn untrimmed_face(face: &mt::Face, holes: UntrimHoles, outer: bool, size: f64) -> Result<mt::Face> {
let surface = face.surface();
let eval =
crate::tess::surface::eval_surface(&surface).ok_or_else(|| KernelError::TooLarge("untrimming a face on this kind of surface".into()))?;
let dom = uv::domain(&eval);
let accept = (size * 1e-5).max(1e-6);
let wires = face.absolute_boundaries();
let loops: Vec<LoopSamples> = wires.iter().map(|w| sample_loop(&eval, &dom, w, accept)).collect::<Result<_>>()?;
// The outer loop encloses the others: the most area in parameters.
let areas: Vec<f64> = loops.iter().map(LoopSamples::area).collect();
let outer_index = areas
.iter()
.enumerate()
.max_by(|a, b| a.1.abs().total_cmp(&b.1.abs()))
.map(|(i, _)| i)
.ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?;
let inner: Vec<usize> = (0..wires.len()).filter(|i| *i != outer_index).collect();
let removed: Vec<usize> = match holes {
UntrimHoles::All => inner.clone(),
UntrimHoles::At(points) => {
let mut picked = Vec::new();
for p in points {
let (i, d) = inner
.iter()
.filter_map(|i| loops.get(*i).map(|l| (*i, l.dist(*p))))
.min_by(|a, b| a.1.total_cmp(&b.1))
.ok_or_else(|| KernelError::Invalid("the face has no holes".into()))?;
if d > size * 0.05 + 1e-6 {
return Err(KernelError::Invalid(format!("no hole of the face at {p:?}")));
}
if !picked.contains(&i) {
picked.push(i);
}
}
picked
}
};
if !outer && removed.is_empty() {
return Err(KernelError::Invalid(if inner.is_empty() {
"the face has no holes to remove (restore its outer boundary instead)".into()
} else {
"nothing to untrim: pick holes to remove or restore the outer boundary".into()
}));
}
let (Some(outer_wire), Some(outer_loop), Some(outer_area)) = (wires.get(outer_index), loops.get(outer_index), areas.get(outer_index)) else {
return Err(KernelError::Invalid("the face has no boundary".into()));
};
let first = if outer {
let analytic = crate::offset::surf_of(face, (size * 1e-7).max(1e-9) * 100.0).is_some();
let span = natural_span(&eval, &dom, outer_loop, analytic, accept)?;
bounds_wire(&surface, span, *outer_area > 0.0, size)?
} else {
outer_wire.clone()
};
let kept = inner.iter().filter(|i| !removed.contains(i)).filter_map(|i| wires.get(*i).cloned());
let boundaries: Vec<mt::Wire> = std::iter::once(first).chain(kept).collect();
let mut f = mt::Face::try_new(boundaries, surface).map_err(|e| KernelError::Failed(format!("untrimmed face: {e}")))?;
if !face.orientation() {
f.invert();
}
Ok(f)
}
/// The parameter box the outer boundary is restored to: along each direction the surface's own
/// range where it has one of its own (a free-form surface's; not a periodic or unbounded
/// direction, and not an analytic surface's), else the face's own span.
fn natural_span(s: &EvalSurface, dom: &Domain, outer: &LoopSamples, analytic: bool, accept: f64) -> Result<[(f64, f64); 2]> {
let mut span = [(0.0, 0.0); 2];
for (d, out) in span.iter_mut().enumerate() {
*out = match (dom.range.get(d).copied().flatten(), dom.period.get(d).copied().flatten()) {
(Some(range), None) if !analytic => range,
(_, period) => {
let lo = extreme(s, dom, outer, d, false, accept);
let hi = extreme(s, dom, outer, d, true, accept);
if let Some(p) = period
&& hi - lo >= p * (1.0 - 1e-9)
{
return Err(KernelError::TooLarge("a face that goes all the way round its surface".into()));
}
(lo, hi)
}
};
if !(out.1 - out.0 > 1e-12 * (1.0 + out.0.abs() + out.1.abs())) || !out.0.is_finite() || !out.1.is_finite() {
return Err(KernelError::Invalid("the face has no extent along its surface".into()));
}
}
Ok(span)
}
/// The least (`max == false`) or greatest parameter along direction `d` the loop reaches: the
/// extreme sample, then placed on its edge between the neighbouring samples. Samples where the
/// surface does not move along `d` (a pole) say nothing about `d` and are passed over.
fn extreme(s: &EvalSurface, dom: &Domain, lp: &LoopSamples, d: usize, max: bool, accept: f64) -> f64 {
let sign = if max { 1.0 } else { -1.0 };
let mut best: Option<(f64, usize, usize)> = None;
for (ei, (_, samples)) in lp.edges.iter().enumerate() {
for (k, (_, _, q)) in samples.iter().enumerate() {
if uv::degenerate(s, *q, d) {
continue;
}
let x = sign * q[d];
if best.is_none_or(|(b, _, _)| x > b) {
best = Some((x, ei, k));
}
}
}
let Some((x, ei, k)) = best else { return f64::NAN };
let Some((curve, samples)) = lp.edges.get(ei) else { return sign * x };
let (Some(at), Some(lo), Some(hi)) = (samples.get(k), samples.get(k.saturating_sub(1)), samples.get(k + 1).or(samples.get(k))) else {
return sign * x;
};
// Golden-section search for the extreme between the neighbouring samples.
let value = |t: f64| -> Option<f64> {
let p = from_p3(curve.subs(t));
let q = uv::project(s, dom, p, Some(at.2), accept)?;
Some(sign * uv::unwrap(q[d], at.2[d], dom.period[d]))
};
let g = (5f64.sqrt() - 1.0) / 2.0;
let (mut a, mut b) = (lo.0, hi.0);
let mut best = x;
for _ in 0..EXTREME_STEPS {
let (c, e) = (b - g * (b - a), a + g * (b - a));
let (Some(fc), Some(fe)) = (value(c), value(e)) else { break };
best = best.max(fc).max(fe);
if fc > fe {
b = e;
} else {
a = c;
}
}
sign * best
}
/// The closed boundary of the parameter box `span` on `surface`, counter-clockwise in its
/// parameters when `ccw`: four parameter-line edges, fewer where a side shrinks to a point (a
/// pole: the corners at its ends are one vertex).
fn bounds_wire(surface: &mt::Surface, span: [(f64, f64); 2], ccw: bool, size: f64) -> Result<mt::Wire> {
let [(u0, u1), (v0, v1)] = span;
let tol = size * 1e-6 + 1e-9;
// The corners counter-clockwise from (u0, v0); each side runs between two of them.
let mut corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
if !ccw {
corners.reverse();
}
let points: Vec<mt::Point3> = corners.iter().map(|(u, v)| surface.subs(*u, *v)).collect();
if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) {
return Err(KernelError::Failed("the surface cannot be evaluated at its bounds".into()));
}
// Corners at one point (a pole) are one vertex.
let mut vertices: Vec<mt::Vertex> = Vec::with_capacity(4);
for (i, p) in points.iter().enumerate() {
let same = points.iter().take(i).position(|q| q.distance(*p) <= tol).and_then(|j| vertices.get(j).cloned());
vertices.push(same.unwrap_or_else(|| mt::Vertex::new(*p)));
}
let mut edges = Vec::with_capacity(4);
for i in 0..4 {
let j = (i + 1) % 4;
let (Some(&(ua, va)), Some(&(ub, vb)), Some(a), Some(b)) = (corners.get(i), corners.get(j), vertices.get(i), vertices.get(j)) else {
continue;
};
let along_u = va == vb;
let (w, from, to) = if along_u { (va, ua, ub) } else { (ua, va, vb) };
let mid = if along_u { surface.subs(0.5 * (from + to), w) } else { surface.subs(w, 0.5 * (from + to)) };
if a.id() == b.id() {
// A side shrunk to a pole is no edge; one that closes on itself can't be bounded.
if mid.distance(a.point()) > tol {
return Err(KernelError::TooLarge("a surface that closes on itself within the face's bounds".into()));
}
continue;
}
let curve = iso_curve(surface, along_u, w, from, to)?;
let close = |p: mt::Point3, q: mt::Point3| p.distance(q) <= tol * 10.0;
let (t0, t1) = curve.range_tuple();
if !close(curve.subs(t0), a.point()) || !close(curve.subs(t1), b.point()) || !passes(&curve, mid, tol * 10.0) {
return Err(KernelError::Failed("a restored boundary edge does not follow its surface".into()));
}
edges.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("restored boundary edge: {e}")))?);
}
if edges.len() < 2 {
return Err(KernelError::Invalid("the face's bounds shrink to a point".into()));
}
Ok(edges.into())
}
/// Whether the curve passes within `tol` of `p`.
fn passes(curve: &mt::Curve, p: mt::Point3, tol: f64) -> bool {
let (t0, t1) = curve.range_tuple();
let start = (0..=64)
.map(|k| t0 + (t1 - t0) * k as f64 / 64.0)
.min_by(|a, b| curve.subs(*a).distance(p).total_cmp(&curve.subs(*b).distance(p)))
.unwrap_or(t0);
let t = curve.search_nearest_parameter(p, Some(start), 100).unwrap_or(start).clamp(t0.min(t1), t0.max(t1));
curve.subs(t).distance(p) <= tol
}
/// The parameter line of `surface` at `w` (v = w when `along_u`, else u = w) from parameter
/// `from` to `to` along it, exact: a line on a plane, a B-spline surface's iso-curve, and on a
/// revolved surface the profile turned to its angle or an arc about the axis.
fn iso_curve(surface: &mt::Surface, along_u: bool, w: f64, from: f64, to: f64) -> Result<mt::Curve> {
let at = |t: f64| if along_u { surface.subs(t, w) } else { surface.subs(w, t) };
match surface {
mt::Surface::Plane(_) => Ok(mt::Curve::Line(mt::Line(at(from), at(to)))),
mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => {
let hom: mt::BSplineSurface<mt::Vector4> = match surface {
mt::Surface::BSplineSurface(b) => mt::BSplineSurface::new(
b.knot_vecs().clone(),
b.control_points().iter().map(|row| row.iter().map(|p| p.to_homogeneous()).collect()).collect(),
),
mt::Surface::NurbsSurface(n) => n.non_rationalized().clone(),
_ => return Err(KernelError::Failed("not a B-spline surface".into())),
};
let iso = crate::step_in::split::iso_bsp(&hom, w, along_u).map_err(KernelError::Failed)?;
Ok(piece(mt::Curve::NurbsCurve(iso), from, to))
}
mt::Surface::RevolutedCurve(proc) => {
let rev = proc.entity();
// Truck's revolved surface is (profile parameter, angle), swapped when the processor
// is turned over; the processor's matrix places it.
let along_profile = along_u == proc.orientation();
let transform = *proc.transform();
let (origin, axis) = (rev.origin(), rev.axis().normalize());
if along_profile {
let profile = rev.entity_curve();
if let mt::Curve::Line(_) = profile {
return Ok(mt::Curve::Line(mt::Line(at(from), at(to))));
}
if let mt::Curve::IntersectionCurve(_) = profile {
return Err(KernelError::TooLarge("untrimming a surface revolved from an intersection curve".into()));
}
let turn = mt::Matrix4::from_translation(origin - mt::Point3::new(0.0, 0.0, 0.0))
* mt::Matrix4::from_axis_angle(axis, mt::Rad(w))
* mt::Matrix4::from_translation(mt::Point3::new(0.0, 0.0, 0.0) - origin);
Ok(piece(profile.transformed(transform * turn), from, to))
} else {
// The profile point at `w` turns about the axis: an arc from angle `from` to `to`.
let p = rev.entity_curve().subs(w);
let centre = origin + axis * (p - origin).dot(axis);
let r = (p - centre).magnitude();
if !(r > 1e-12) {
return Err(KernelError::Failed("an arc on the axis of a revolved surface".into()));
}
let x = (p - centre) / r;
let frame = crate::step_in::geom::Frame { o: centre, x, y: axis.cross(x), z: axis };
let arc = if to > from {
crate::step_in::geom::conic_arc(&frame, r, r, from, to - from)
} else {
crate::step_in::geom::conic_arc(&frame, r, r, to, from - to).inverse()
};
Ok(mt::Curve::NurbsCurve(arc).transformed(transform))
}
}
}
}
/// The piece of `curve` between its parameters `from` and `to`, running from `from`.
fn piece(mut curve: mt::Curve, from: f64, to: f64) -> mt::Curve {
let (lo, hi) = if from < to { (from, to) } else { (to, from) };
let (t0, t1) = curve.range_tuple();
let eps = (t1 - t0).abs() * 1e-12;
if lo > t0 + eps {
curve = curve.cut(lo);
}
if hi < t1 - eps {
let _ = curve.cut(hi);
}
if from > to {
curve.invert();
}
curve
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{extrude, measure};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
use std::f64::consts::PI;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-9)
}
fn inner_loops(b: &Body) -> usize {
b.solid.face_iter().map(|f| f.boundaries().len().saturating_sub(1)).sum()
}
/// A 40 × 20 × 2 plate with three holes of radius 2 at x = 10, 20, 30 on y = 10.
fn plate() -> Body {
let outer = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 20.0), Vec2::new(0.0, 20.0)]);
let holes = [10.0, 20.0, 30.0].iter().map(|x| Loop2::circle(Vec2::new(*x, 10.0), 2.0).reversed()).collect();
extrude(&Plane::XY, &[Region2 { outer, holes }], 0.0, 2.0).unwrap().pop().unwrap()
}
fn face_at(b: &Body, p: Vec3) -> usize {
crate::surfaces::nearest_face(b, p).unwrap()
}
/// The plate's top with every hole taken away is the whole 40 × 20 rectangle, one face with
/// no inner loops; one hole taken away leaves two (area 800 − 2 · 4π); the face's own loops
/// are kept by identity. Nothing to take away is refused.
#[test]
fn plate_with_three_holes_untrimmed_to_its_rectangle() {
let b = plate();
let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0));
let all = untrim(&b, top, UntrimHoles::All, false, false).unwrap();
assert!(all.is_surface() && all.face_count() == 1 && inner_loops(&all) == 0);
assert!(rel(measure(&all).unwrap().area, 800.0) < 1e-9, "{}", measure(&all).unwrap().area);
let one = untrim(&b, top, UntrimHoles::At(&[Vec3::new(22.0, 10.0, 2.0)]), false, false).unwrap();
assert_eq!(inner_loops(&one), 2);
let want = 800.0 - 2.0 * 4.0 * PI;
assert!(rel(measure(&one).unwrap().area, want) < 1e-3, "{} vs {want}", measure(&one).unwrap().area); // (measured on meshes)
// The plate is untouched (three holes through its top and bottom); the history says the
// face is the top, trimmed differently.
assert_eq!(inner_loops(&b), 6);
let h = all.history().unwrap();
assert_eq!(h.faces.first().map(|f| f.change), Some(crate::history::Change::Modified));
// Nothing to do: a face without holes, a point on no hole, an empty pick.
let side = b.solid.face_iter().position(|f| f.boundaries().len() == 1).unwrap();
assert!(untrim(&b, side, UntrimHoles::All, false, false).is_err());
assert!(untrim(&b, top, UntrimHoles::At(&[Vec3::new(5.0, 5.0, 2.0)]), false, false).is_err());
assert!(untrim(&b, top, UntrimHoles::At(&[]), false, false).is_err());
assert!(untrim(&b, 99, UntrimHoles::All, false, false).is_err());
}
/// A face whose hole is stored before its outer loop (as an imported face may list them):
/// the outer loop is the one enclosing the most, so the 10 × 10 square comes back whole.
#[test]
fn the_outer_loop_is_found_whatever_order_the_loops_are_in() {
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![Loop2::polygon(&[Vec2::new(4.0, 4.0), Vec2::new(4.0, 6.0), Vec2::new(6.0, 6.0), Vec2::new(6.0, 4.0)])],
};
let sheet = crate::patch_region(&Plane::XY, &r).unwrap();
let f = sheet.solid.face_iter().next().unwrap().clone();
let mut loops = f.absolute_boundaries().clone();
loops.reverse();
let swapped = mt::Face::new_unchecked(loops, f.surface());
let swapped = if f.orientation() { swapped } else { swapped.inverse() };
let b = surface_body(vec![swapped]);
assert!(rel(measure(&b).unwrap().area, 96.0) < 1e-9);
let back = untrim(&b, 0, UntrimHoles::All, false, true).unwrap();
assert!(back.face_count() == 1 && inner_loops(&back) == 0);
assert!(rel(measure(&back).unwrap().area, 100.0) < 1e-9, "{}", measure(&back).unwrap().area);
}
/// A half cylinder sheet (radius 10, angles 0…π, height 20) trimmed by the slanted plane
/// z = 15 − y/2: its top edge runs from 15 down to 10 and back, its area ∫ (15 − 5 sin θ) r dθ
/// = 150π − 100. Restored, it fills its own span again, angles 0…π and heights 0…15: 150π,
/// on the same cylinder.
#[test]
fn a_trimmed_cylinder_face_is_restored_to_its_full_span() {
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 10.0, start: 0.0, sweep: PI }];
let sheet = crate::extrude_sheet(&Plane::XY, &[arc], 0.0, 20.0).unwrap();
let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, 15.0), Vec3::new(0.0, 0.5, 1.0)).unwrap());
let cut = crate::trim(&sheet, &tool, Vec3::new(0.0, 10.0, 1.0)).unwrap();
assert_eq!(cut.face_count(), 1);
let trimmed = measure(&cut).unwrap().area;
assert!(rel(trimmed, 150.0 * PI - 100.0) < 1e-3, "{trimmed}"); // (measured on meshes)
// Nothing to take away and the outer boundary kept: nothing to do.
assert!(untrim(&cut, 0, UntrimHoles::All, false, false).is_err());
let restored = untrim(&cut, 0, UntrimHoles::All, true, false).unwrap();
assert!(restored.face_count() == 1 && inner_loops(&restored) == 0);
let a = measure(&restored).unwrap().area;
assert!(rel(a, 150.0 * PI) < 1e-3, "{a} vs {}", 150.0 * PI); // (measured on meshes)
let bb = measure(&restored).unwrap().bbox;
assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-6, "{bb:?}");
assert!(matches!(
crate::face_surface(&restored, 0),
Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - 10.0).abs() < 1e-6
));
}
/// A revolved face: the cone frustum swept by the line from radius 5 at z = 0 to radius 10
/// at z = −10 (slant √125), its first face (angles 0…½), trimmed by the plane z = −8 + y/2.
/// The cut reaches lowest (profile parameter 0.8) at angle 0, so the face's own span is
/// angles 0…½ by parameters 0…0.8, restored with arcs about the axis and straight sides:
/// ½ · √125 · ∫₀^0.8 (5 + 5t) dt = ½ · √125 · 5.6.
#[test]
fn a_trimmed_cone_face_is_restored_to_its_span() {
let line = vec![Seg2::Line { a: Vec2::new(5.0, 0.0), b: Vec2::new(10.0, 10.0) }];
let sheet = crate::revolve_sheet(&Plane::XZ, &[line], Vec2::ZERO, Vec2::Y, 0.0, 1.0).unwrap();
let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, -8.0), Vec3::new(0.0, -0.5, 1.0)).unwrap());
let (c, s) = (0.25f64.cos(), 0.25f64.sin());
let cut = crate::trim(&sheet, &tool, Vec3::new(5.0 * c, 5.0 * s, 0.0)).unwrap();
let face = face_at(&cut, Vec3::new(5.5 * c, 5.5 * s, -1.0));
let restored = untrim(&cut, face, UntrimHoles::All, true, false).unwrap();
let want = 0.5 * 125f64.sqrt() * 5.6;
let a = measure(&restored).unwrap().area;
assert!(rel(a, want) < 1e-3, "{a} vs {want}"); // (measured on meshes)
let bb = measure(&restored).unwrap().bbox;
assert!((bb.min.z + 8.0).abs() < 1e-6 && bb.max.z.abs() < 1e-6, "{bb:?}");
assert!(matches!(crate::face_surface(&restored, 0), Some(crate::FaceSurface::Cone { .. })));
}
/// A torus face (a revolved arc) with a piece of its outer equator cut off by the plane
/// x = 12: the cut touches one side of the face only, so its span is the face's whole span
/// and the restored face is the face as it was before the cut.
#[test]
fn a_torus_face_cut_at_one_side_is_restored_whole() {
let torus = crate::torus(Vec3::ZERO, 10.0, 3.0).unwrap();
let f = torus.solid.face_iter().next().unwrap().clone();
let face = surface_body(vec![f]);
let before = measure(&face).unwrap().area;
let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(12.0, 0.0, 0.0), Vec3::X).unwrap());
let cut = crate::trim(&face, &tool, Vec3::new(0.0, 13.0, 0.0)).unwrap();
let trimmed = measure(&cut).unwrap().area;
assert!(trimmed < before * 0.99, "{trimmed} vs {before}");
let restored = untrim(&cut, 0, UntrimHoles::All, true, false).unwrap();
let a = measure(&restored).unwrap().area;
assert!(rel(a, before) < 1e-3, "{a} vs {before}"); // (measured on meshes)
assert!(matches!(
crate::face_surface(&restored, 0),
Some(crate::FaceSurface::Torus { major, minor, .. }) if (major - 10.0).abs() < 1e-6 && (minor - 3.0).abs() < 1e-6
));
}
/// A free-form face goes back to its surface's own parameter box: half of the saddle
/// z = x y / 10 over 0…10 × 0…10 (a bilinear patch, its iso-lines straight), x from 0 to 5,
/// restored to the whole patch: x and y 0…10, z 0…10.
#[test]
fn a_free_form_face_is_restored_to_its_surfaces_bounds() {
let pt = |x: f64, y: f64| mt::Point3::new(x, y, x * y / 10.0);
let knots = mt::KnotVec::bezier_knot(1);
let saddle = mt::BSplineSurface::new((knots.clone(), knots), vec![vec![pt(0.0, 0.0), pt(0.0, 10.0)], vec![pt(10.0, 0.0), pt(10.0, 10.0)]]);
let corners = [pt(0.0, 0.0), pt(5.0, 0.0), pt(5.0, 10.0), pt(0.0, 10.0)];
let v: Vec<mt::Vertex> = corners.iter().map(|p| mt::Vertex::new(*p)).collect();
let edges: Vec<mt::Edge> =
(0..4).map(|i| mt::Edge::new_unchecked(&v[i], &v[(i + 1) % 4], mt::Curve::Line(mt::Line(corners[i], corners[(i + 1) % 4])))).collect();
let half = surface_body(vec![mt::Face::new_unchecked(vec![edges.into()], mt::Surface::BSplineSurface(saddle))]);
assert!(crate::face_surface(&half, 0).is_none(), "not analytic");
assert!((measure(&half).unwrap().bbox.max.x - 5.0).abs() < 1e-9);
let whole = untrim(&half, 0, UntrimHoles::All, true, false).unwrap();
let bb = measure(&whole).unwrap().bbox;
assert!(bb.min.x.abs() < 1e-9 && bb.min.y.abs() < 1e-9 && bb.min.z.abs() < 1e-9, "{bb:?}");
assert!((bb.max.x - 10.0).abs() < 1e-9 && (bb.max.y - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}");
assert_eq!(whole.solid.face_iter().next().unwrap().boundaries().first().map(|w| w.len()), Some(4));
}
/// The plate exported to STEP and read back: its imported top face untrims to the whole
/// rectangle as the plate's own did.
#[test]
fn an_imported_step_face_is_untrimmed() {
let text = crate::step_export(&[&plate()], "untrim test").unwrap();
let imported = crate::step_import(&text).unwrap();
let b = &imported.bodies.first().unwrap().body;
let top = face_at(b, Vec3::new(5.0, 5.0, 2.0));
assert_eq!(b.solid.face_iter().nth(top).unwrap().boundaries().len(), 4);
let all = untrim(b, top, UntrimHoles::All, false, false).unwrap();
assert!(all.face_count() == 1 && inner_loops(&all) == 0);
assert!(rel(measure(&all).unwrap().area, 800.0) < 1e-9);
let restored = untrim(b, top, UntrimHoles::All, true, false).unwrap();
assert!(rel(measure(&restored).unwrap().area, 800.0) < 1e-9);
}
/// In place: a surface body of the plate's top and bottom keeps its bottom (and its three
/// holes), the top replaced by the whole rectangle: 800 + 800 − 3 · 4π. A solid's face is
/// copied out whatever `in_place` says.
#[test]
fn in_place_untrims_a_surface_and_copies_a_solids_face() {
let b = plate();
let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0));
let copied = untrim(&b, top, UntrimHoles::All, false, true).unwrap();
assert_eq!(copied.face_count(), 1);
let sheet = crate::copy_faces(&b, &[Vec3::new(5.0, 5.0, 2.0), Vec3::new(5.0, 5.0, 0.0)]).unwrap();
assert!(sheet.is_surface() && sheet.face_count() == 2 && inner_loops(&sheet) == 6);
let top = face_at(&sheet, Vec3::new(5.0, 5.0, 2.0));
let whole = untrim(&sheet, top, UntrimHoles::All, false, true).unwrap();
assert!(whole.is_surface());
assert_eq!((whole.face_count(), inner_loops(&whole)), (2, 3));
let want = 1600.0 - 3.0 * 4.0 * PI;
assert!(rel(measure(&whole).unwrap().area, want) < 1e-3, "{} vs {want}", measure(&whole).unwrap().area); // (measured on meshes)
}
/// Points that are not finite and too many picks are refused, never panic.
#[test]
fn hostile_inputs_are_refused() {
let b = plate();
let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0));
assert!(untrim(&b, top, UntrimHoles::At(&[Vec3::new(f64::NAN, 0.0, 0.0)]), false, false).is_err());
let many = vec![Vec3::ZERO; 10_001];
assert!(untrim(&b, top, UntrimHoles::At(&many), false, false).is_err());
}
}