//! 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.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 { 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 { 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 = wires.iter().map(|w| sample_loop(&eval, &dom, w, accept)).collect::>()?; // The outer loop encloses the others: the most area in parameters. let areas: Vec = 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 = (0..wires.len()).filter(|i| *i != outer_index).collect(); let removed: Vec = 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 = 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 { 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 { 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 = 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 = 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 { 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 = 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 = corners.iter().map(|p| mt::Vertex::new(*p)).collect(); let edges: Vec = (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()); } }