1539 lines
80 KiB
Rust
1539 lines
80 KiB
Rust
//! 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> {
|
||
FairOptions { strength, iterations, keep_edges, tolerance }.checked()
|
||
}
|
||
|
||
/// The same checks as [`FairOptions::new`], for options built field by field.
|
||
pub fn checked(self) -> Result<FairOptions> {
|
||
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<f64>,
|
||
}
|
||
|
||
/// 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<const N: usize> {
|
||
pub degree: usize,
|
||
pub knots: Vec<f64>,
|
||
/// 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<const N: usize> {
|
||
pub degree: [usize; 2],
|
||
pub knots: [Vec<f64>; 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<const N: usize>(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<const N: usize>(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<f64>; 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<Gram> {
|
||
let bw = degree;
|
||
let mut g: [Vec<f64>; 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<f64>; 2], count: [usize; 2], terms: &'static [(usize, usize, f64)]) -> Result<Net> {
|
||
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<const N: usize>(&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<Band> {
|
||
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<const N: usize>(&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<usize>,
|
||
/// The interior number of each flattened index, `None` where the point is held.
|
||
slot: Vec<Option<usize>>,
|
||
/// 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<Interior> {
|
||
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<const N: usize>(&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<const N: usize>(&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<const N: usize>(&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<const N: usize>(&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<f64>,
|
||
scale: f64,
|
||
}
|
||
|
||
impl Band {
|
||
fn new(n: usize, bw: usize) -> Result<Band> {
|
||
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<usize> {
|
||
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<Band> {
|
||
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<const N: usize>(degree: usize, knots: &[f64], ctrl: &[[f64; N]], opts: &FairOptions) -> Result<FairCurve<N>> {
|
||
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<const N: usize>(
|
||
degree: [usize; 2],
|
||
knots: [&[f64]; 2],
|
||
count: [usize; 2],
|
||
ctrl: &[[f64; N]],
|
||
opts: &FairOptions,
|
||
) -> Result<FairNet<N>> {
|
||
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<mt::Point3>, opts: &FairOptions) -> Result<(mt::BSplineCurve<mt::Point3>, 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<mt::Point3> = 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<mt::Vector4>, opts: &FairOptions) -> Result<(mt::NurbsCurve<mt::Vector4>, 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<mt::Vector4> = 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<mt::Point3>, opts: &FairOptions) -> Result<(mt::BSplineSurface<mt::Point3>, 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<mt::Point3>> = 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<HPoint> = 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<mt::Vector4>, opts: &FairOptions) -> Result<(mt::NurbsSurface<mt::Vector4>, 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<mt::Vector4>> = 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<mt::Face> = body.solid.face_iter().cloned().collect();
|
||
if all.is_empty() {
|
||
return Err(KernelError::Invalid("the body has no faces".into()));
|
||
}
|
||
let mut picked: Vec<usize> = 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<FairReport> = 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<mt::Shell> = 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<crate::history::FaceProv> =
|
||
(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<f64> {
|
||
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::<f64>() / 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<const N: usize>(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<f64> = (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::<Vec<_>>(), &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<mt::Point3> { 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<Vec<mt::Point3>> =
|
||
(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<f64> = (0..cu * cv).map(|k| 0.75 + (k % 3) as f64 * 0.5).collect();
|
||
let rows: Vec<Vec<mt::Vector4>> = (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<Vec<mt::Point3>> =
|
||
(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::<Vec<_>>(),
|
||
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}");
|
||
}
|
||
}
|
||
}
|