//! Fairing the control points of a B-spline curve or surface: moving the interior control //! vertices to the least-bending shape while the ends, or the boundary rows, stay exactly where //! the design put them. //! //! The fairness energy is the thin-plate (curvature-variation) energy of the *geometry* the //! control net describes, in the net's own parameter directions: //! //! - a curve: `E = ∫‖C″(t)‖² dt`, //! - a surface: `E = ∫∫ ‖S_uu‖² + 2‖S_uv‖² + ‖S_vv‖² du dv`. //! //! A plane has energy zero and anything else pays for how much it bends, which is what a //! designer means by "fair". Both energies are quadratic in the control points, `E(P) = Pᵀ·G·P`, //! with `G` the Gram matrix of the basis functions' derivatives (`Gᵢⱼ = ∫Nᵢ″Nⱼ″ dt`, and the //! tensor products of the one-direction Gram matrices for a surface). Each one-direction Gram is //! integrated span by span with four-point Gauss–Legendre, exact to degree 7 — a cubic's second //! derivative products are quadratic, so the cubic curves and bicubic faces fairing meets in //! practice are integrated exactly. //! //! One fairing step is then a constrained least-squares solve: hold the boundary control points, //! take the exact minimiser of `E` over the interior (for a quadratic that is one Newton step, //! `G_ff·d = −(G·P)_f`), and apply a fraction `strength` of it, so `strength = 1` is the full //! solution and a smaller value walks towards it. With the exact step the energy falls by //! `q·s·(1 − s)` for `q ≥ 0`, so a run at any strength decreases it strictly until it converges. //! `G_ff` does not change as the control points move, so it is factorised once, by banded //! Cholesky, and every further step is a matrix–vector product and a triangular solve. //! //! Two guarantees are built in rather than checked afterwards: //! //! - **The boundary never moves.** The result starts as a copy of the input and only interior //! entries are written, so a held control point is bit-identical to the one the design gave. //! - **The geometry cannot jump.** A B-spline lies in the convex hull of its control points //! (NURBS Book §3.1; with the weights unchanged the rational basis functions are still //! non-negative and sum to one), so the geometry moves at most as far as the farthest control //! point. Every step is clamped so that no control point leaves the ball of radius //! `tolerance` about where it started: the faired curve or surface is then within `tolerance` //! of the input at the same parameters, by a bound rather than by measurement. A step that //! would not lower the energy is halved and, if it still would not, the run stops — it never //! loops for ever and never moves the design further than the caller allowed. //! //! Everything is checked before it is used: degrees, knot vectors and counts must be consistent, //! the control points finite, `strength ∈ (0, 1]`, `iterations` in `1…`[`MAX_FAIR_ITERATIONS`] and //! `tolerance > 0`. A singular system — fewer than two interior control points, or an interior //! control point that does not change the geometry at all — is reported as an error and the //! geometry is left alone. use solidcraft_geom::Vec3; use solidcraft_geom::bspline::{HPoint, MAX_SPLINE_DEGREE, NurbsSurface, Side, SplineError, check_knots, local_basis}; use truck_modeling as mt; use crate::body::Body; use crate::history::Change; use crate::{KernelError, Result, guard}; /// Most steps one fairing run takes (see [`FairOptions::iterations`]). pub const MAX_FAIR_ITERATIONS: usize = 1000; /// Most control points one fairing run accepts (a 64 × 64 net): plenty for any face's control /// net, and small enough that a step stays cheap whatever a design file contains. pub const MAX_FAIR_POINTS: usize = 4096; /// Most entries of the banded system (2²¹ doubles, 16 MB). const MAX_FAIR_BAND: usize = 1 << 21; /// The fairness energy of a curve, as a one-direction net: the `(order in u, order in v, weight)` /// term list of `∫∫ (∂S)²`. const CURVE_ENERGY: [(usize, usize, f64); 1] = [(2, 0, 1.0)]; /// The thin-plate energy of a surface: `‖S_uu‖² + 2‖S_uv‖² + ‖S_vv‖²` — the classic /// curvature-variation energy, zero on a plane and the same if u and v are swapped. const SURFACE_ENERGY: [(usize, usize, f64); 3] = [(2, 0, 1.0), (1, 1, 2.0), (0, 2, 1.0)]; /// Four-point Gauss–Legendre nodes and weights on `[−1, 1]` (exact to degree 7). const GAUSS4: [(f64, f64); 4] = [ (-0.861_136_311_594_052_6, 0.347_854_845_137_453_8), (-0.339_981_043_584_856_3, 0.652_145_154_862_546_2), (0.339_981_043_584_856_3, 0.652_145_154_862_546_2), (0.861_136_311_594_052_6, 0.347_854_845_137_453_8), ]; /// The second direction of a curve seen as a control net: one basis function of degree 0 over /// `[0, 1]`, whose Gram entry is exactly `∫1 dt = 1`, so the net's energy is the curve's own /// energy unchanged. const TRIVIAL_KNOTS: [f64; 2] = [0.0, 1.0]; /// How often a step is halved before the run calls the point converged. const MAX_HALVINGS: u32 = 40; /// A step smaller than this fraction of the full Newton step is not worth taking. const MIN_STEP: f64 = 1e-12; /// Steps are clamped this far inside the tolerance, so rounding cannot push a control point /// past it. const TOLERANCE_SLACK: f64 = 1.0 - 1e-12; /// How a fairing run is asked to behave. #[derive(Clone, Copy, Debug, PartialEq)] pub struct FairOptions { /// The fraction of each energy step to apply, `0 < strength ≤ 1`: `1` is the full least /// squares solution (the exact minimiser of the energy at the held boundary), a smaller /// value takes that fraction of the way there. The energy falls at every step either way. pub strength: f64, /// How many steps at most, one to [`MAX_FAIR_ITERATIONS`]. pub iterations: usize, /// Hold the layer of control points next to the boundary as well as the boundary itself (two /// layers held at each end). For a clamped knot vector the boundary row fixes the position /// along the edge and the row next to it fixes the derivative across the edge, so with this /// set the boundary keeps the position and the tangent the design gave it. pub keep_edges: bool, /// The farthest a control point may move from where the design put it, in mm. The geometry /// then stays within this distance of the input at the same parameters (see the module /// docs), so a fairing run can never make a design jump. pub tolerance: f64, } impl FairOptions { /// Checked options for one fairing run: an error, never a panic, for a strength outside /// `(0, 1]`, zero iterations, more than [`MAX_FAIR_ITERATIONS`] of them, or a tolerance that /// is not a positive finite number. pub fn new(strength: f64, iterations: usize, keep_edges: bool, tolerance: f64) -> Result { FairOptions { strength, iterations, keep_edges, tolerance }.checked() } /// The same checks as [`FairOptions::new`], for options built field by field. pub fn checked(self) -> Result { if !(self.strength.is_finite() && self.strength > 0.0 && self.strength <= 1.0) { return Err(KernelError::Invalid(format!("the fairing strength {} must be greater than 0 and at most 1", self.strength))); } if self.iterations == 0 || self.iterations > MAX_FAIR_ITERATIONS { return Err(KernelError::Invalid(format!("{} fairing iterations (1 to {MAX_FAIR_ITERATIONS})", self.iterations))); } if !(self.tolerance.is_finite() && self.tolerance > 0.0) { return Err(KernelError::Invalid(format!("the fairing tolerance {} must be a positive number of mm", self.tolerance))); } Ok(self) } } impl Default for FairOptions { /// One full step with the boundary held, at most 1 mm of movement: the defaults a command can /// start from before a user picks their own. fn default() -> Self { FairOptions { strength: 1.0, iterations: 1, keep_edges: false, tolerance: 1.0 } } } /// What a fairing run did: the energy before and after, how many steps were applied, how far the /// control points moved and the energy after every applied step. The energy is a sum of squares, /// so where rounding leaves it a hair below zero the true value is zero: it is reported as zero. #[derive(Clone, Debug, PartialEq)] pub struct FairReport { /// The fairness energy of the input, in the parameter units of the net's own knots. pub energy_before: f64, /// The fairness energy of the result: never more than `energy_before`, and equal to it only /// when no step could lower it. pub energy_after: f64, /// How many steps were applied (at most the requested `iterations`). pub steps: usize, /// The farthest any control point moved from where the design put it, in mm. It bounds how /// far the geometry moved from the input (see the module docs) and never exceeds the /// requested tolerance. pub movement: f64, /// The energy after each applied step, the last equal to `energy_after`: it never rises, and /// falls strictly at every step until the run reaches the rounding of its own arithmetic. pub energy_trace: Vec, } /// A faired B-spline curve: the input's degree and knots, the moved control points and the run's /// report. #[derive(Clone, Debug, PartialEq)] pub struct FairCurve { pub degree: usize, pub knots: Vec, /// The faired control points; the ends (and, with `keep_edges`, the points next to them) are /// bit-identical to the input. pub ctrl: Vec<[f64; N]>, pub report: FairReport, } /// A faired B-spline control net: the input's degrees, knots and counts, the moved control points /// (u fastest) and the run's report. #[derive(Clone, Debug, PartialEq)] pub struct FairNet { pub degree: [usize; 2], pub knots: [Vec; 2], pub count: [usize; 2], /// The faired control points, u fastest; the held boundary layers are bit-identical to the /// input. pub ctrl: Vec<[f64; N]>, pub report: FairReport, } /// A geom spline error as the kernel's. fn spline_error(what: &str, e: SplineError) -> KernelError { match e { SplineError::TooLarge(m) => KernelError::TooLarge(format!("{what}: {m}")), other => KernelError::Invalid(format!("{what}: {other}")), } } /// The dot product of two control points' coordinate vectors. fn dot(a: &[f64; N], b: &[f64; N]) -> f64 { a.iter().zip(b).map(|(x, y)| x * y).sum() } /// The distance between two control points. fn dist(a: &[f64; N], b: &[f64; N]) -> f64 { let d: [f64; N] = std::array::from_fn(|c| a[c] - b[c]); dot(&d, &d).sqrt() } /// The Gram matrices of one parameter direction, stored banded: entry `r` is /// `∫ Nᵢ^{(r)}(t)·Nₖ^{(r)}(t) dt` over the direction's domain, and since a basis function /// overlaps only its `degree` neighbours an entry is non-zero only for `|i − k| ≤ degree`, so /// the whole thing is `count·(degree + 1)` numbers per order. struct Gram { count: usize, bw: usize, g: [Vec; 3], } impl Gram { /// The Gram matrices of `count` basis functions of `degree` over `knots`, for orders 0, 1 and /// 2 — the derivatives a thin-plate energy is built from. fn new(degree: usize, knots: &[f64], count: usize) -> Result { let bw = degree; let mut g: [Vec; 3] = std::array::from_fn(|_| vec![0.0; count * (bw + 1)]); // On the span `[knots[s], knots[s + 1])` only the basis functions `i ∈ [s − degree, s]` // are non-zero. The quadrature nodes lie strictly inside the span, so either one-sided // limit gives the same value there. let mut ders = [[0.0f64; 3]; MAX_SPLINE_DEGREE + 1]; for s in degree..count { let (Some(&a), Some(&b)) = (knots.get(s), knots.get(s + 1)) else { continue }; if !(b > a) { continue; } for &(x, w) in GAUSS4.iter() { let t = 0.5 * (a + b) + 0.5 * (b - a) * x; let first = s - degree; let mut live = 0usize; for i in first..=s { let Some(local) = knots.get(i..=i + degree + 1) else { break }; let d = local_basis(local, t, Side::Right, 2); if let Some(slot) = ders.get_mut(live) { for (o, v) in slot.iter_mut().zip(d.iter()) { *o = *v; } } live += 1; } let weight = 0.5 * (b - a) * w; let value = |rel: usize, r: usize| ders.get(rel).and_then(|d| d.get(r)).copied().unwrap_or(0.0); // The upper triangle only — the band holds one copy of a symmetric entry — and at // the basis functions' own indices, not their places in this span. for i in 0..live { for k in i..live { let (row, off) = (first + i, k - i); for (r, gg) in g.iter_mut().enumerate() { if let Some(slot) = gg.get_mut(row * (bw + 1) + off) { *slot += weight * value(i, r) * value(k, r); } } } } } } if g.iter().flatten().any(|v| !v.is_finite()) { return Err(KernelError::Invalid("the fairness energy of this net is not a finite number".into())); } Ok(Gram { count, bw, g }) } /// `∫ Nᵢ^{(r)} Nₖ^{(r)} dt`, zero outside the band or off the end of the basis. fn at(&self, r: usize, i: usize, k: usize) -> f64 { let Some(m) = self.g.get(r) else { return 0.0 }; let (row, off) = if i <= k { (i, k - i) } else { (k, i - k) }; if off > self.bw || row >= self.count { return 0.0; } m.get(row * (self.bw + 1) + off).copied().unwrap_or(0.0) } } /// A control net to fair: a tensor-product B-spline with the terms of its fairness energy. A /// direction of one control point (degree 0, knots `[0, 1]`) makes the net a curve, whose energy /// is the one-direction [`CURVE_ENERGY`]. struct Net { degree: [usize; 2], count: [usize; 2], /// The number of control points, `count[0] · count[1]`. total: usize, gram: [Gram; 2], terms: &'static [(usize, usize, f64)], } impl Net { /// Check the degrees, knots and counts and build the Gram matrices: an error, never a panic, /// for a degree above [`MAX_SPLINE_DEGREE`], too few control points for the degree, a knot /// vector of the wrong length, unusable knots or an empty parameter range. fn new(degree: [usize; 2], knots: [Vec; 2], count: [usize; 2], terms: &'static [(usize, usize, f64)]) -> Result { let Some(total) = count[0].checked_mul(count[1]).filter(|&n| n <= MAX_FAIR_POINTS) else { return Err(KernelError::TooLarge(format!("fairing {} × {} control points (at most {MAX_FAIR_POINTS} in all)", count[0], count[1]))); }; for d in 0..2 { let (p, c, kn) = (degree[d], count[d], &knots[d]); if p > MAX_SPLINE_DEGREE { return Err(KernelError::TooLarge(format!("fairing a spline of degree {p} (at most {MAX_SPLINE_DEGREE})"))); } if c <= p { return Err(KernelError::Invalid(format!("{c} control points is too few across one direction of a spline of degree {p}"))); } if kn.len() != c + p + 1 { return Err(KernelError::Invalid(format!( "{} knots across one direction, expected {} for {c} control points of degree {p}", kn.len(), c + p + 1 ))); } check_knots(kn).map_err(|e| spline_error("fairing", e))?; let (lo, hi) = (kn.get(p).copied().unwrap_or(f64::NAN), kn.get(c).copied().unwrap_or(f64::NAN)); if !(lo < hi) { return Err(KernelError::Invalid("the fairing's parameter range is empty".into())); } } if terms.iter().any(|&(ru, rv, w)| ru > degree[0] || rv > degree[1] || !(w.is_finite() && w >= 0.0)) { return Err(KernelError::Invalid("a fairness term does not fit the net's degrees".into())); } let gram = [Gram::new(degree[0], &knots[0], count[0])?, Gram::new(degree[1], &knots[1], count[1])?]; Ok(Net { degree, count, total, gram, terms }) } /// The index of control point `(i, j)` in the flattened net (u fastest). fn index(&self, i: usize, j: usize) -> usize { j * self.count[0] + i } /// The fairness energy `Pᵀ·G·P` of the net `ctrl` (u fastest) and, in `grad`, `G·P`. One /// banded sweep gives both, since `E = Σ Pₚ·(G·P)ₚ`. fn energy_gradient(&self, ctrl: &[[f64; N]], grad: &mut [[f64; N]]) -> f64 { let [cu, cv] = self.count; let (pu, pv) = (self.degree[0], self.degree[1]); let mut energy = 0.0; for j in 0..cv { for i in 0..cu { let mut acc = [0.0f64; N]; for &(ru, rv, w) in self.terms { for l in j.saturating_sub(pv)..=(j + pv).min(cv - 1) { for k in i.saturating_sub(pu)..=(i + pu).min(cu - 1) { let a = w * self.gram[0].at(ru, i, k) * self.gram[1].at(rv, j, l); if a == 0.0 { continue; } if let Some(p) = ctrl.get(self.index(k, l)) { for (x, v) in acc.iter_mut().zip(p) { *x += a * v; } } } } } let at = self.index(i, j); if let Some(c) = ctrl.get(at) { energy += dot(c, &acc); } if let Some(g) = grad.get_mut(at) { *g = acc; } } } energy } /// The energy matrix `G` over the interior control points, banded and already factorised (the /// matrix does not change as the control points move, so one factorisation serves every /// step). In the u-fastest order two unknowns couple within `degree_u + degree_v · cols`: /// `degree_u` along a row, `degree_v` rows apart. fn system(&self, interior: &Interior) -> Result { let [cu, cv] = self.count; let (pu, pv) = (self.degree[0], self.degree[1]); let rows = if cv > 1 { interior.rows.saturating_sub(1).min(pv) } else { 0 }; let mut band = Band::new(interior.points.len(), pu + rows * interior.cols)?; for (r, &at) in interior.points.iter().enumerate() { let (i, j) = (at % cu, at / cu); for &(ru, rv, w) in self.terms { for l in j.saturating_sub(pv)..=(j + pv).min(cv - 1) { for k in i.saturating_sub(pu)..=(i + pu).min(cu - 1) { let Some(s) = interior.slot.get(self.index(k, l)).copied().flatten() else { continue }; band.add(r, s, w * self.gram[0].at(ru, i, k) * self.gram[1].at(rv, j, l)); } } } } band.factorised() } /// Fair `ctrl` (u fastest) with checked `opts`: see the module docs. The result is the input /// with the interior control points moved; the held ones are bit-identical. fn fair(&self, ctrl: &[[f64; N]], opts: &FairOptions) -> Result<(Vec<[f64; N]>, FairReport)> { if ctrl.len() != self.total { return Err(KernelError::Invalid(format!("{} control points, expected {}", ctrl.len(), self.total))); } if ctrl.iter().flatten().any(|x| !x.is_finite()) { return Err(KernelError::Invalid("a control point is not a finite number".into())); } let interior = Interior::new(self, if opts.keep_edges { 2 } else { 1 })?; let system = self.system(&interior)?; let mut current: Vec<[f64; N]> = ctrl.to_vec(); let mut grad = vec![[0.0; N]; self.total]; let energy_before = self.energy_gradient(¤t, &mut grad); if !energy_before.is_finite() { return Err(KernelError::Invalid("the fairness energy of this net is not a finite number".into())); } // The energy is a sum of squares, so where rounding leaves it a hair below zero the true // value is zero: report it that way rather than as a negative number. let energy_before = energy_before.max(0.0); // How big the net itself is, for telling a fairing step from rounding (see below). let size = interior.size(ctrl); let mut step = vec![[0.0; N]; interior.points.len()]; let mut b = vec![0.0; interior.points.len()]; let mut report = FairReport { energy_before, energy_after: energy_before, steps: 0, movement: 0.0, energy_trace: Vec::new() }; for _ in 0..opts.iterations { let e_now = self.energy_gradient(¤t, &mut grad); if !e_now.is_finite() { break; } // The exact minimiser over the interior is the Newton step `G_ff·d = −(G·P)_f`; the // system is per coordinate, all three sharing the one factorisation. for c in 0..N { for (slot, &at) in interior.points.iter().enumerate() { b[slot] = -grad.get(at).and_then(|g| g.get(c)).copied().unwrap_or(0.0); } system.solve(&mut b)?; for (slot, v) in b.iter().enumerate() { if let Some(d) = step.get_mut(slot).and_then(|d| d.get_mut(c)) { *d = *v; } } } if step.iter().flatten().all(|&x| x == 0.0) { break; } // The largest fraction of the step that keeps every control point within the // tolerance of where the design put it, and never more than the whole step. let mut s = opts.strength.min(interior.limit(ctrl, ¤t, &step, opts.tolerance)); if !(s > 0.0) { break; } // A step this much shorter than the net's own size cannot be told from the rounding // of the arithmetic that found it: the net is as fair as it gets. let longest = step.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs())); if s * longest <= MIN_STEP * size { break; } let mut taken = false; for _ in 0..MAX_HALVINGS { let candidate = interior.apply(¤t, &step, s); let e = self.energy_gradient(&candidate, &mut grad); if e < e_now { current = candidate; report.steps += 1; report.energy_trace.push(e.max(0.0)); taken = true; break; } // The step did not lower the energy: halve it and try again, and when even a hair // of a step will not, this is as fair as the net gets. s *= 0.5; if s <= MIN_STEP { break; } } if !taken { break; } } report.energy_after = report.energy_trace.last().copied().unwrap_or(energy_before); report.movement = interior.movement(ctrl, ¤t); Ok((current, report)) } } /// Which control points a fairing run may move, and where each sits in the banded system. struct Interior { /// The flattened index (u fastest) of interior point number `k`. points: Vec, /// The interior number of each flattened index, `None` where the point is held. slot: Vec>, /// How many control points across u (`cols`) and across v (`rows`) are interior. cols: usize, rows: usize, } impl Interior { /// The interior of `net` with `keep` layers held at each end of each direction (1 without /// [`FairOptions::keep_edges`], 2 with it). fn new(net: &Net, keep: usize) -> Result { let [cu, cv] = net.count; let (i0, i1) = free_span(cu, keep)?; let (j0, j1) = free_span(cv, keep)?; let (cols, rows) = (i1 - i0, j1 - j0); let mut points = Vec::with_capacity(cols * rows); let mut slot = vec![None; net.total]; for j in j0..j1 { for i in i0..i1 { if let Some(s) = slot.get_mut(net.index(i, j)) { *s = Some(points.len()); } points.push(net.index(i, j)); } } if points.len() < 2 { return Err(KernelError::Invalid(format!( "fairing needs at least two interior control points; this {cu} × {cv} net has {}", points.len() ))); } Ok(Interior { points, slot, cols, rows }) } /// `current` with `s` times the step `d` added to the interior control points; the held ones /// are copied as they are, bit for bit. fn apply(&self, current: &[[f64; N]], d: &[[f64; N]], s: f64) -> Vec<[f64; N]> { let mut out = current.to_vec(); for (slot, &at) in self.points.iter().enumerate() { let (Some(p), Some(v)) = (out.get_mut(at), d.get(slot)) else { continue }; for (x, dx) in p.iter_mut().zip(v) { *x += s * dx; } } out } /// How big the interior is: the farthest interior control point from the first one, in mm. /// A fairing step far below this belongs to the rounding of the arithmetic, not the design. fn size(&self, ctrl: &[[f64; N]]) -> f64 { let Some(first) = self.points.first().and_then(|&at| ctrl.get(at)) else { return 0.0 }; self.points.iter().fold(0.0f64, |m, &at| ctrl.get(at).map_or(m, |p| m.max(dist(first, p)))) } /// The largest fraction `s ≤ 1` of the step `d` that keeps every interior control point /// within `tolerance` of where the design put it (`input`). The movement is `‖Δ + s·d‖ ≤ tol` /// per point — a quadratic in `s` opening upwards, with the accumulated movement /// `Δ = current − input` already inside the ball, so the bound is its positive root. fn limit(&self, input: &[[f64; N]], current: &[[f64; N]], d: &[[f64; N]], tolerance: f64) -> f64 { let mut s = 1.0f64; for (slot, &at) in self.points.iter().enumerate() { let (Some(dv), Some(cv), Some(iv)) = (d.get(slot), current.get(at), input.get(at)) else { continue }; let a = dot(dv, dv); if a <= 0.0 { continue; } let delta: [f64; N] = std::array::from_fn(|c| cv[c] - iv[c]); let b = 2.0 * dot(&delta, dv); let c = dot(&delta, &delta) - tolerance * tolerance; let disc = b * b - 4.0 * a * c; let root = if disc >= 0.0 { (-b + disc.sqrt()) / (2.0 * a) } else { 0.0 }; // `f64::max` ignores a NaN, so a step whose arithmetic overflows stops the run // instead of leaking a not-a-number into a control point. s = s.min(root.max(0.0)); } (s * TOLERANCE_SLACK).clamp(0.0, 1.0) } /// The farthest any interior control point moved from the design's own position, in mm. fn movement(&self, input: &[[f64; N]], current: &[[f64; N]]) -> f64 { self.points.iter().fold(0.0f64, |m, &at| match (input.get(at), current.get(at)) { (Some(a), Some(b)) => m.max(dist(a, b)), _ => m, }) } } /// The control points of one direction a fairing run may move, as `[lo, hi)`. A direction of one /// control point — a curve seen as a net one row deep — is all interior; otherwise `keep` layers /// are held at each end: the boundary, and with `keep = 2` the layer next to it. fn free_span(count: usize, keep: usize) -> Result<(usize, usize)> { if count == 1 { return Ok((0, 1)); } if count <= 2 * keep { return Err(KernelError::Invalid(format!( "fairing a net {count} control points across one direction and holding {keep} at each edge leaves nothing to move" ))); } Ok((keep, count - keep)) } /// A symmetric banded matrix in upper-triangular form: row `r` holds columns `r ..= r + bw`. struct Band { n: usize, bw: usize, /// The upper band, `n · (bw + 1)` numbers; the scaling by the largest diagonal is folded in /// when the matrix is factorised. a: Vec, scale: f64, } impl Band { fn new(n: usize, bw: usize) -> Result { let Some(len) = (bw + 1).checked_mul(n).filter(|&l| l <= MAX_FAIR_BAND) else { return Err(KernelError::TooLarge(format!("a fairing system of {n} unknowns {bw} apart"))); }; Ok(Band { n, bw, a: vec![0.0; len], scale: 1.0 }) } fn index(&self, r: usize, s: usize) -> Option { let (r, s) = if r <= s { (r, s) } else { (s, r) }; (s - r <= self.bw && s < self.n).then_some(r * (self.bw + 1) + (s - r)) } fn get(&self, r: usize, s: usize) -> f64 { self.index(r, s).and_then(|i| self.a.get(i)).copied().unwrap_or(0.0) } fn set(&mut self, r: usize, s: usize, v: f64) { if let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) { *x = v; } } /// Add `v` at `(r, s)`. Only `r ≤ s` is stored (the matrix is symmetric, so callers adding a /// whole symmetric neighbourhood reach each stored entry once). fn add(&mut self, r: usize, s: usize, v: f64) { if r <= s && let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) { *x += v; } } /// `A = Uᵀ·U` in place, the entries scaled by the largest diagonal first (the scaling does /// not change the solution, only the size of the pivots, so a net whose knots span microns or /// kilometres factorises just as well). An error, never a panic, when a pivot is not /// positive: the system is singular there and the geometry must not move. fn factorised(mut self) -> Result { let (n, bw) = (self.n, self.bw); let scale = (0..n).fold(0.0f64, |m, r| m.max(self.get(r, r).abs())); if !(scale > 0.0) || !scale.is_finite() { return Err(KernelError::Invalid("the fairness energy does not depend on any interior control point: nothing to fair".into())); } self.scale = scale; for v in self.a.iter_mut() { *v /= scale; } for r in 0..n { let mut d = self.get(r, r); for k in r.saturating_sub(bw)..r { let u = self.get(k, r); d -= u * u; } if !(d > 0.0) { return Err(KernelError::Invalid( "the fairing equations are singular (an interior control point that does not move the geometry)".into(), )); } let d = d.sqrt(); self.set(r, r, d); for s in r + 1..(r + bw + 1).min(n) { let mut v = self.get(r, s); for k in s.saturating_sub(bw)..r { v -= self.get(k, r) * self.get(k, s); } self.set(r, s, v / d); } } Ok(self) } /// Solve `A·x = b` with the stored factorisation, in place. fn solve(&self, b: &mut [f64]) -> Result<()> { let (n, bw) = (self.n, self.bw); if b.len() != n { return Err(KernelError::Invalid(format!("{} fairing unknowns for a system of {n}", b.len()))); } for x in b.iter_mut() { *x /= self.scale; } // Uᵀ·y = b, then U·x = y. for r in 0..n { let mut v = b.get(r).copied().unwrap_or(0.0); for k in r.saturating_sub(bw)..r { v -= self.get(k, r) * b.get(k).copied().unwrap_or(0.0); } if let Some(x) = b.get_mut(r) { *x = v / self.get(r, r); } } for r in (0..n).rev() { let mut v = b.get(r).copied().unwrap_or(0.0); for s in r + 1..(r + bw + 1).min(n) { v -= self.get(r, s) * b.get(s).copied().unwrap_or(0.0); } if let Some(x) = b.get_mut(r) { *x = v / self.get(r, r); } } if b.iter().any(|x| !x.is_finite()) { return Err(KernelError::Invalid("the fairing step is not a finite number".into())); } Ok(()) } } /// Fair a B-spline curve's control points of any dimension: hold control points `0` and `n − 1` /// and (with [`FairOptions::keep_edges`]) those next to them, and move the rest to lower the /// bending energy `∫‖C″‖² dt` in the curve's own parameter (see the module docs). `N` is 3 for a /// wire in space, 2 for a sketch curve. pub fn fair_curve(degree: usize, knots: &[f64], ctrl: &[[f64; N]], opts: &FairOptions) -> Result> { let opts = opts.checked()?; let net = Net::new([degree, 0], [knots.to_vec(), TRIVIAL_KNOTS.to_vec()], [ctrl.len(), 1], &CURVE_ENERGY)?; let (faired, report) = net.fair(ctrl, &opts)?; Ok(FairCurve { degree, knots: knots.to_vec(), ctrl: faired, report }) } /// Fair the interior of a B-spline control net with the thin-plate energy /// `∫∫ ‖S_uu‖² + 2‖S_uv‖² + ‖S_vv‖² du dv` in the net's own parameters (see the module docs). /// `count[0]` control points lie along u and `count[1]` along v, `ctrl` is u fastest, and the /// boundary layers (see [`FairOptions::keep_edges`]) are held. The generic core behind /// [`fair_surface`], [`fair_bspline_surface`] and their rational counterparts, for a caller with /// its own control net. pub fn fair_net( degree: [usize; 2], knots: [&[f64]; 2], count: [usize; 2], ctrl: &[[f64; N]], opts: &FairOptions, ) -> Result> { let opts = opts.checked()?; let net = Net::new(degree, [knots[0].to_vec(), knots[1].to_vec()], count, &SURFACE_ENERGY)?; let (faired, report) = net.fair(ctrl, &opts)?; Ok(FairNet { degree, knots: [knots[0].to_vec(), knots[1].to_vec()], count, ctrl: faired, report }) } /// Fair a truck B-spline curve's control points: [`fair_curve`] on its three-dimensional points, /// rebuilt as a B-spline of the same knots. Guarded here itself, like every call into truck, so a /// panic inside comes back as an error and the design is left alone. pub fn fair_bspline_curve(curve: &mt::BSplineCurve, opts: &FairOptions) -> Result<(mt::BSplineCurve, FairReport)> { guard("fair a B-spline curve", || { let ctrl: Vec<[f64; 3]> = curve.control_points().iter().map(|p| [p.x, p.y, p.z]).collect(); let faired = fair_curve(curve.degree(), curve.knot_vec(), &ctrl, opts)?; let points: Vec = faired.ctrl.iter().map(|c| mt::Point3::new(c[0], c[1], c[2])).collect(); let curve = mt::BSplineCurve::try_new(mt::KnotVec::from(faired.knots), points).map_err(|e| KernelError::Failed(format!("fair: {e}")))?; Ok((curve, faired.report)) }) } /// Fair a truck rational curve's control points: its Cartesian control points are faired like any /// other curve and its weights are kept, so the result is the same rational curve with moved /// control points. The convex-hull bound that keeps the geometry within the tolerance still /// holds, because with the weights unchanged the rational basis functions are non-negative and /// sum to one. Guarded like [`fair_bspline_curve`]. pub fn fair_nurbs_curve(curve: &mt::NurbsCurve, opts: &FairOptions) -> Result<(mt::NurbsCurve, FairReport)> { guard("fair a NURBS curve", || { let count = curve.control_points().len(); let mut ctrl = Vec::with_capacity(count); let mut weights = Vec::with_capacity(count); for h in curve.control_points() { let Some(p) = (HPoint { x: h.x, y: h.y, z: h.z, w: h.w }).project() else { return Err(KernelError::Invalid("a control point has no position: its weight is zero".into())); }; ctrl.push([p.x, p.y, p.z]); weights.push(h.w); } let faired = fair_curve(curve.degree(), curve.knot_vec(), &ctrl, opts)?; let points: Vec = faired.ctrl.iter().zip(&weights).map(|(c, w)| mt::Vector4::new(c[0] * w, c[1] * w, c[2] * w, *w)).collect(); let bsp = mt::BSplineCurve::try_new(mt::KnotVec::from(faired.knots), points).map_err(|e| KernelError::Failed(format!("fair: {e}")))?; Ok((mt::NurbsCurve::new(bsp), faired.report)) }) } /// Fair a truck B-spline surface's control net: [`fair_net`] on its points, rebuilt as a B-spline /// of the same knots, with the boundary rows held. Guarded like [`fair_bspline_curve`]. pub fn fair_bspline_surface(surface: &mt::BSplineSurface, opts: &FairOptions) -> Result<(mt::BSplineSurface, FairReport)> { guard("fair a B-spline surface", || { let rows = surface.control_points(); let count = [rows.len(), rows.first().map_or(0, Vec::len)]; if count[0] == 0 || count[1] == 0 { return Err(KernelError::Invalid("the surface has no control points".into())); } if rows.iter().any(|r| r.len() != count[1]) { return Err(KernelError::Invalid("the surface's control net is not rectangular".into())); } // truck keeps the net as control_points()[i][j] with i along u; fair_net wants u fastest. let mut ctrl = Vec::with_capacity(count[0] * count[1]); for j in 0..count[1] { for i in 0..count[0] { let Some(p) = rows.get(i).and_then(|r| r.get(j)) else { continue }; ctrl.push([p.x, p.y, p.z]); } } let (ku, kv) = surface.knot_vecs(); let (pu, pv) = surface.degrees(); let faired = fair_net([pu, pv], [ku, kv], count, &ctrl, opts)?; let mut net: Vec> = Vec::with_capacity(count[0]); for i in 0..count[0] { let mut row = Vec::with_capacity(count[1]); for j in 0..count[1] { let Some(c) = faired.ctrl.get(j * count[0] + i) else { return Err(KernelError::Failed("fairing lost a control point".into())); }; row.push(mt::Point3::new(c[0], c[1], c[2])); } net.push(row); } let surface = mt::BSplineSurface::try_new((ku.clone(), kv.clone()), net).map_err(|e| KernelError::Failed(format!("fair: {e}")))?; Ok((surface, faired.report)) }) } /// Fair a geom NURBS surface's control net: [`fair_net`] on its Cartesian control points, with /// the weights kept as they are (a fairing moves points, not weights) and the boundary rows held. /// A rational surface's geometry then stays within the tolerance too, by the same convex-hull /// bound as the non-rational case. pub fn fair_surface(surface: &NurbsSurface, opts: &FairOptions) -> Result<(NurbsSurface, FairReport)> { let [cu, cv] = surface.count(); let mut ctrl = Vec::with_capacity(cu * cv); let mut weights = Vec::with_capacity(cu * cv); for j in 0..cv { for i in 0..cu { let h = surface.point(i, j); let Some(p) = h.project() else { return Err(KernelError::Invalid("a control point has no position: its weight is zero".into())); }; ctrl.push([p.x, p.y, p.z]); weights.push(h.w); } } let [ku, kv] = surface.knots(); let degree = surface.degree(); let faired = fair_net(degree, [ku, kv], [cu, cv], &ctrl, opts)?; let points: Vec = faired.ctrl.iter().zip(&weights).map(|(p, w)| HPoint::weighted(Vec3::new(p[0], p[1], p[2]), *w)).collect(); let out = NurbsSurface::from_homogeneous(degree, surface.knots().clone(), points).map_err(|e| spline_error("fairing the surface", e))?; Ok((out, faired.report)) } /// Fair a truck rational surface's control net, like [`fair_surface`]: the Cartesian control /// points are faired, the weights kept, and the result is a NURBS surface of the same knots. /// Guarded like [`fair_bspline_curve`]. pub fn fair_nurbs_surface(surface: &mt::NurbsSurface, opts: &FairOptions) -> Result<(mt::NurbsSurface, FairReport)> { guard("fair a NURBS surface", || { let bsp = surface.non_rationalized(); let rows = bsp.control_points(); let count = [rows.len(), rows.first().map_or(0, Vec::len)]; if count[0] == 0 || count[1] == 0 { return Err(KernelError::Invalid("the surface has no control points".into())); } if rows.iter().any(|r| r.len() != count[1]) { return Err(KernelError::Invalid("the surface's control net is not rectangular".into())); } let mut ctrl = Vec::with_capacity(count[0] * count[1]); let mut weights = Vec::with_capacity(count[0] * count[1]); for j in 0..count[1] { for i in 0..count[0] { let Some(h) = rows.get(i).and_then(|r| r.get(j)) else { continue }; let Some(p) = (HPoint { x: h.x, y: h.y, z: h.z, w: h.w }).project() else { return Err(KernelError::Invalid("a control point has no position: its weight is zero".into())); }; ctrl.push([p.x, p.y, p.z]); weights.push(h.w); } } let (ku, kv) = bsp.knot_vecs(); let (pu, pv) = bsp.degrees(); let faired = fair_net([pu, pv], [ku, kv], count, &ctrl, opts)?; let mut net: Vec> = Vec::with_capacity(count[0]); for i in 0..count[0] { let mut row = Vec::with_capacity(count[1]); for j in 0..count[1] { let (Some(c), Some(w)) = (faired.ctrl.get(j * count[0] + i), weights.get(j * count[0] + i)) else { return Err(KernelError::Failed("fairing lost a control point".into())); }; row.push(mt::Vector4::new(c[0] * w, c[1] * w, c[2] * w, *w)); } net.push(row); } let bsp = mt::BSplineSurface::try_new((ku.clone(), kv.clone()), net).map_err(|e| KernelError::Failed(format!("fair: {e}")))?; Ok((mt::NurbsSurface::new(bsp), faired.report)) }) } /// Fair the B-spline faces at `faces` (indices in `body`'s face order) of `body`: each face's /// interior control vertices move to flatten the thin-plate energy of the face while its boundary /// rows stay put, so the face keeps meeting its neighbours as it did. /// /// Every picked face keeps the very wires, edges and vertices it had and the way it pointed: only /// the surface between them changes, so the body is as closed (or as open) as it was, holes and /// all. A face that is not a B-spline (a plane, cylinder, cone, sphere, torus or swept face) has no /// control points to fair and is refused by name; a mesh body has no faces to fair at all. /// /// The report is over the picked faces together: the energies summed, the largest movement of any /// control point, and the energy trace as the sum at each step (a face that stops early keeps its /// last energy for the steps the others still take). pub fn fair_faces(body: &Body, faces: &[usize], opts: &FairOptions) -> Result<(Body, FairReport)> { let opts = opts.checked()?; body.require_brep("fair")?; let all: Vec = body.solid.face_iter().cloned().collect(); if all.is_empty() { return Err(KernelError::Invalid("the body has no faces".into())); } let mut picked: Vec = Vec::new(); for &i in faces { if i >= all.len() { return Err(KernelError::Invalid(format!("no face {i}: the body has {}", all.len()))); } if !picked.contains(&i) { picked.push(i); } } if picked.is_empty() { return Err(KernelError::Invalid("pick the faces to fair".into())); } guard("fair faces", || { let mut faired = all.clone(); let mut reports: Vec = Vec::with_capacity(picked.len()); for &i in &picked { let Some(face) = all.get(i) else { continue }; let (surface, report) = match face.surface() { mt::Surface::BSplineSurface(s) => { let (faired, report) = fair_bspline_surface(&s, &opts)?; (mt::Surface::BSplineSurface(faired), report) } mt::Surface::NurbsSurface(s) => { let (faired, report) = fair_nurbs_surface(&s, &opts)?; (mt::Surface::NurbsSurface(faired), report) } _ => { return Err(KernelError::Invalid(format!( "face {i} is not a B-spline face: a plane, cylinder, cone, sphere, torus or swept face has no control points to fair" ))); } }; // The same face as before, wires and all, with the faired surface between them. let mut nf = mt::Face::new_unchecked(face.absolute_boundaries().clone(), surface); if !face.orientation() { nf.invert(); } if let Some(slot) = faired.get_mut(i) { *slot = nf; } reports.push(report); } let report = FairReport { energy_before: reports.iter().map(|r| r.energy_before).sum(), energy_after: reports.iter().map(|r| r.energy_after).sum(), steps: reports.iter().map(|r| r.steps).max().unwrap_or(0), movement: reports.iter().fold(0.0f64, |m, r| m.max(r.movement)), energy_trace: { let longest = reports.iter().map(|r| r.energy_trace.len()).max().unwrap_or(0); (0..longest).map(|k| reports.iter().map(|r| r.energy_trace.get(k).copied().unwrap_or(r.energy_after)).sum()).collect() }, }; // The body shell for shell, in its own face order: a body with holes keeps them. let mut shells: Vec = Vec::with_capacity(body.solid.boundaries().len()); let mut index = 0usize; for shell in body.solid.boundaries() { let mut shell_faces = Vec::with_capacity(shell.face_iter().count()); for f in shell.face_iter() { let i = index; index += 1; shell_faces.push(faired.get(i).cloned().unwrap_or_else(|| f.clone())); } shells.push(mt::Shell::from(shell_faces)); } // The topology is the body's own, face for face, so the solid is built as it (the shells // are as closed, or as open, as they were) and only the shape is measured anew. let mut result = body.clone(); result.solid = std::sync::Arc::new(mt::Solid::new_unchecked(shells)); result.size = 0.0; result.volume = Some(std::sync::Arc::new(std::sync::Mutex::new(None))); result.coarse_missing = None; result.history = None; let faces: Vec = (0..all.len()).map(|i| crate::record::from_face(0, i, if picked.contains(&i) { Change::Modified } else { Change::Kept })).collect(); let result = crate::record::attach(result, &[crate::record::Operand::new(body)], Some(faces), std::collections::HashMap::new())?; Ok((result, report)) }) } #[cfg(test)] mod tests { use super::*; use mt::ParametricCurve; /// A clamped uniform knot vector over `[0, spans]` of degree `degree`: `spans + degree` /// control points. fn clamped(spans: usize, degree: usize) -> Vec { let mut k = vec![0.0; degree + 1]; k.extend((1..spans).map(|i| i as f64)); k.extend(std::iter::repeat_n(spans as f64, degree + 1)); k } /// The knot average `(kᵢ₊₁ + … + kᵢ₊ₚ)/p` of control point `i`: `Σ Nᵢ(t)·ξᵢ = t` (NURBS Book /// eq. 3.4), so control points at `a + b·ξᵢ` describe the affine curve `a + b·t` exactly. fn average(knots: &[f64], degree: usize, i: usize) -> f64 { (1..=degree).map(|r| knots.get(i + r).copied().unwrap_or(0.0)).sum::() / degree as f64 } /// The control points of the affine curve `ξ ↦ a + b·ξ` at the knot averages of `knots`: a /// curve of exactly zero bending energy. fn affine_curve(knots: &[f64], degree: usize, a: [f64; 3], b: [f64; 3]) -> Vec<[f64; 3]> { (0..knots.len() - degree - 1).map(|i| std::array::from_fn(|c| a[c] + b[c] * average(knots, degree, i))).collect() } /// The control net (u fastest) of the plane `(ξ, η) ↦ a + b·ξ + c·η` at the knot averages of /// `ku` and `kv`: a net of exactly zero thin-plate energy. fn planar_net(ku: &[f64], kv: &[f64], degree: [usize; 2], a: [f64; 3], b: [f64; 3], c: [f64; 3]) -> Vec<[f64; 3]> { let (cu, cv) = (ku.len() - degree[0] - 1, kv.len() - degree[1] - 1); let mut net = Vec::with_capacity(cu * cv); for j in 0..cv { for i in 0..cu { let (xi, eta) = (average(ku, degree[0], i), average(kv, degree[1], j)); net.push(std::array::from_fn(|d| a[d] + b[d] * xi + c[d] * eta)); } } net } /// The point of the curve `(degree, knots, ctrl)` at `t`, through the same basis geom uses: a /// basis function `i` owns the `degree + 2` knots `knots[i..=i + degree + 1]`. fn curve_at(degree: usize, knots: &[f64], ctrl: &[[f64; N]], t: f64) -> [f64; N] { let n = ctrl.len(); let side = if t >= knots.get(n).copied().unwrap_or(f64::INFINITY) { Side::Left } else { Side::Right }; let mut out = [0.0; N]; for (i, c) in ctrl.iter().enumerate() { let b = knots.get(i..=i + degree + 1).and_then(|l| local_basis(l, t, side, 0).first().copied()).unwrap_or(0.0); for (o, x) in out.iter_mut().zip(c) { *o += b * x; } } out } /// The greatest distance between two points of space, in mm. fn apart(a: mt::Point3, b: mt::Point3) -> f64 { ((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt() } /// A geom point as a coordinate triple. fn triple(p: Vec3) -> [f64; 3] { [p.x, p.y, p.z] } #[test] fn a_perturbed_curve_is_faired_back_to_its_smooth_shape_and_its_ends_stay_put() { // A cubic curve whose control points sit at the knot averages of an affine curve: zero // bending energy, so a fairing run must return to the same control polygon. The knots are // deliberately uneven, so a fairing that ignored the curve's own parameter would not. let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 9.0, 9.0, 9.0, 9.0]; let (a, b) = ([-20.0, 5.0, 1.5], [4.0, -2.5, 3.0]); let smooth = affine_curve(&knots, 3, a, b); let mut rough = smooth.clone(); rough[2] = [rough[2][0] + 1.5, rough[2][1] - 2.0, rough[2][2] + 3.0]; rough[3][2] += 2.5; rough[4][0] -= 1.25; rough[5][1] += 1.75; let far = (0..smooth.len()).map(|i| dist(&rough[i], &smooth[i])).fold(0.0, f64::max); let opts = FairOptions::new(1.0, 8, false, 100.0).unwrap(); let faired = fair_curve(3, &knots, &rough, &opts).unwrap(); // The ends did not move at all, bit for bit. assert_eq!(faired.ctrl[0], rough[0]); assert_eq!(faired.ctrl[6], rough[6]); // Every interior control point came back to the smooth shape. let near = (1..6).map(|i| dist(&faired.ctrl[i], &smooth[i])).fold(0.0, f64::max); eprintln!( "curve: perturbation {far} mm, faired to {near:e} mm, energy {:e} → {:e} in {} steps, movement {:e} mm", faired.report.energy_before, faired.report.energy_after, faired.report.steps, faired.report.movement ); assert!(near < 1e-6 && near < far * 1e-6, "faired to {near:e}, was {far} mm out"); // The energy is a real number before, fell to nothing, and never rose on the way. assert!(faired.report.energy_before > 1e-3, "{:e}", faired.report.energy_before); assert!(faired.report.energy_after >= 0.0 && faired.report.energy_after < 1e-9 * faired.report.energy_before); assert!(faired.report.steps >= 1 && faired.report.energy_trace.len() == faired.report.steps); assert_eq!(faired.report.energy_trace.last().copied(), Some(faired.report.energy_after)); assert!(faired.report.energy_trace.windows(2).all(|w| w[1] < w[0]), "{:?}", faired.report.energy_trace); // A step never moves a control point further than the tolerance, and here it moved the // whole way back to the smooth shape. assert!(faired.report.movement <= 100.0 && (faired.report.movement - far).abs() < 1e-9, "{:e}", faired.report.movement); // The faired curve is the affine line it was meant to be. let mut off = 0.0f64; for k in 0..=32 { let t = 9.0 * f64::from(k) / 32.0; let want: [f64; 3] = std::array::from_fn(|c| a[c] + b[c] * t); off = off.max(dist(&curve_at(3, &knots, &faired.ctrl, t), &want)); } eprintln!("curve: the faired curve is {off:e} mm off the affine line it describes"); assert!(off < 1e-6, "{off:e}"); } #[test] fn a_half_strength_run_walks_to_the_same_shape_over_its_iterations() { // The same perturbation, walked a half step at a time: the energy must fall at every step // (monotone, strictly) and still arrive at the smooth shape. let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 9.0, 9.0, 9.0, 9.0]; let smooth = affine_curve(&knots, 3, [-20.0, 5.0, 1.5], [4.0, -2.5, 3.0]); let mut rough = smooth.clone(); rough[2][2] += 4.0; rough[3][0] += 2.0; let opts = FairOptions::new(0.5, 40, false, 100.0).unwrap(); let faired = fair_curve(3, &knots, &rough, &opts).unwrap(); eprintln!("half strength: energy {:e} → {:e} in {} steps", faired.report.energy_before, faired.report.energy_after, faired.report.steps); assert!(faired.report.energy_trace.len() > 4, "one step is not a walk"); assert!(faired.report.energy_trace.windows(2).all(|w| w[1] < w[0]), "the energy must fall at every step"); assert!(faired.report.energy_after < 1e-9 * faired.report.energy_before); assert_eq!(faired.ctrl[0], rough[0]); assert_eq!(faired.ctrl[6], rough[6]); assert!(dist(&faired.ctrl[3], &smooth[3]) < 1e-6); } #[test] fn the_faired_curve_stays_within_the_tolerance_of_the_design() { // A heavily perturbed curve with a 0.75 mm tolerance: the design may be smoothed, but // never by more than the caller allowed, and the bound is on the geometry itself. let knots = clamped(3, 3); let smooth = affine_curve(&knots, 3, [0.0, 0.0, 0.0], [30.0, 8.0, 0.0]); let mut rough = smooth.clone(); for (i, d) in [(1, [1.0, -2.0, 4.0]), (2, [-3.0, 2.0, -6.0]), (3, [2.0, -1.0, 5.0]), (4, [-2.0, 3.0, -4.0])] { for c in 0..3 { rough[i][c] += d[c]; } } let tolerance = 0.75; let opts = FairOptions::new(1.0, 12, false, tolerance).unwrap(); let faired = fair_curve(3, &knots, &rough, &opts).unwrap(); let mut off = 0.0f64; for k in 0..=64 { let t = 3.0 * f64::from(k) / 64.0; off = off.max(dist(&curve_at(3, &knots, &faired.ctrl, t), &curve_at(3, &knots, &rough, t))); } eprintln!( "tolerance {tolerance}: the curve moved {off:e} mm at worst ({:e} mm of control points), energy {:e} → {:e}", faired.report.movement, faired.report.energy_before, faired.report.energy_after ); assert!(faired.report.movement <= tolerance, "{:e}", faired.report.movement); assert!(off <= tolerance * (1.0 + 1e-9), "{off:e} over {tolerance}"); assert!(faired.report.energy_after < faired.report.energy_before); // The tolerance did not stop the run from heading for the smooth shape. let before = (0..6).map(|i| dist(&rough[i], &smooth[i])).fold(0.0, f64::max); let after = (0..6).map(|i| dist(&faired.ctrl[i], &smooth[i])).fold(0.0, f64::max); assert!(after < before, "{after} vs {before}"); assert_eq!(faired.ctrl[0], rough[0]); assert_eq!(faired.ctrl[5], rough[5]); } #[test] fn a_sketch_curve_of_two_dimensions_fairs_the_same_way() { // The generic core takes `[f64; 2]` just as well: a quadratic sketch curve, its middle // pulled 2 mm out of the smooth polyline. let knots = clamped(4, 2); let smooth: Vec<[f64; 2]> = (0..knots.len() - 3).map(|i| [average(&knots, 2, i) * 10.0, average(&knots, 2, i) * 2.0]).collect(); let mut rough = smooth.clone(); rough[2] = [rough[2][0] + 1.0, rough[2][1] - 2.0]; rough[3][1] += 1.5; let opts = FairOptions::new(1.0, 10, false, 50.0).unwrap(); let faired = fair_curve(2, &knots, &rough, &opts).unwrap(); eprintln!("2D sketch curve: energy {:e} → {:e} in {} steps", faired.report.energy_before, faired.report.energy_after, faired.report.steps); assert_eq!(faired.ctrl[0], rough[0]); assert_eq!(faired.ctrl[5], rough[5]); assert!(faired.report.energy_after >= 0.0 && faired.report.energy_after < 1e-9 * faired.report.energy_before); for (i, want) in smooth.iter().enumerate().take(5).skip(1) { assert!(dist(&faired.ctrl[i], want) < 1e-6, "point {i}"); } } #[test] fn a_perturbed_net_is_faired_back_to_its_plane_with_its_boundary_rows_held() { // A bicubic net of a plane (zero thin-plate energy), its middle pushed out; the boundary // rows must not move at all and the interior must come back to the plane. let (ku, kv) = (clamped(3, 3), clamped(4, 3)); let degree = [3, 3]; let (cu, cv) = (ku.len() - 4, kv.len() - 4); let (a, b, c) = ([1.0, -2.0, 4.0], [2.0, 0.5, -0.25], [-0.5, 1.5, 0.75]); let smooth = planar_net(&ku, &kv, degree, a, b, c); let mut rough = smooth.clone(); for &(i, j, d) in &[(2usize, 2usize, 3.0), (3, 3, -2.5), (1, 4, 1.5), (4, 5, -1.0)] { rough[j * cu + i][2] += d; } let opts = FairOptions::new(1.0, 6, false, 100.0).unwrap(); let faired = fair_net(degree, [&ku, &kv], [cu, cv], &rough, &opts).unwrap(); for j in 0..cv { for i in [0, cu - 1] { assert_eq!(faired.ctrl[j * cu + i], rough[j * cu + i], "column {i}, row {j} moved"); } } for i in 0..cu { for j in [0, cv - 1] { assert_eq!(faired.ctrl[j * cu + i], rough[j * cu + i], "row {j}, column {i} moved"); } } let near = (0..cu * cv).map(|k| dist(&faired.ctrl[k], &smooth[k])).fold(0.0, f64::max); let far = (0..cu * cv).map(|k| dist(&rough[k], &smooth[k])).fold(0.0, f64::max); eprintln!("net: perturbation {far} mm, faired to {near:e} mm, energy {:e} → {:e}", faired.report.energy_before, faired.report.energy_after); assert!(near < 1e-6, "{near:e}"); assert!(faired.report.energy_after < 1e-9 * faired.report.energy_before); assert!(faired.report.energy_trace.windows(2).all(|w| w[1] < w[0])); // With keep_edges the two outer layers at each side stay exactly as designed — so the // bump goes into the middle of the net — and the rest still comes back to the plane. let mut inner = smooth.clone(); for &(i, j, d) in &[(2usize, 2usize, 3.0), (3, 3, -2.5), (2, 3, 1.5), (3, 2, -1.0)] { inner[j * cu + i][2] += d; } let kept = FairOptions::new(1.0, 6, true, 100.0).unwrap(); let faired = fair_net(degree, [&ku, &kv], [cu, cv], &inner, &kept).unwrap(); let shell = |i: usize, j: usize| i < 2 || i + 2 >= cu || j < 2 || j + 2 >= cv; for j in 0..cv { for i in 0..cu { if shell(i, j) { assert_eq!(faired.ctrl[j * cu + i], inner[j * cu + i], "({i}, {j}) is a boundary layer and moved"); } } } let near = (0..cu * cv).filter(|&k| !shell(k % cu, k / cu)).map(|k| dist(&faired.ctrl[k], &smooth[k])).fold(0.0, f64::max); eprintln!("net with keep_edges: interior faired to {near:e} mm, energy {:e} → {:e}", faired.report.energy_before, faired.report.energy_after); assert!(near < 1e-6 && faired.report.energy_after < faired.report.energy_before); } #[test] fn a_faired_geom_surface_stays_within_the_tolerance_of_the_design_and_keeps_its_weights() { // A rational surface (weights off one) whose middle is pushed out: fair it with a 0.5 mm // tolerance. The weights must be untouched, the boundary rows must not move, and the // surface must stay within the tolerance of the one the design asked for. let (ku, kv) = (clamped(3, 3), clamped(3, 3)); let degree = [3, 3]; let (cu, cv) = (ku.len() - 4, kv.len() - 4); let smooth = planar_net(&ku, &kv, degree, [0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [0.0, 15.0, 0.0]); let weights: Vec = (0..cu * cv).map(|k| 0.5 + (k % 5) as f64 * 0.25).collect(); let mut rough = smooth.clone(); for &(i, j, d) in &[(2usize, 2usize, 4.0), (3, 3, -3.0), (1, 2, 2.0)] { rough[j * cu + i][2] += d; } let surface = NurbsSurface::new(degree, [ku.clone(), kv.clone()], &rough.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect::>(), &weights) .unwrap(); let tolerance = 0.5; let opts = FairOptions::new(1.0, 8, false, tolerance).unwrap(); let (faired, report) = fair_surface(&surface, &opts).unwrap(); for k in 0..cu * cv { let (i, j) = (k % cu, k / cu); assert_eq!(faired.point(i, j).w, surface.point(i, j).w, "the weight of ({i}, {j}) changed"); } for j in 0..cv { for i in [0, cu - 1] { assert_eq!(faired.point(i, j), surface.point(i, j), "column {i} moved"); } } let dom = surface.domain(); let mut off = 0.0f64; for k in 0..=24 { for l in 0..=24 { let (u, v) = (dom[0][0] + (dom[0][1] - dom[0][0]) * f64::from(k) / 24.0, dom[1][0] + (dom[1][1] - dom[1][0]) * f64::from(l) / 24.0); off = off.max(surface.eval(u, v).unwrap().dist(faired.eval(u, v).unwrap())); } } eprintln!( "surface: moved {off:e} mm at worst ({:e} mm of control points), energy {:e} → {:e}", report.movement, report.energy_before, report.energy_after ); assert!(report.movement <= tolerance, "{:e}", report.movement); assert!(off <= tolerance * (1.0 + 1e-9), "{off:e} over {tolerance}"); assert!(report.energy_after < report.energy_before); // Every interior control point is nearer the plane than it was. let before = (0..cu * cv).map(|k| dist(&rough[k], &smooth[k])).fold(0.0, f64::max); let after = (0..cu * cv).map(|k| dist(&triple(faired.point(k % cu, k / cu).project().unwrap()), &smooth[k])).fold(0.0, f64::max); assert!(after < before, "{after} vs {before}"); } #[test] fn the_truck_wrappers_fair_a_spline_curve_and_surface_with_their_ends_held() { let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 9.0, 9.0, 9.0, 9.0]; let smooth = affine_curve(&knots, 3, [-20.0, 5.0, 1.5], [4.0, -2.5, 3.0]); let mut rough = smooth.clone(); rough[2][2] += 3.0; rough[4][1] -= 2.0; let points = |net: &[[f64; 3]]| -> Vec { net.iter().map(|c| mt::Point3::new(c[0], c[1], c[2])).collect() }; let curve = mt::BSplineCurve::try_new(mt::KnotVec::from(knots.to_vec()), points(&rough)).unwrap(); let tolerance = 5.0; let opts = FairOptions::new(1.0, 8, false, tolerance).unwrap(); let (faired, report) = fair_bspline_curve(&curve, &opts).unwrap(); let mut off = 0.0f64; for k in 0..=64 { let t = 9.0 * f64::from(k) / 64.0; off = off.max(apart(faired.subs(t), curve.subs(t))); } eprintln!( "truck curve: energy {:e} → {:e} in {} steps, moved {off:e} mm ({:e} mm of control points)", report.energy_before, report.energy_after, report.steps, report.movement ); assert_eq!(faired.control_points().first().copied(), curve.control_points().first().copied()); assert_eq!(faired.control_points().last().copied(), curve.control_points().last().copied()); assert!(off <= tolerance * (1.0 + 1e-9), "{off:e}"); assert!(report.energy_after < 1e-9 * report.energy_before); // The same on a B-spline surface: boundary rows bit-identical, the bump in the middle // comes down, the energy falls. let (ku, kv) = (clamped(3, 3), clamped(3, 3)); let degree = [3, 3]; let (cu, cv) = (ku.len() - 4, kv.len() - 4); let smooth = planar_net(&ku, &kv, degree, [0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [0.0, 15.0, 0.0]); let mut rough = smooth.clone(); rough[2 * cu + 2][2] += 5.0; let rows: Vec> = (0..cu).map(|i| (0..cv).map(|j| mt::Point3::new(rough[j * cu + i][0], rough[j * cu + i][1], rough[j * cu + i][2])).collect()).collect(); let surface = mt::BSplineSurface::try_new((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows).unwrap(); let (faired, report) = fair_bspline_surface(&surface, &FairOptions::new(1.0, 6, true, 8.0).unwrap()).unwrap(); eprintln!("truck surface: energy {:e} → {:e}, {} steps", report.energy_before, report.energy_after, report.steps); for i in 0..cu { for j in 0..cv { if i < 2 || i + 2 >= cu || j < 2 || j + 2 >= cv { assert_eq!(faired.control_points()[i][j], surface.control_points()[i][j], "({i}, {j}) is a boundary layer and moved"); } } } assert!(report.energy_after < report.energy_before); assert!(faired.control_points()[2][2].z < surface.control_points()[2][2].z, "the bump should come down"); } #[test] fn a_rational_surface_fairs_with_its_boundary_and_tolerance_intact() { // The truck NURBS wrapper, on weights away from one: fairing moves the Cartesian control // points and keeps the weights, so the surface still cannot move more than the tolerance. let (ku, kv) = (clamped(3, 3), clamped(3, 3)); let degree = [3, 3]; let (cu, cv) = (ku.len() - 4, kv.len() - 4); let smooth = planar_net(&ku, &kv, degree, [0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [0.0, 15.0, 0.0]); let mut rough = smooth.clone(); rough[2 * cu + 3][2] += 6.0; let weights: Vec = (0..cu * cv).map(|k| 0.75 + (k % 3) as f64 * 0.5).collect(); let rows: Vec> = (0..cu) .map(|i| { (0..cv) .map(|j| { let (c, w) = (rough[j * cu + i], weights[j * cu + i]); mt::Vector4::new(c[0] * w, c[1] * w, c[2] * w, w) }) .collect() }) .collect(); let bsp = mt::BSplineSurface::try_new((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows).unwrap(); let surface = mt::NurbsSurface::new(bsp); let (faired, report) = fair_nurbs_surface(&surface, &FairOptions::new(1.0, 6, false, 0.5).unwrap()).unwrap(); eprintln!("rational surface: energy {:e} → {:e}, movement {:e} mm", report.energy_before, report.energy_after, report.movement); assert!(report.movement <= 0.5 && report.energy_after < report.energy_before); for i in 0..cu { for j in 0..cv { let (before, after) = (surface.non_rationalized().control_points()[i][j], faired.non_rationalized().control_points()[i][j]); assert_eq!(after.w, before.w, "the weight of ({i}, {j}) changed"); if i == 0 || i + 1 == cu || j == 0 || j + 1 == cv { assert_eq!(after, before, "({i}, {j}) is on the boundary and moved"); } } } } #[test] fn hostile_fairing_inputs_are_refused() { let knots = clamped(3, 3); let ctrl = affine_curve(&knots, 3, [0.0, 0.0, 0.0], [10.0, 4.0, 2.0]); let ok = FairOptions::new(1.0, 4, false, 10.0).unwrap(); // Out-of-range parameters: strength, iterations and tolerance are all input-derived. for strength in [0.0, -0.25, 1.000_001, f64::NAN, f64::INFINITY] { assert!(FairOptions::new(strength, 4, false, 10.0).is_err(), "strength {strength}"); } for iterations in [0, MAX_FAIR_ITERATIONS + 1, usize::MAX] { assert!(FairOptions::new(1.0, iterations, false, 10.0).is_err(), "iterations {iterations}"); } for tolerance in [0.0, -1.0, f64::NAN, f64::INFINITY] { assert!(FairOptions::new(1.0, 4, false, tolerance).is_err(), "tolerance {tolerance}"); } // A control point that is not a finite number. for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { let mut nan = ctrl.clone(); nan[2][1] = bad; assert!(fair_curve(3, &knots, &nan, &ok).is_err(), "a control point of {bad} was faired"); } // Knot vectors that do not fit the control points or the degree. assert!(fair_curve(3, &knots[..9], &ctrl, &ok).is_err(), "nine knots for six control points of degree three"); assert!(fair_curve(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 3.0, 4.0, 4.0, 4.0], &ctrl, &ok).is_err(), "knots that decrease"); assert!(fair_curve(3, &vec![f64::NAN; knots.len()], &ctrl, &ok).is_err(), "a knot that is not a number"); assert!(fair_curve(9, &knots, &ctrl, &ok).is_err(), "a degree above the supported maximum"); // Fewer than two interior control points: one free point (a triangle of a quadratic), and // a 3 × 3 net whose two edge layers leave nothing at all. let triangle = [[0.0, 0.0, 0.0], [5.0, 2.0, 0.0], [10.0, 0.0, 1.0]]; let one = fair_curve(2, &[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], &triangle, &ok).unwrap_err(); assert!(one.to_string().contains("at least two interior"), "{one}"); let flat = [0.0, 0.0, 1.0, 2.0, 2.0]; let grid = [[0.0, 0.0, 0.0]; 9]; let kept = FairOptions::new(1.0, 4, true, 1.0).unwrap(); assert!(fair_net([1, 1], [&flat, &flat], [3, 3], &grid, &kept).is_err(), "keep_edges on a 3 × 3 net"); // A net too large to fair, refused before anything is allocated for it. let line = [0.0, 0.0, 1.0, 1.0]; assert!(fair_net([1, 1], [&line, &line], [65, 65], &vec![[0.0; 3]; 65 * 65], &ok).is_err(), "4225 control points"); // A singular system: a cubic whose last control points lie past the end of its domain // (0.5), where they do not move the curve at all, so the interior equations have no // unique solution and the run must refuse rather than move anything. let odd = [0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0]; let six: Vec<[f64; 3]> = (0..6).map(|i| [i as f64, 0.0, 1.0]).collect(); let singular = fair_curve(3, &odd, &six, &ok).unwrap_err(); eprintln!("hostile inputs: refused with {singular}"); assert!(singular.to_string().contains("singular"), "{singular}"); } #[test] fn a_small_quadratic_net_and_a_quintic_curve_fair_the_same_way() { // Degrees and band shapes away from the bicubic case. A 5 × 5 quadratic net has fewer // rows than the degree, so the solver's band is narrower than `degree_u + degree_v · cols` // (the coupling cannot reach that far in v); a quintic curve carries six basis functions // per span instead of four. Both must reproduce a zero-energy shape exactly. let (ku, kv) = (clamped(3, 2), clamped(3, 2)); let degree = [2, 2]; let (cu, cv) = (ku.len() - 3, kv.len() - 3); assert_eq!((cu, cv), (5, 5)); let smooth = planar_net(&ku, &kv, degree, [0.0, 0.0, 0.0], [12.0, 1.0, 0.0], [-2.0, 9.0, 0.0]); let mut rough = smooth.clone(); for &(i, j, d) in &[(1usize, 2usize, 2.5), (2, 2, -1.5), (3, 3, 3.0), (2, 1, -2.0)] { rough[j * cu + i][2] += d; } let faired = fair_net(degree, [&ku, &kv], [cu, cv], &rough, &FairOptions::new(1.0, 8, false, 100.0).unwrap()).unwrap(); let near = (0..cu * cv).map(|k| dist(&faired.ctrl[k], &smooth[k])).fold(0.0, f64::max); eprintln!("5 × 5 quadratic net: faired to {near:e} mm, energy {:e} → {:e}", faired.report.energy_before, faired.report.energy_after); assert!(near < 1e-6 && faired.report.energy_after < 1e-9 * faired.report.energy_before); for j in 0..cv { for i in 0..cu { if i == 0 || i + 1 == cu || j == 0 || j + 1 == cv { assert_eq!(faired.ctrl[j * cu + i], rough[j * cu + i], "({i}, {j}) is on the boundary and moved"); } } } // A quintic curve, its middle pulled out. let knots = clamped(3, 5); let smooth = affine_curve(&knots, 5, [-10.0, 0.0, 2.0], [6.0, 4.0, -1.0]); let mut rough = smooth.clone(); rough[3][1] += 3.0; rough[4][2] -= 2.0; let faired = fair_curve(5, &knots, &rough, &FairOptions::new(1.0, 8, false, 100.0).unwrap()).unwrap(); let near = (1..smooth.len() - 1).map(|i| dist(&faired.ctrl[i], &smooth[i])).fold(0.0, f64::max); eprintln!("quintic curve: faired to {near:e} mm, energy {:e} → {:e}", faired.report.energy_before, faired.report.energy_after); assert!(near < 1e-6 && faired.report.energy_after < 1e-9 * faired.report.energy_before); assert_eq!(faired.ctrl[0], rough[0]); assert_eq!(faired.ctrl[rough.len() - 1], rough[rough.len() - 1]); } /// A one-face sheet body of the bicubic surface `net` (u fastest) over the clamped knots `ku` /// and `kv`, its sides the straight lines between the surface's corners — true while the /// net's boundary rows describe lines, which is how a plane's control net is built here. fn sheet(ku: &[f64], kv: &[f64], net: &[[f64; 3]]) -> Body { let (cu, cv) = (ku.len() - 4, kv.len() - 4); assert_eq!(net.len(), cu * cv, "the net does not fit the knots"); let rows: Vec> = (0..cu).map(|i| (0..cv).map(|j| mt::Point3::new(net[j * cu + i][0], net[j * cu + i][1], net[j * cu + i][2])).collect()).collect(); let bsp = mt::BSplineSurface::try_new((mt::KnotVec::from(ku.to_vec()), mt::KnotVec::from(kv.to_vec())), rows).unwrap(); let corner = |i: usize, j: usize| { let c = net[j * cu + i]; mt::Vertex::new(mt::Point3::new(c[0], c[1], c[2])) }; let v = [corner(0, 0), corner(cu - 1, 0), corner(cu - 1, cv - 1), corner(0, cv - 1)]; let line = |a: &mt::Vertex, b: &mt::Vertex| mt::Edge::new(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))); let wire: mt::Wire = vec![line(&v[0], &v[1]), line(&v[1], &v[2]), line(&v[2], &v[3]), line(&v[3], &v[0])].into(); crate::surfaces::surface_body(vec![mt::Face::try_new(vec![wire], mt::Surface::BSplineSurface(bsp)).unwrap()]) } #[test] fn a_body_face_is_faired_with_its_edges_untouched_and_its_history_kept() { // A sheet whose middle is bumped: fairing the face must smooth it without moving a single // edge, vertex or neighbouring face of the body, and must leave a history saying so. let (ku, kv) = (clamped(3, 3), clamped(3, 3)); let (cu, cv) = (ku.len() - 4, kv.len() - 4); let smooth = planar_net(&ku, &kv, [3, 3], [0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [0.0, 15.0, 0.0]); let mut rough = smooth.clone(); for &(i, j, d) in &[(2usize, 2usize, 3.0), (3, 3, -2.0), (2, 3, 1.5)] { rough[j * cu + i][2] += d; } let body = sheet(&ku, &kv, &rough); let before: Vec<_> = body.solid.face_iter().flat_map(|f| f.absolute_boundaries().iter()).flat_map(|w| w.edge_iter().map(|e| e.id())).collect(); assert!(!before.is_empty()); let (faired, report) = fair_faces(&body, &[0], &FairOptions::new(1.0, 6, false, 50.0).unwrap()).unwrap(); let after: Vec<_> = faired.solid.face_iter().flat_map(|f| f.absolute_boundaries().iter()).flat_map(|w| w.edge_iter().map(|e| e.id())).collect(); eprintln!( "face: energy {:e} → {:e} in {} steps, movement {:e} mm, {} faces, history {}", report.energy_before, report.energy_after, report.steps, report.movement, faired.face_count(), faired.history().is_some() ); assert_eq!(before, after, "the body's edges must be the very ones it had"); assert_eq!(faired.face_count(), 1); assert!(faired.history().is_some(), "the face came from the one it was faired from"); assert!(report.energy_after < 1e-9 * report.energy_before); // The face's own surface is back on the plane, boundary rows bit for bit as they were. let Some(mt::Surface::BSplineSurface(s)) = faired.solid.face_iter().next().map(|f| f.surface()) else { panic!("a B-spline face") }; for j in 0..cv { for i in 0..cu { let p = s.control_points()[i][j]; let want = if i == 0 || i + 1 == cu || j == 0 || j + 1 == cv { rough[j * cu + i] } else { smooth[j * cu + i] }; assert!(dist(&[p.x, p.y, p.z], &want) < 1e-6, "({i}, {j})"); } } // A solid keeps its shells, its faces and its edges: thickening the sheet gives a closed // body whose top face is still the B-spline one, and fairing that face leaves the body a // solid of the same shape. let solid = crate::thicken(&body, 2.0, false).unwrap(); assert!(!solid.is_surface()); let top = (0..solid.face_count()).find(|&i| matches!(solid.solid.face_iter().nth(i).map(|f| f.surface()), Some(mt::Surface::BSplineSurface(_)))); let Some(top) = top else { panic!("the thickened sheet keeps its B-spline face") }; let edges: Vec<_> = solid.solid.face_iter().flat_map(|f| f.absolute_boundaries().iter()).flat_map(|w| w.edge_iter().map(|e| e.id())).collect(); let (faired, report) = fair_faces(&solid, &[top], &FairOptions::new(1.0, 6, false, 0.5).unwrap()).unwrap(); eprintln!("thickened solid: energy {:e} → {:e}, movement {:e} mm", report.energy_before, report.energy_after, report.movement); assert!(!faired.is_surface() && faired.face_count() == solid.face_count() && faired.shell_count() == solid.shell_count()); assert_eq!( faired.solid.face_iter().flat_map(|f| f.absolute_boundaries().iter()).flat_map(|w| w.edge_iter().map(|e| e.id())).collect::>(), edges ); assert!(report.energy_after < report.energy_before); // A face with no control points to fair is refused, by name. let cube = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); let refused = fair_faces(&cube, &[0], &FairOptions::new(1.0, 1, false, 1.0).unwrap()).unwrap_err(); eprintln!("cube face: {refused}"); assert!(refused.to_string().contains("not a B-spline face"), "{refused}"); assert!(fair_faces(&body, &[7], &FairOptions::new(1.0, 1, false, 1.0).unwrap()).is_err(), "no such face"); assert!(fair_faces(&body, &[], &FairOptions::new(1.0, 1, false, 1.0).unwrap()).is_err(), "no face picked"); } #[test] fn an_already_fair_curve_is_left_where_it_is() { // Zero bending energy, no reason to move: nothing churns, and an input at the minimum // cannot be faired into a worse shape. let knots = clamped(4, 3); let smooth = affine_curve(&knots, 3, [1.0, 2.0, 3.0], [5.0, -1.0, 0.5]); let opts = FairOptions::new(1.0, 20, false, 10.0).unwrap(); let faired = fair_curve(3, &knots, &smooth, &opts).unwrap(); eprintln!( "already fair: energy {:e} → {:e}, {} steps, {:e} mm", faired.report.energy_before, faired.report.energy_after, faired.report.steps, faired.report.movement ); assert!(faired.report.energy_after <= faired.report.energy_before); assert!(faired.report.energy_after >= 0.0); assert!(faired.report.movement < 1e-9, "{:e}", faired.report.movement); for (i, want) in smooth.iter().enumerate() { assert!(dist(&faired.ctrl[i], want) < 1e-9, "control point {i}"); } } }