use std::fmt;
use crate::curve_math::bspline as math;
use crate::curves::{Curve2D, ParametrizeError};
use crate::math::gauss_newton_1d;
use crate::projection::CurveProjection;
use crate::surfaces::Surface;
use crate::{Point3D, Tolerance, Vector3D};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BSplineConstructionError {
InvalidDegree,
TooFewKnots,
KnotsNotIncreasing,
MultiplicityTooLarge,
PeriodicEndMultiplicityMismatch,
PoleCountMismatch,
WeightCountMismatch,
NonPositiveWeight,
}
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",
};
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>,
}
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,
})
}
pub fn degree(&self) -> usize {
self.degree
}
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 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 clamp = |t: f64| {
if self.is_periodic() {
t
} else {
t.clamp(first, last)
}
};
const SEEDS: usize = 64;
const KEEP: usize = 8;
let mut samples: Vec<(f64, f64)> = (0..SEEDS)
.map(|i| {
let u = first + (last - first) * i as f64 / (SEEDS - 1) as f64;
(u, self.eval_point(u).distance(point))
})
.collect();
samples.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
let mut params: Vec<f64> = Vec::new();
for (u, _) in samples.into_iter().take(KEEP) {
let p = self.eval_point(u);
if params
.iter()
.any(|&t| self.eval_point(t).distance(p) <= tol.confusion)
{
continue;
}
let residual = |t: f64| {
let q = self.eval_point(clamp(t)) - point;
[q.x, q.y, q.z]
};
let jacobian = |t: f64| {
let v = self.eval_derivative(clamp(t), 1);
[v.x, v.y, v.z]
};
if let Some(t) = gauss_newton_1d(residual, jacobian, u, tol.confusion, 50) {
params.push(clamp(t));
}
}
if !self.is_periodic() {
params.push(first);
params.push(last);
}
if params.is_empty() {
params.push(first);
}
let mut out: Vec<CurveProjection> = Vec::new();
for t in params {
let p = self.eval_point(t);
if out.iter().any(|e: &CurveProjection| {
self.eval_point(e.parameter).distance(p) <= tol.confusion
}) {
continue;
}
let distance = p.distance(point);
out.push(CurveProjection {
parameter: t,
distance: snap_distance(distance, tol.confusion),
});
}
out.sort_by(|x, y| x.distance.partial_cmp(&y.distance).unwrap());
out
}
pub fn project_point(&self, point: Point3D, tol: Tolerance) -> CurveProjection {
self.extrema(point, tol)
.into_iter()
.next()
.expect("seeding guarantees a non-empty candidate list")
}
pub fn project_points(&self, points: &[Point3D], tol: Tolerance) -> Vec<CurveProjection> {
points.iter().map(|&p| self.project_point(p, tol)).collect()
}
fn eval_euclidean(&self, u: f64, n: usize) -> [f64; 9] {
let dim = if self.is_rational() { 4 } else { 3 };
let mut raw = vec![0.0f64; 3 * dim];
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);
}
euc
}
}
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::*;
#[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);
}
}