use std::fmt;
use crate::curve_math::bspline as math;
use crate::curves::{Curve2D, ParametrizeError};
use crate::math::boxtree::{BoxTree, Lazy};
use crate::math::newton_min_1d;
use crate::projection::CurveProjection;
use crate::surfaces::Surface;
use crate::{Point3D, Tolerance, Transform, Vector3D};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BSplineConstructionError {
InvalidDegree,
TooFewKnots,
KnotsNotIncreasing,
MultiplicityTooLarge,
PeriodicEndMultiplicityMismatch,
PoleCountMismatch,
WeightCountMismatch,
NonPositiveWeight,
TooFewPoints,
DegreeTooHigh,
ConfusedPoints,
TooManyPoles,
}
impl fmt::Display for BSplineConstructionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
BSplineConstructionError::InvalidDegree => "degree must be between 1 and 25",
BSplineConstructionError::TooFewKnots => "fewer than two distinct knots",
BSplineConstructionError::KnotsNotIncreasing => "knots are not strictly increasing",
BSplineConstructionError::MultiplicityTooLarge => "a knot multiplicity is too large",
BSplineConstructionError::PeriodicEndMultiplicityMismatch => {
"periodic end multiplicities differ"
}
BSplineConstructionError::PoleCountMismatch => {
"pole count is inconsistent with knots and multiplicities"
}
BSplineConstructionError::WeightCountMismatch => {
"weight count does not match pole count"
}
BSplineConstructionError::NonPositiveWeight => "a weight is zero or negative",
BSplineConstructionError::TooFewPoints => "interpolation needs at least two points",
BSplineConstructionError::DegreeTooHigh => {
"interpolation degree exceeds pole count minus one"
}
BSplineConstructionError::ConfusedPoints => {
"two consecutive interpolation points coincide"
}
BSplineConstructionError::TooManyPoles => "more poles requested than data points",
};
f.write_str(message)
}
}
impl std::error::Error for BSplineConstructionError {}
#[derive(Debug, Clone, PartialEq)]
pub struct BSplineCurve3D {
degree: usize,
periodic: bool,
poles: Vec<Point3D>,
weights: Option<Vec<f64>>,
knots: Vec<f64>,
mults: Vec<u32>,
flat: Vec<f64>,
flat_poles: Vec<f64>,
spans: Lazy<SpanTree>,
}
#[derive(Clone)]
struct SpanTree {
breaks: Vec<f64>,
tree: BoxTree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterpParametrization {
Chordal,
Centripetal,
}
fn chord_params(points: &[Point3D], exponent: f64) -> Result<Vec<f64>, BSplineConstructionError> {
let n = points.len();
let mut params = vec![0.0; n];
for i in 1..n {
let d = points[i].distance(points[i - 1]);
if d <= crate::tol::CONFUSION {
return Err(BSplineConstructionError::ConfusedPoints);
}
params[i] = params[i - 1] + d.powf(exponent);
}
let total = params[n - 1];
for t in &mut params {
*t /= total;
}
Ok(params)
}
fn averaged_full_knots(params: &[f64], degree: usize, m_poles: usize) -> Vec<f64> {
let p = degree;
let mut full = vec![0.0; p + 1];
for j in 1..=(m_poles - p - 1) {
let mut acc = 0.0;
for k in 0..p {
acc += params[j + k];
}
full.push(acc / p as f64);
}
full.extend(std::iter::repeat_n(1.0, p + 1));
full
}
fn compress_knots(full: &[f64]) -> (Vec<f64>, Vec<u32>) {
let mut knots = vec![full[0]];
let mut mults = vec![1u32];
for &u in &full[1..] {
if u > *knots.last().expect("knots non-empty") {
knots.push(u);
mults.push(1);
} else {
let last = mults.len() - 1;
mults[last] += 1;
}
}
(knots, mults)
}
fn basis_values(full: &[f64], degree: usize, t: f64) -> Vec<f64> {
let n_poles = full.len() - degree - 1;
let mut out = vec![0.0; n_poles];
let (lo, hi) = (full[degree], full[n_poles]);
let tt = t.clamp(lo, hi);
let mut span = n_poles - 1;
if tt < hi {
span = degree;
while full[span + 1] <= tt {
span += 1;
}
}
let mut n = vec![0.0; degree + 1];
let mut left = vec![0.0; degree + 1];
let mut right = vec![0.0; degree + 1];
n[0] = 1.0;
for k in 1..=degree {
left[k] = tt - full[span + 1 - k];
right[k] = full[span + k] - tt;
let mut saved = 0.0;
for r in 0..k {
let denom = right[r + 1] + left[k - r];
let temp = if denom == 0.0 { 0.0 } else { n[r] / denom };
n[r] = saved + right[r + 1] * temp;
saved = left[k - r] * temp;
}
n[k] = saved;
}
for (r, &v) in n.iter().enumerate() {
out[span - degree + r] = v;
}
out
}
fn banded_solve(band: &mut [Vec<f64>], p: usize, rhs: &mut [Vec<f64>]) {
let m = band.len();
for k in 0..m {
for i in (k + 1)..(k + p + 1).min(m) {
let factor = band[i][p + k - i] / band[k][p];
band[i][p + k - i] = factor;
for j in (k + 1)..(k + p + 1).min(m) {
band[i][p + j - i] -= factor * band[k][p + j - k];
}
}
}
for col in rhs.iter_mut() {
for i in 0..m {
let (done, rest) = col.split_at_mut(i);
let elem = &mut rest[0];
for j in i.saturating_sub(p)..i {
*elem -= band[i][p + j - i] * done[j];
}
}
for i in (0..m).rev() {
let (left, right) = col.split_at_mut(i + 1);
let elem = &mut left[i];
for (k, r) in right.iter().enumerate().take(p) {
*elem -= band[i][p + 1 + k] * r;
}
*elem /= band[i][p];
}
}
}
impl BSplineCurve3D {
pub fn new(
degree: usize,
poles: Vec<Point3D>,
knots: Vec<f64>,
multiplicities: Vec<u32>,
periodic: bool,
) -> Result<BSplineCurve3D, BSplineConstructionError> {
Self::build(degree, poles, None, knots, multiplicities, periodic)
}
pub fn new_rational(
degree: usize,
poles: Vec<Point3D>,
weights: Vec<f64>,
knots: Vec<f64>,
multiplicities: Vec<u32>,
periodic: bool,
) -> Result<BSplineCurve3D, BSplineConstructionError> {
Self::build(
degree,
poles,
Some(weights),
knots,
multiplicities,
periodic,
)
}
fn build(
degree: usize,
poles: Vec<Point3D>,
weights: Option<Vec<f64>>,
knots: Vec<f64>,
multiplicities: Vec<u32>,
periodic: bool,
) -> Result<BSplineCurve3D, BSplineConstructionError> {
if let Some(ws) = &weights {
if ws.len() != poles.len() {
return Err(BSplineConstructionError::WeightCountMismatch);
}
if ws.iter().any(|&w| w <= 0.0) {
return Err(BSplineConstructionError::NonPositiveWeight);
}
}
math::validate_direction(degree, poles.len(), &knots, &multiplicities, periodic)?;
let flat = math::flat_knots(&knots, &multiplicities);
let flat_poles = pack_flat_poles(&poles, weights.as_deref());
Ok(BSplineCurve3D {
degree,
periodic,
poles,
weights,
knots,
mults: multiplicities,
flat,
flat_poles,
spans: Lazy::default(),
})
}
pub fn degree(&self) -> usize {
self.degree
}
pub fn interpolate(
points: &[Point3D],
degree: usize,
param: InterpParametrization,
) -> Result<BSplineCurve3D, BSplineConstructionError> {
let n = points.len();
if n < 2 {
return Err(BSplineConstructionError::TooFewPoints);
}
if !(1..=25).contains(°ree) {
return Err(BSplineConstructionError::InvalidDegree);
}
if degree > n - 1 {
return Err(BSplineConstructionError::DegreeTooHigh);
}
let exponent = match param {
InterpParametrization::Chordal => 1.0,
InterpParametrization::Centripetal => 0.5,
};
let params = chord_params(points, exponent)?;
let p = degree;
let full = averaged_full_knots(¶ms, p, n);
let (knots, mults) = compress_knots(&full);
let m = n - 2;
let mut poles = vec![Point3D::ORIGIN; n];
poles[0] = points[0];
poles[n - 1] = points[n - 1];
if m > 0 {
let mut band = vec![vec![0.0; 2 * p + 1]; m];
let mut rhs = vec![vec![0.0; m]; 3];
for (row, j) in (1..n - 1).enumerate() {
let basis = basis_values(&full, p, params[j]);
for (i, &b) in basis.iter().enumerate().take(n - 1).skip(1) {
if b != 0.0 && i + p >= j && i <= j + p {
band[row][p + i - j] = b;
}
}
for k in 0..3 {
let coord = |pt: Point3D| [pt.x, pt.y, pt.z][k];
rhs[k][row] = coord(points[j])
- basis[0] * coord(points[0])
- basis[n - 1] * coord(points[n - 1]);
}
}
banded_solve(&mut band, p, &mut rhs);
for row in 0..m {
poles[row + 1] = Point3D::new(rhs[0][row], rhs[1][row], rhs[2][row]);
}
}
Self::new(degree, poles, knots, mults, false)
}
pub fn approximate(
points: &[Point3D],
degree: usize,
num_poles: usize,
param: InterpParametrization,
) -> Result<BSplineCurve3D, BSplineConstructionError> {
let n = points.len();
if n < 2 {
return Err(BSplineConstructionError::TooFewPoints);
}
if !(1..=25).contains(°ree) {
return Err(BSplineConstructionError::InvalidDegree);
}
if degree > num_poles.saturating_sub(1) {
return Err(BSplineConstructionError::DegreeTooHigh);
}
if num_poles > n {
return Err(BSplineConstructionError::TooManyPoles);
}
let exponent = match param {
InterpParametrization::Chordal => 1.0,
InterpParametrization::Centripetal => 0.5,
};
let params = chord_params(points, exponent)?;
let p = degree;
let m = num_poles;
let mut full = vec![0.0; p + 1];
for j in 1..=(m - p - 1) {
full.push(j as f64 / (m - p) as f64);
}
full.extend(std::iter::repeat_n(1.0, p + 1));
let (knots, mults) = compress_knots(&full);
let bw = 2 * p;
let mut band = vec![vec![0.0; 2 * bw + 1]; m];
let mut rhs = vec![vec![0.0; m]; 3];
for (j, &t) in params.iter().enumerate() {
let basis = basis_values(&full, p, t);
let coords = [points[j].x, points[j].y, points[j].z];
for (i1, &b1) in basis.iter().enumerate() {
if b1 == 0.0 {
continue;
}
for (i2, &b2) in basis.iter().enumerate() {
if b2 != 0.0 && i1 + bw >= i2 && i2 + bw >= i1 {
band[i1][bw + i2 - i1] += b1 * b2;
}
}
for k in 0..3 {
rhs[k][i1] += b1 * coords[k];
}
}
}
let max_diag = band.iter().map(|row| row[bw].abs()).fold(0.0f64, f64::max);
if max_diag <= 0.0 || band.iter().any(|row| row[bw] <= 1e-12 * max_diag) {
return Err(BSplineConstructionError::PoleCountMismatch);
}
banded_solve(&mut band, bw, &mut rhs);
if rhs.iter().flatten().any(|v| !v.is_finite()) {
return Err(BSplineConstructionError::PoleCountMismatch);
}
let poles: Vec<Point3D> = (0..m)
.map(|i| Point3D::new(rhs[0][i], rhs[1][i], rhs[2][i]))
.collect();
Self::new(degree, poles, knots, mults, false)
}
pub fn is_periodic(&self) -> bool {
self.periodic
}
pub fn is_rational(&self) -> bool {
self.weights.is_some()
}
pub fn poles(&self) -> &[Point3D] {
&self.poles
}
pub fn weights(&self) -> Option<&[f64]> {
self.weights.as_deref()
}
pub fn knots(&self) -> &[f64] {
&self.knots
}
pub fn multiplicities(&self) -> &[u32] {
&self.mults
}
pub fn transformed(&self, transform: Transform) -> BSplineCurve3D {
let poles: Vec<Point3D> = self
.poles
.iter()
.map(|&p| transform.apply_point(p))
.collect();
let flat_poles = pack_flat_poles(&poles, self.weights.as_deref());
BSplineCurve3D {
degree: self.degree,
periodic: self.periodic,
poles,
weights: self.weights.clone(),
knots: self.knots.clone(),
mults: self.mults.clone(),
flat: self.flat.clone(),
flat_poles,
spans: Lazy::default(),
}
}
pub fn bounds(&self) -> (f64, f64) {
let sum_mults: u32 = self.mults.iter().sum();
math::param_range(&self.flat, self.degree, self.periodic, sum_mults as usize)
}
pub fn eval_point(&self, u: f64) -> Point3D {
let euc = self.eval_euclidean(u, 0);
Point3D::new(euc[0], euc[1], euc[2])
}
pub fn eval_points(&self, us: &[f64]) -> Vec<Point3D> {
us.iter().map(|&u| self.eval_point(u)).collect()
}
pub fn eval_derivative(&self, u: f64, order: u32) -> Vector3D {
match order {
0 => panic!("eval_derivative: order must be >= 1 (use eval_point for order 0)"),
1 | 2 => {
let euc = self.eval_euclidean(u, order as usize);
let base = order as usize * 3;
Vector3D::new(euc[base], euc[base + 1], euc[base + 2])
}
_ => panic!(
"eval_derivative: order {order} is not supported (only first and second derivatives are supported)"
),
}
}
pub fn parametrize_on(&self, surface: impl Into<Surface>) -> Result<Curve2D, ParametrizeError> {
let _ = surface.into();
Err(ParametrizeError::NotAnalytic)
}
pub fn contains(&self, point: Point3D, tol: Tolerance) -> bool {
self.project_point(point, tol).distance <= tol.confusion
}
pub fn extrema(&self, point: Point3D, tol: Tolerance) -> Vec<CurveProjection> {
use crate::projection::snap_distance;
let (first, last) = self.bounds();
let breaks = &self.span_tree().breaks;
let n = breaks.len() - 1;
let mut params = vec![self.project_point(point, tol).parameter];
let ends: Vec<SpanEnds> = (0..n)
.map(|k| self.scan_span(point, breaks[k], breaks[k + 1], &mut params))
.collect();
for k in 1..n {
if ends[k - 1].end_falling && ends[k].start_rising {
params.push(breaks[k]);
}
}
if !self.periodic {
params.push(first);
params.push(last);
} else if ends[n - 1].end_falling && ends[0].start_rising {
params.push(first);
}
let mut out: Vec<CurveProjection> = Vec::new();
let mut kept: Vec<Point3D> = Vec::new();
for t in params {
let p = self.eval_point(t);
if kept.iter().any(|q| q.distance(p) <= tol.confusion) {
continue;
}
kept.push(p);
out.push(CurveProjection {
parameter: t,
distance: snap_distance(p.distance(point), tol.confusion),
});
}
out.sort_by(|x, y| x.distance.total_cmp(&y.distance));
out
}
pub fn project_point(&self, point: Point3D, tol: Tolerance) -> CurveProjection {
use crate::projection::snap_distance;
let spans = self.span_tree();
let breaks = &spans.breaks;
let mut best = (f64::INFINITY, breaks[0]);
let mut minima = Vec::new();
spans.tree.nearest_first(point, |k| {
let (a, b) = (breaks[k], breaks[k + 1]);
minima.clear();
let ends = self.scan_span(point, a, b, &mut minima);
let interior = minima
.iter()
.map(|&t| (self.eval_point(t).distance(point), t));
for (d, t) in [(ends.distance[0], a), (ends.distance[1], b)]
.into_iter()
.chain(interior)
{
if d < best.0 {
best = (d, t);
}
}
best.0
});
let (first, last) = self.bounds();
let t = if self.periodic && best.1 == last {
first
} else {
best.1
};
CurveProjection {
parameter: t,
distance: snap_distance(self.eval_point(t).distance(point), tol.confusion),
}
}
pub fn project_points(&self, points: &[Point3D], tol: Tolerance) -> Vec<CurveProjection> {
points.iter().map(|&p| self.project_point(p, tol)).collect()
}
fn span_tree(&self) -> &SpanTree {
self.spans.get_or_init(|| {
let (first, last) = self.bounds();
let breaks = math::span_breaks(&self.knots, first, last);
let dim = if self.is_rational() { 4 } else { 3 };
let boxes = math::span_boxes(
&breaks,
self.degree,
self.periodic,
&self.flat,
&self.flat_poles,
dim,
);
SpanTree {
tree: BoxTree::new(&boxes, 1),
breaks,
}
})
}
fn scan_span(&self, point: Point3D, a: f64, b: f64, minima: &mut Vec<f64>) -> SpanEnds {
const SMALL: usize = 3 * math::SMALL;
if self.degree < math::SMALL {
self.scan_span_sized::<SMALL>(point, a, b, minima)
} else {
self.scan_span_sized::<{ crate::math::bernstein::CAPACITY }>(point, a, b, minima)
}
}
fn scan_span_sized<const N: usize>(
&self,
point: Point3D,
a: f64,
b: f64,
minima: &mut Vec<f64>,
) -> SpanEnds {
use crate::math::bernstein::{add_product, rising_roots, scale, unscale};
let p = self.degree;
let rational = self.is_rational();
let dim = if rational { 4 } else { 3 };
let mut bezier = [[0.0; N]; 3];
let mut weight = [1.0; N];
{
let [x, y, z] = &mut bezier;
let mut out: [&mut [f64]; 4] = [x, y, z, &mut weight];
math::span_bezier(
0.5 * (a + b),
p,
self.periodic,
&self.flat,
&self.flat_poles,
&mut out[..dim],
);
}
let q = [point.x, point.y, point.z];
let mut offset = [[0.0; N]; 3];
let mut slope = [[0.0; N]; 3];
let mut magnitude: f64 = 0.0;
for c in 0..3 {
for k in 0..=p {
offset[c][k] = bezier[c][k] - q[c] * weight[k];
magnitude = magnitude.max(bezier[c][k].abs() + q[c].abs() * weight[k]);
}
for k in 0..p {
slope[c][k] = offset[c][k + 1] - offset[c][k];
}
scale(&mut offset[c][..=p]);
scale(&mut slope[c][..p]);
}
let mut g = [0.0; N];
let degree_g = if rational { 3 * p - 1 } else { 2 * p - 1 };
if rational {
let mut dot = [0.0; N];
let mut square = [0.0; N];
for c in 0..3 {
add_product(&slope[c][..p], &offset[c][..=p], 1.0, &mut dot);
add_product(&offset[c][..=p], &offset[c][..=p], 1.0, &mut square);
}
let mut weight_slope = [0.0; N];
for k in 0..p {
weight_slope[k] = weight[k + 1] - weight[k];
}
let mut scaled_weight = weight;
scale(&mut scaled_weight[..=p]);
scale(&mut weight_slope[..p]);
add_product(&dot[..2 * p], &scaled_weight[..=p], 1.0, &mut g);
add_product(&square[..=2 * p], &weight_slope[..p], -1.0, &mut g);
} else {
for c in 0..3 {
add_product(&slope[c][..p], &offset[c][..=p], 1.0, &mut g);
}
}
let g = &mut g[..=degree_g];
unscale(g);
let max_weight = weight[..=p].iter().fold(0.0f64, |m, &w| m.max(w));
let tiny = 1e3 * f64::EPSILON * magnitude * magnitude * max_weight;
let h = b - a;
rising_roots::<N>(g, tiny, |s0, s1, s| {
let bounds = (a + s0 * h, if s1 == 1.0 { b } else { a + s1 * h });
minima.push(self.refine_projection(point, a + s * h, Some(bounds)));
});
let end_distance = |k: usize| {
(0..3)
.map(|c| (offset[c][k] / weight[k]).powi(2))
.sum::<f64>()
.sqrt()
};
SpanEnds {
distance: [end_distance(0), end_distance(p)],
start_rising: g[0] >= -tiny,
end_falling: g[degree_g] <= tiny,
}
}
fn refine_projection(&self, point: Point3D, t: f64, bounds: Option<(f64, f64)>) -> f64 {
let objective = |t: f64| {
let e = self.eval_euclidean(math::inward(t, bounds), 2);
let r = [e[0] - point.x, e[1] - point.y, e[2] - point.z];
let dot = |a: &[f64], b: &[f64]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
(
0.5 * dot(&r, &r),
dot(&e[3..6], &r),
dot(&e[3..6], &e[3..6]) + dot(&e[6..9], &r),
)
};
let t = newton_min_1d(objective, t, bounds, 1e-14, 100);
if self.periodic {
let (first, last) = self.bounds();
first + (t - first).rem_euclid(last - first)
} else {
t
}
}
fn eval_euclidean(&self, u: f64, n: usize) -> [f64; 9] {
let dim = if self.is_rational() { 4 } else { 3 };
let mut raw = [0.0f64; 3 * 4];
math::eval_dn(
u,
self.degree,
self.periodic,
&self.flat,
&self.flat_poles,
dim,
n,
&mut raw,
);
let mut euc = [0.0f64; 9];
if self.is_rational() {
math::rational_derivatives(&raw, n, &mut euc);
} else {
euc.copy_from_slice(&raw[..9]);
}
euc
}
}
struct SpanEnds {
distance: [f64; 2],
start_rising: bool,
end_falling: bool,
}
fn pack_flat_poles(poles: &[Point3D], weights: Option<&[f64]>) -> Vec<f64> {
match weights {
None => poles.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
Some(ws) => poles
.iter()
.zip(ws)
.flat_map(|(p, &w)| [p.x * w, p.y * w, p.z * w, w])
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Axis3D, Frame3D};
#[test]
fn test_new_ok_accessors() {
let poles = vec![Point3D::new(0.0, 0.0, 0.0), Point3D::new(2.0, 0.0, 0.0)];
let curve =
BSplineCurve3D::new(1, poles.clone(), vec![0.0, 1.0], vec![2, 2], false).unwrap();
assert_eq!(curve.degree(), 1);
assert!(!curve.is_periodic());
assert!(!curve.is_rational());
assert_eq!(curve.poles(), poles.as_slice());
assert_eq!(curve.weights(), None);
assert_eq!(curve.knots(), &[0.0, 1.0]);
assert_eq!(curve.multiplicities(), &[2, 2]);
assert_eq!(curve.bounds(), (0.0, 1.0));
}
#[test]
fn test_new_rational_ok_accessors() {
let poles = vec![
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(1.0, 1.0, 0.0),
Point3D::new(0.0, 1.0, 0.0),
];
let w = std::f64::consts::FRAC_1_SQRT_2;
let curve = BSplineCurve3D::new_rational(
2,
poles,
vec![1.0, w, 1.0],
vec![0.0, 1.0],
vec![3, 3],
false,
)
.unwrap();
assert!(curve.is_rational());
assert_eq!(curve.weights(), Some([1.0, w, 1.0].as_slice()));
}
#[test]
fn test_new_invalid_degree_errors() {
let poles = vec![Point3D::ORIGIN];
let err = BSplineCurve3D::new(0, poles, vec![0.0, 1.0], vec![1, 1], false).unwrap_err();
assert_eq!(err, BSplineConstructionError::InvalidDegree);
}
#[test]
fn test_new_pole_count_mismatch_errors() {
let poles = vec![Point3D::ORIGIN; 3];
let err = BSplineCurve3D::new(3, poles, vec![0.0, 1.0, 2.0, 3.0], vec![4, 1, 1, 4], false)
.unwrap_err();
assert_eq!(err, BSplineConstructionError::PoleCountMismatch);
}
#[test]
fn test_new_rational_weight_count_mismatch_errors() {
let poles = vec![Point3D::ORIGIN, Point3D::new(1.0, 0.0, 0.0)];
let err =
BSplineCurve3D::new_rational(1, poles, vec![1.0], vec![0.0, 1.0], vec![2, 2], false)
.unwrap_err();
assert_eq!(err, BSplineConstructionError::WeightCountMismatch);
}
#[test]
fn test_new_rational_non_positive_weight_errors() {
let poles = vec![Point3D::ORIGIN, Point3D::new(1.0, 0.0, 0.0)];
let err = BSplineCurve3D::new_rational(
1,
poles,
vec![1.0, 0.0],
vec![0.0, 1.0],
vec![2, 2],
false,
)
.unwrap_err();
assert_eq!(err, BSplineConstructionError::NonPositiveWeight);
let poles = vec![Point3D::ORIGIN, Point3D::new(1.0, 0.0, 0.0)];
let err = BSplineCurve3D::new_rational(
1,
poles,
vec![1.0, -2.0],
vec![0.0, 1.0],
vec![2, 2],
false,
)
.unwrap_err();
assert_eq!(err, BSplineConstructionError::NonPositiveWeight);
}
#[test]
fn test_weight_checks_run_before_direction_validation() {
let poles = vec![Point3D::ORIGIN];
let err = BSplineCurve3D::new_rational(0, poles, vec![], vec![0.0, 1.0], vec![1, 1], false)
.unwrap_err();
assert_eq!(err, BSplineConstructionError::WeightCountMismatch);
}
#[test]
fn test_error_display_all_variants() {
let variants = [
BSplineConstructionError::InvalidDegree,
BSplineConstructionError::TooFewKnots,
BSplineConstructionError::KnotsNotIncreasing,
BSplineConstructionError::MultiplicityTooLarge,
BSplineConstructionError::PeriodicEndMultiplicityMismatch,
BSplineConstructionError::PoleCountMismatch,
BSplineConstructionError::WeightCountMismatch,
BSplineConstructionError::NonPositiveWeight,
];
for v in variants {
assert!(!v.to_string().is_empty());
}
}
#[test]
fn test_error_is_std_error() {
fn takes_error(_e: &dyn std::error::Error) {}
takes_error(&BSplineConstructionError::InvalidDegree);
}
#[test]
fn test_eval_point_degree1_line_midpoint() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
assert_eq!(curve.eval_point(0.5), Point3D::new(1.0, 0.0, 0.0));
}
#[test]
fn test_eval_points_matches_mapped_eval_point() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
let us = [0.0, 0.3, 0.5, 1.0];
let expected: Vec<Point3D> = us.iter().map(|&u| curve.eval_point(u)).collect();
assert_eq!(curve.eval_points(&us), expected);
}
#[test]
fn test_eval_derivative_order1_constant_tangent() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
assert_eq!(curve.eval_derivative(0.5, 1), Vector3D::new(2.0, 0.0, 0.0));
}
#[test]
fn test_eval_derivative_order2_of_line_is_zero() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
assert_eq!(curve.eval_derivative(0.5, 2), Vector3D::ZERO);
}
#[test]
#[should_panic(expected = "order must be >= 1")]
fn test_eval_derivative_order0_panics() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
curve.eval_derivative(0.5, 0);
}
#[test]
#[should_panic(expected = "only first and second derivatives are supported")]
fn test_eval_derivative_order3_panics_with_clear_message() {
let poles = vec![Point3D::ORIGIN, Point3D::new(2.0, 0.0, 0.0)];
let curve = BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap();
curve.eval_derivative(0.5, 3);
}
#[test]
fn test_periodic_bounds_are_full_knot_span() {
let curve = periodic_ring_curve();
assert_eq!(curve.bounds(), (0.0, 6.0));
}
#[test]
fn test_periodic_seam_point_matches_across_wrap() {
let curve = periodic_ring_curve();
let (first, last) = curve.bounds();
let p_first = curve.eval_point(first);
let p_last = curve.eval_point(last);
assert!((p_first.x - p_last.x).abs() < 1e-9);
assert!((p_first.y - p_last.y).abs() < 1e-9);
assert!((p_first.z - p_last.z).abs() < 1e-9);
}
#[test]
fn test_periodic_out_of_window_u_matches_golden_samples() {
let curve = periodic_ring_curve();
let p = curve.eval_point(-0.5);
assert!((p.x - 1.4375000000000002).abs() < 1e-7);
assert!((p.y - 0.829941011960087).abs() < 1e-7);
assert!((p.z - -6.938893903907228e-17).abs() < 1e-7);
let p = curve.eval_point(7.3);
assert!((p.x - -1.2338333333333324).abs() < 1e-7);
assert!((p.y - 1.1134199941321936).abs() < 1e-7);
assert!((p.z - 0.056799999999999996).abs() < 1e-7);
}
fn periodic_ring_curve() -> BSplineCurve3D {
let poles = vec![
Point3D::new(2.0, 0.0, 0.3),
Point3D::new(1.0000000000000002, 1.7320508075688772, -0.3),
Point3D::new(-0.9999999999999996, 1.7320508075688774, 0.3),
Point3D::new(-2.0, 2.4492935982947064e-16, -0.3),
Point3D::new(-1.0000000000000009, -1.7320508075688767, 0.3),
Point3D::new(1.0000000000000002, -1.7320508075688772, -0.3),
];
let knots = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let mults = vec![1; 7];
BSplineCurve3D::new(3, poles, knots, mults, true).unwrap()
}
fn degree_one_segment() -> BSplineCurve3D {
let poles = vec![Point3D::new(0.0, 0.0, 0.0), Point3D::new(2.0, 0.0, 0.0)];
BSplineCurve3D::new(1, poles, vec![0.0, 1.0], vec![2, 2], false).unwrap()
}
#[test]
fn test_bspline_project_point_segment() {
let curve = degree_one_segment();
let tol = Tolerance::DEFAULT;
let proj = curve.project_point(Point3D::new(1.0, 1.0, 0.0), tol);
assert!((proj.parameter - 0.5).abs() < 1e-9);
assert!((proj.distance - 1.0).abs() < 1e-9);
let end = curve.project_point(Point3D::new(5.0, 0.0, 0.0), tol);
assert!((end.parameter - 1.0).abs() < 1e-9);
assert!((end.distance - 3.0).abs() < 1e-9);
}
#[test]
fn test_bspline_contains_segment() {
let curve = degree_one_segment();
let tol = Tolerance::DEFAULT;
assert!(curve.contains(Point3D::new(1.0, 0.0, 0.0), tol));
assert!(curve.contains(curve.eval_point(0.25), tol));
assert!(!curve.contains(Point3D::new(1.0, 1.0, 0.0), tol));
assert!(!curve.contains(Point3D::new(3.0, 0.0, 0.0), tol));
}
#[test]
fn test_bspline_project_point_periodic_ring() {
let curve = periodic_ring_curve();
let tol = Tolerance::DEFAULT;
let on = curve.eval_point(2.5);
assert_eq!(curve.project_point(on, tol).distance, 0.0);
assert!(curve.contains(on, tol));
let query = Point3D::new(0.5, 1.0, 2.0);
let proj = curve.project_point(query, tol);
let (first, last) = curve.bounds();
let mut best = f64::INFINITY;
for i in 0..=2000 {
let d = curve
.eval_point(first + (last - first) * i as f64 / 2000.0)
.distance(query);
best = best.min(d);
}
assert!(proj.distance <= best);
assert!(best - proj.distance < 1e-3);
}
#[test]
#[allow(clippy::approx_constant)] fn test_bspline_project_point_close_basins_in_one_span() {
let poles: Vec<Point3D> = [
(-2.25, 3.33, -1.92),
(1.71, 1.64, 0.97),
(1.34, 1.35, 2.74),
(4.44, 4.84, -0.38),
(-1.01, -3.14, 0.75),
(3.09, 2.88, -2.34),
(-2.89, -2.3, 1.67),
(-2.52, -0.25, -2.4),
(-0.46, -4.8, 2.63),
(-1.1, -3.33, 3.21),
(1.66, 0.37, -3.9),
(1.89, -4.08, 4.82),
(2.57, 0.13, 4.41),
(1.98, 0.86, -3.55),
(1.38, -3.48, -2.22),
(1.78, -0.64, -4.31),
(3.03, 2.35, 4.74),
(-2.61, -1.25, -2.56),
(-1.6, 4.56, -3.09),
(4.73, -3.44, -0.01),
(-4.69, 3.97, -3.48),
(-3.26, 3.84, 0.16),
]
.iter()
.map(|&(x, y, z)| Point3D::new(x, y, z))
.collect();
let weights = vec![
2.85, 3.49, 0.48, 4.48, 2.42, 0.46, 4.03, 4.34, 4.38, 2.57, 4.8, 0.85, 1.9, 1.18, 4.07,
2.17, 1.43, 1.28, 3.19, 4.94, 1.84, 1.29,
];
let knots = vec![2.07, 2.56, 3.15, 4.75, 5.48, 6.12, 7.5, 8.3, 9.67];
let curve = BSplineCurve3D::new_rational(
5,
poles,
weights,
knots,
vec![6, 1, 1, 1, 4, 3, 2, 4, 6],
false,
)
.unwrap();
for i in 0..=200 {
let t = 6.12 + 1.38 * i as f64 / 200.0;
let q = curve.eval_point(t);
let proj = curve.project_point(q, Tolerance::DEFAULT);
assert_eq!(proj.distance, 0.0, "t = {t}: {proj:?}");
assert_eq!(curve.extrema(q, Tolerance::DEFAULT)[0].distance, 0.0);
}
}
#[test]
fn test_bspline_project_point_exact_foot() {
let poles = vec![
Point3D::new(0.0, 0.0, 0.0),
Point3D::new(1.0, 1.0, 0.0),
Point3D::new(2.0, 1.0, 0.0),
Point3D::new(3.0, 0.0, 0.0),
];
let curve = BSplineCurve3D::new(3, poles, vec![0.0, 1.0], vec![4, 4], false).unwrap();
let proj = curve.project_point(Point3D::new(1.526, -0.65, 0.0), Tolerance::DEFAULT);
assert!((proj.parameter - 0.6).abs() < 1e-12);
assert!((proj.distance - 1.951976f64.sqrt()).abs() < 1e-12);
let poles = vec![
Point3D::new(2.0, 0.0, 0.0),
Point3D::new(0.66, 3.437, 1.22),
Point3D::new(-4.644, 1.852, 2.439),
];
let curve = BSplineCurve3D::new(2, poles, vec![1.0242, 2.3891], vec![3, 3], false).unwrap();
let query = Point3D::new(1.436, 5.4066, 5.122);
let proj = curve.project_point(query, Tolerance::DEFAULT);
assert!((proj.parameter - 1.756876).abs() < 1e-6);
assert!((proj.distance - 5.349035).abs() < 1e-6);
let r = curve.eval_point(proj.parameter) - query;
let d1 = curve.eval_derivative(proj.parameter, 1);
assert!(
r.dot(d1).abs() < 1e-12 * r.magnitude() * d1.magnitude(),
"{}",
r.dot(d1)
);
}
#[test]
fn test_interpolate_line_points() {
let tol = Tolerance::DEFAULT;
let points = vec![
Point3D::new(0.0, 0.0, 0.0),
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(3.0, 0.0, 0.0),
];
for param in [
InterpParametrization::Chordal,
InterpParametrization::Centripetal,
] {
let curve = BSplineCurve3D::interpolate(&points, 2, param).unwrap();
for p in &points {
assert!(curve.contains(*p, tol), "{param:?}");
}
assert!((curve.eval_point(0.5).y).abs() < 1e-9);
assert!((curve.eval_point(0.5).z).abs() < 1e-9);
}
}
#[test]
fn test_interpolate_circle_points() {
let tol = Tolerance::DEFAULT;
let points: Vec<Point3D> = (0..9)
.map(|i| {
let u = i as f64 / 8.0 * std::f64::consts::TAU;
Point3D::new(2.0 * u.cos(), 2.0 * u.sin(), 0.0)
})
.collect();
let curve =
BSplineCurve3D::interpolate(&points, 3, InterpParametrization::Centripetal).unwrap();
for p in &points {
assert!(curve.contains(*p, tol));
}
for i in 0..=40 {
let p = curve.eval_point(i as f64 / 40.0);
let r = (p.x * p.x + p.y * p.y).sqrt();
assert!((r - 2.0).abs() < 0.05, "{p:?}");
}
}
#[test]
fn test_interpolate_errors() {
let one = vec![Point3D::ORIGIN];
assert_eq!(
BSplineCurve3D::interpolate(&one, 1, InterpParametrization::Chordal),
Err(BSplineConstructionError::TooFewPoints)
);
let three = vec![
Point3D::ORIGIN,
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(1.0, 1.0, 0.0),
];
assert_eq!(
BSplineCurve3D::interpolate(&three, 0, InterpParametrization::Chordal),
Err(BSplineConstructionError::InvalidDegree)
);
assert_eq!(
BSplineCurve3D::interpolate(&three, 3, InterpParametrization::Chordal),
Err(BSplineConstructionError::DegreeTooHigh)
);
let dup = vec![
Point3D::ORIGIN,
Point3D::ORIGIN,
Point3D::new(1.0, 0.0, 0.0),
];
assert_eq!(
BSplineCurve3D::interpolate(&dup, 1, InterpParametrization::Chordal),
Err(BSplineConstructionError::ConfusedPoints)
);
}
#[test]
fn test_approximate_circle_fewer_poles() {
let points: Vec<Point3D> = (0..12)
.map(|i| {
let u = i as f64 / 12.0 * std::f64::consts::TAU;
Point3D::new(2.0 * u.cos(), 2.0 * u.sin(), 0.0)
})
.collect();
let curve =
BSplineCurve3D::approximate(&points, 3, 7, InterpParametrization::Centripetal).unwrap();
assert_eq!(curve.degree(), 3);
for i in 0..=60 {
let p = curve.eval_point(i as f64 / 60.0);
let r = (p.x * p.x + p.y * p.y).sqrt();
assert!((r - 2.0).abs() < 0.05, "{p:?}");
}
}
#[test]
fn test_approximate_line_data_exact() {
let points = vec![
Point3D::new(0.0, 0.0, 0.0),
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(2.0, 0.0, 0.0),
Point3D::new(3.0, 0.0, 0.0),
];
let curve =
BSplineCurve3D::approximate(&points, 1, 2, InterpParametrization::Chordal).unwrap();
for i in 0..=20 {
let p = curve.eval_point(i as f64 / 20.0);
assert!(p.y.abs() < 1e-9 && p.z.abs() < 1e-9, "{p:?}");
assert!((0.0..=3.0).contains(&p.x), "{p:?}");
}
}
#[test]
fn test_approximate_errors() {
let one = vec![Point3D::ORIGIN];
assert_eq!(
BSplineCurve3D::approximate(&one, 1, 2, InterpParametrization::Chordal),
Err(BSplineConstructionError::TooFewPoints)
);
let four = vec![
Point3D::ORIGIN,
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(1.0, 1.0, 0.0),
Point3D::new(0.0, 1.0, 0.0),
];
assert_eq!(
BSplineCurve3D::approximate(&four, 1, 5, InterpParametrization::Chordal),
Err(BSplineConstructionError::TooManyPoles)
);
assert_eq!(
BSplineCurve3D::approximate(&four, 4, 3, InterpParametrization::Chordal),
Err(BSplineConstructionError::DegreeTooHigh)
);
}
fn transformed_test_curve() -> BSplineCurve3D {
let poles = vec![
Point3D::new(0.0, 0.0, 0.0),
Point3D::new(1.0, 2.0, 0.0),
Point3D::new(3.0, 2.0, 1.0),
Point3D::new(4.0, 0.0, 1.0),
];
BSplineCurve3D::new(3, poles, vec![0.0, 1.0], vec![4, 4], false).unwrap()
}
#[test]
fn test_bspline_curve3d_transformed_translation_spot_check() {
let curve = transformed_test_curve();
let t = Transform::translation(Vector3D::new(1.0, 1.0, 1.0));
let moved = curve.transformed(t);
assert_eq!(moved.poles().len(), curve.poles().len());
for (a, b) in moved.poles().iter().zip(curve.poles()) {
assert_eq!(*a, t.apply_point(*b));
}
assert_eq!(moved.knots(), curve.knots());
assert_eq!(moved.bounds(), curve.bounds());
let p = moved.eval_point(0.5);
let q = curve.eval_point(0.5);
assert_eq!(p, Point3D::new(q.x + 1.0, q.y + 1.0, q.z + 1.0));
}
#[test]
fn test_bspline_curve3d_transformed_round_trip_and_mirror() {
use std::f64::consts::PI;
let curve = transformed_test_curve();
let axis = Axis3D::new(Point3D::new(0.0, 1.0, 0.0), Vector3D::Z).unwrap();
let t = Transform::rotation(axis, PI / 3.0).then(Transform::scaling(Point3D::ORIGIN, 2.0));
let moved = curve.transformed(t);
let tol = Tolerance::DEFAULT;
for i in 0..=10 {
let u = i as f64 / 10.0;
assert!(moved.contains(t.apply_point(curve.eval_point(u)), tol));
}
let inv =
Transform::scaling(Point3D::ORIGIN, 0.5).then(Transform::rotation(axis, -PI / 3.0));
let back = moved.transformed(inv);
for i in 0..=10 {
let u = i as f64 / 10.0;
assert!(back.eval_point(u).distance(curve.eval_point(u)) < 1e-9);
}
let m = Transform::mirror_plane(Frame3D::WORLD);
let mirrored = curve.transformed(m);
for i in 0..=10 {
let u = i as f64 / 10.0;
assert!(mirrored.contains(m.apply_point(curve.eval_point(u)), tol));
}
assert_eq!(mirrored.transformed(m), curve);
}
#[test]
fn test_bspline_curve3d_transformed_rational_keeps_weights() {
let w = std::f64::consts::FRAC_1_SQRT_2;
let poles = vec![
Point3D::new(1.0, 0.0, 0.0),
Point3D::new(1.0, 1.0, 0.0),
Point3D::new(0.0, 1.0, 0.0),
];
let curve = BSplineCurve3D::new_rational(
2,
poles,
vec![1.0, w, 1.0],
vec![0.0, 1.0],
vec![3, 3],
false,
)
.unwrap();
let t = Transform::translation(Vector3D::new(5.0, 0.0, 0.0));
let moved = curve.transformed(t);
assert_eq!(moved.weights(), curve.weights());
assert!(moved.is_rational());
let p = moved.eval_point(1.0);
assert!((p.x - 5.0).abs() < 1e-9 && (p.y - 1.0).abs() < 1e-9);
}
}