Skip to main content

ogeom_math/
knots.rs

1//! Knot vectors and B-spline basis functions.
2//!
3//! The basis is the foundation of every free-form curve and surface in the
4//! kernel. Everything else (de Boor evaluation, knot insertion, degree
5//! elevation, Bézier decomposition) is built on the functions here.
6//!
7//! # Representation
8//!
9//! A [`KnotVector`] stores the *flat* non-decreasing sequence, with repeated
10//! knots written out. That is what every algorithm wants, and deriving it from
11//! a distinct-knots-plus-multiplicities form on each call would cost an
12//! allocation in the hottest loop in the crate.
13//!
14//! Repeated knots must be bit-identical, and every operation here preserves
15//! that: knot insertion copies the inserted value rather than recomputing it.
16//! Multiplicity is therefore an exact question, not a tolerance one, which
17//! matters because multiplicity determines continuity: a knot of multiplicity
18//! `p` in a degree-`p` curve is a corner, and "nearly a corner" is not a thing.
19//!
20//! # Conventions
21//!
22//! For degree `p` and `n` control points the flat vector has `n + p + 1`
23//! entries. A *clamped* vector repeats its first and last knots `p + 1` times,
24//! so the curve passes through its first and last control points; that is the
25//! usual form for a bounded curve and the one [`KnotVector::clamped_uniform`]
26//! produces.
27
28use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
29use smallvec::SmallVec;
30
31/// Basis values for one span, sized to avoid allocating for typical degrees.
32pub type BasisValues = SmallVec<[f64; 8]>;
33
34/// One row of basis values per derivative order, inline up to the jet
35/// orders the kernel asks for.
36pub type DerivativeRows = SmallVec<[BasisValues; 4]>;
37
38/// A non-decreasing knot sequence with an associated degree.
39#[derive(Debug, Clone, PartialEq)]
40pub struct KnotVector {
41    knots: Vec<f64>,
42    degree: usize,
43}
44
45impl KnotVector {
46    /// A knot vector from a flat non-decreasing sequence.
47    ///
48    /// # Errors
49    ///
50    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
51    /// sequence is too short for the degree, is not non-decreasing, contains a
52    /// non-finite value, or has an interior knot of multiplicity greater than
53    /// the degree, which would disconnect the curve rather than merely make it
54    /// sharp.
55    pub fn new(knots: Vec<f64>, degree: usize) -> OgeomResult<Self> {
56        if degree == 0 {
57            ogeom_bail!(Construction, "degree must be at least 1");
58        }
59        // n + p + 1 entries for n control points, and n >= p + 1 for the basis
60        // to be well defined over a non-empty domain.
61        let minimum = 2 * (degree + 1);
62        if knots.len() < minimum {
63            ogeom_bail!(
64                Construction,
65                "degree {degree} needs at least {minimum} knots, got {}",
66                knots.len()
67            );
68        }
69        if !knots.iter().all(|k| k.is_finite()) {
70            ogeom_bail!(Construction, "knot vector contains a non-finite value");
71        }
72        if knots.windows(2).any(|w| w[1] < w[0]) {
73            ogeom_bail!(Construction, "knot vector is not non-decreasing");
74        }
75
76        let this = Self { knots, degree };
77        if this.domain_start() >= this.domain_end() {
78            ogeom_bail!(Construction, "knot vector spans an empty domain");
79        }
80
81        // Interior multiplicity above the degree splits the curve in two.
82        let (first, last) = (this.degree, this.knots.len() - this.degree - 1);
83        let mut index = first;
84        while index < last {
85            let value = this.knots[index];
86            let mut count = 0;
87            while index < last && this.knots[index] == value {
88                count += 1;
89                index += 1;
90            }
91            // The two clamp knots at either end of the domain are allowed their
92            // full multiplicity; only strictly interior ones are constrained.
93            if value > this.domain_start() && value < this.domain_end() && count > this.degree {
94                ogeom_bail!(
95                    Construction,
96                    "interior knot {value} has multiplicity {count}, above degree {}",
97                    this.degree
98                );
99            }
100        }
101        Ok(this)
102    }
103
104    /// A clamped uniform knot vector for `control_points` control points.
105    ///
106    /// The domain is `[0, 1]`, the ends are clamped, and the interior knots are
107    /// evenly spaced.
108    ///
109    /// # Errors
110    ///
111    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are
112    /// too few control points for the degree.
113    pub fn clamped_uniform(degree: usize, control_points: usize) -> OgeomResult<Self> {
114        if control_points < degree + 1 {
115            ogeom_bail!(
116                Construction,
117                "degree {degree} needs at least {} control points, got {control_points}",
118                degree + 1
119            );
120        }
121        let interior = control_points - degree - 1;
122        let mut knots = Vec::with_capacity(control_points + degree + 1);
123        knots.extend(core::iter::repeat_n(0.0, degree + 1));
124        for i in 1..=interior {
125            #[allow(clippy::cast_precision_loss)]
126            knots.push(i as f64 / (interior + 1) as f64);
127        }
128        knots.extend(core::iter::repeat_n(1.0, degree + 1));
129        Self::new(knots, degree)
130    }
131
132    /// A clamped knot vector from parameter values, for interpolation.
133    ///
134    /// Uses the averaging rule, which places interior knots so that the
135    /// resulting interpolation system is well conditioned; a uniform vector
136    /// over unevenly spaced parameters gives a nearly singular one.
137    ///
138    /// # Errors
139    ///
140    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are
141    /// too few parameters, or they are not strictly increasing.
142    pub fn averaged(degree: usize, parameters: &[f64]) -> OgeomResult<Self> {
143        if parameters.len() < degree + 1 {
144            ogeom_bail!(
145                Construction,
146                "degree {degree} needs at least {} parameters",
147                degree + 1
148            );
149        }
150        if parameters.windows(2).any(|w| w[1] <= w[0]) {
151            ogeom_bail!(Construction, "parameters must be strictly increasing");
152        }
153        let n = parameters.len();
154        let mut knots = Vec::with_capacity(n + degree + 1);
155        knots.extend(core::iter::repeat_n(parameters[0], degree + 1));
156        #[allow(clippy::cast_precision_loss)]
157        for j in 1..n - degree {
158            let mean: f64 = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
159            knots.push(mean);
160        }
161        knots.extend(core::iter::repeat_n(parameters[n - 1], degree + 1));
162        Self::new(knots, degree)
163    }
164
165    /// The degree.
166    #[must_use]
167    pub const fn degree(&self) -> usize {
168        self.degree
169    }
170
171    /// The flat knot sequence.
172    #[must_use]
173    pub fn knots(&self) -> &[f64] {
174        &self.knots
175    }
176
177    /// The number of control points this vector describes.
178    #[must_use]
179    pub const fn control_point_count(&self) -> usize {
180        self.knots.len() - self.degree - 1
181    }
182
183    /// The first parameter of the usable domain.
184    #[must_use]
185    pub fn domain_start(&self) -> f64 {
186        self.knots[self.degree]
187    }
188
189    /// The last parameter of the usable domain.
190    #[must_use]
191    pub fn domain_end(&self) -> f64 {
192        self.knots[self.knots.len() - self.degree - 1]
193    }
194
195    /// The usable domain.
196    #[must_use]
197    pub fn domain(&self) -> (f64, f64) {
198        (self.domain_start(), self.domain_end())
199    }
200
201    /// Whether the ends are clamped, so the curve meets its first and last
202    /// control points.
203    #[must_use]
204    pub fn is_clamped(&self) -> bool {
205        let last = self.knots.len() - 1;
206        self.knots[..=self.degree]
207            .iter()
208            .all(|k| *k == self.knots[0])
209            && self.knots[last - self.degree..]
210                .iter()
211                .all(|k| *k == self.knots[last])
212    }
213
214    /// The multiplicity of the knot value at `index`.
215    ///
216    /// Exact: repeated knots are bit-identical by construction.
217    #[must_use]
218    pub fn multiplicity_at(&self, index: usize) -> usize {
219        let Some(&value) = self.knots.get(index) else {
220            return 0;
221        };
222        self.knots.iter().filter(|k| **k == value).count()
223    }
224
225    /// The multiplicity of `value`, or zero if it is not a knot.
226    #[must_use]
227    pub fn multiplicity_of(&self, value: f64) -> usize {
228        self.knots.iter().filter(|k| **k == value).count()
229    }
230
231    /// The distinct knot values with their multiplicities, in order.
232    #[must_use]
233    pub fn distinct(&self) -> Vec<(f64, usize)> {
234        let mut out: Vec<(f64, usize)> = Vec::new();
235        for &k in &self.knots {
236            match out.last_mut() {
237                Some((value, count)) if *value == k => *count += 1,
238                _ => out.push((k, 1)),
239            }
240        }
241        out
242    }
243
244    /// Whether `u` lies in the usable domain, within `tol.parametric()`.
245    #[must_use]
246    pub fn contains(&self, u: f64, tol: Tolerances) -> bool {
247        let (start, end) = self.domain();
248        u >= start - tol.parametric() && u <= end + tol.parametric()
249    }
250
251    /// The index of the knot span containing `u`.
252    ///
253    /// Returns `i` with `knots[i] <= u < knots[i+1]`, clamped so that the end of
254    /// the domain resolves to the last non-empty span rather than falling off
255    /// it. Binary search, so cost is logarithmic in the knot count.
256    ///
257    /// # Errors
258    ///
259    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `u` is outside the
260    /// domain by more than `tol.parametric()`.
261    pub fn span(&self, u: f64, tol: Tolerances) -> OgeomResult<usize> {
262        let (start, end) = self.domain();
263        if !u.is_finite() || u < start - tol.parametric() || u > end + tol.parametric() {
264            ogeom_bail!(Domain, "parameter {u} outside knot domain [{start}, {end}]");
265        }
266        Ok(self.span_unchecked(u))
267    }
268
269    /// The knot span containing `u`, clamping out-of-range values into the
270    /// domain rather than reporting them.
271    #[must_use]
272    pub fn span_unchecked(&self, u: f64) -> usize {
273        let last = self.control_point_count() - 1;
274        // The end of the domain belongs to the last span; without this it would
275        // land one past it, since the search looks for `knots[i] <= u`.
276        if u >= self.knots[last + 1] {
277            return last;
278        }
279        if u <= self.knots[self.degree] {
280            return self.degree;
281        }
282        let mut low = self.degree;
283        let mut high = last + 1;
284        while high - low > 1 {
285            let mid = usize::midpoint(low, high);
286            if u < self.knots[mid] {
287                high = mid;
288            } else {
289                low = mid;
290            }
291        }
292        low
293    }
294
295    /// The `degree + 1` non-zero basis functions at `u`.
296    ///
297    /// Entry `i` is the value of basis function `span - degree + i`. They are
298    /// non-negative and sum to exactly one up to rounding: the partition of
299    /// unity, which is what makes a B-spline curve lie in the convex hull of its
300    /// control points.
301    ///
302    /// Cox-de Boor, in the triangular form that avoids evaluating the zero
303    /// functions and never divides by a zero knot difference.
304    #[must_use]
305    pub fn basis(&self, span: usize, u: f64) -> BasisValues {
306        let p = self.degree;
307        let mut n = BasisValues::with_capacity(p + 1);
308        n.push(1.0);
309        let mut left = BasisValues::with_capacity(p + 1);
310        let mut right = BasisValues::with_capacity(p + 1);
311        left.push(0.0);
312        right.push(0.0);
313
314        for j in 1..=p {
315            left.push(u - self.knots[span + 1 - j]);
316            right.push(self.knots[span + j] - u);
317            let mut saved = 0.0;
318            n.push(0.0);
319            for r in 0..j {
320                // `right[r + 1] + left[j - r]` is the width of the union of two
321                // adjacent supports, which is positive whenever the basis
322                // function is, so this cannot divide by zero for a valid vector.
323                let denominator = right[r + 1] + left[j - r];
324                let temp = n[r] / denominator;
325                n[r] = saved + right[r + 1] * temp;
326                saved = left[j - r] * temp;
327            }
328            n[j] = saved;
329        }
330        n
331    }
332
333    /// The non-zero basis functions and their derivatives up to order `n`.
334    ///
335    /// `result[k][i]` is the `k`th derivative of basis function
336    /// `span - degree + i`. Orders above the degree are identically zero and
337    /// are returned as such rather than as noise.
338    #[must_use]
339    pub fn basis_derivatives(&self, span: usize, u: f64, n: usize) -> DerivativeRows {
340        let p = self.degree;
341        let order = n.min(p);
342
343        // `ndu` holds the basis values and the knot differences from the
344        // triangular recurrence; both halves are needed to build derivatives.
345        // Every scratch row lives inline for the degrees the kernel actually
346        // meets: this is the innermost loop of every spline evaluation, and
347        // a heap row per call there is the kernel's largest allocation source.
348        let mut ndu: SmallVec<[BasisValues; 8]> =
349            core::iter::repeat_with(|| BasisValues::from_elem(0.0, p + 1))
350                .take(p + 1)
351                .collect();
352        ndu[0][0] = 1.0;
353        let mut left = BasisValues::from_elem(0.0, p + 1);
354        let mut right = BasisValues::from_elem(0.0, p + 1);
355
356        for j in 1..=p {
357            left[j] = u - self.knots[span + 1 - j];
358            right[j] = self.knots[span + j] - u;
359            let mut saved = 0.0;
360            for r in 0..j {
361                ndu[j][r] = right[r + 1] + left[j - r];
362                let temp = ndu[r][j - 1] / ndu[j][r];
363                ndu[r][j] = saved + right[r + 1] * temp;
364                saved = left[j - r] * temp;
365            }
366            ndu[j][j] = saved;
367        }
368
369        let mut derivatives: DerivativeRows =
370            core::iter::repeat_with(|| BasisValues::from_elem(0.0, p + 1))
371                .take(n + 1)
372                .collect();
373        for (j, slot) in derivatives[0].iter_mut().enumerate() {
374            *slot = ndu[j][p];
375        }
376        // The rows above `order` stay zero: a derivative past the degree of a
377        // piecewise polynomial is identically zero, not merely small.
378
379        // Two alternating rows of coefficients, per the standard algorithm.
380        let mut a = [
381            BasisValues::from_elem(0.0, p + 1),
382            BasisValues::from_elem(0.0, p + 1),
383        ];
384        for r in 0..=p {
385            let (mut s1, mut s2) = (0_usize, 1_usize);
386            a[0][0] = 1.0;
387            for k in 1..=order {
388                let mut d = 0.0;
389                let rk = r as isize - k as isize;
390                let pk = p - k;
391                if r >= k {
392                    a[s2][0] = a[s1][0] / ndu[pk + 1][rk as usize];
393                    d = a[s2][0] * ndu[rk as usize][pk];
394                }
395                let j1 = if rk >= -1 { 1 } else { (-rk) as usize };
396                let j2 = if r as isize - 1 <= pk as isize {
397                    k - 1
398                } else {
399                    p - r
400                };
401                for j in j1..=j2 {
402                    let index = (rk + j as isize) as usize;
403                    a[s2][j] = (a[s1][j] - a[s1][j - 1]) / ndu[pk + 1][index];
404                    d += a[s2][j] * ndu[index][pk];
405                }
406                if r <= pk {
407                    a[s2][k] = -a[s1][k - 1] / ndu[pk + 1][r];
408                    d += a[s2][k] * ndu[r][pk];
409                }
410                derivatives[k][r] = d;
411                core::mem::swap(&mut s1, &mut s2);
412            }
413        }
414
415        // Multiply through by the falling factorial the recurrence omits.
416        let mut factor = p;
417        for (k, row) in derivatives.iter_mut().enumerate().take(order + 1).skip(1) {
418            #[allow(clippy::cast_precision_loss)]
419            let scale = factor as f64;
420            for value in row.iter_mut() {
421                *value *= scale;
422            }
423            factor = factor.saturating_mul(p.saturating_sub(k));
424        }
425        derivatives
426    }
427
428    /// Insert `value` into the sequence, `count` times.
429    ///
430    /// Only the knots change; adjusting control points to keep the shape is
431    /// [`crate::bspline::insert_knot`].
432    ///
433    /// # Errors
434    ///
435    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the result
436    /// would push a knot's multiplicity above the degree.
437    pub fn with_knot_inserted(&self, value: f64, count: usize) -> OgeomResult<Self> {
438        let mut knots = self.knots.clone();
439        let position = knots.partition_point(|k| *k <= value);
440        for _ in 0..count {
441            knots.insert(position, value);
442        }
443        Self::new(knots, self.degree)
444    }
445
446    /// This vector with its domain mapped onto `[start, end]`.
447    ///
448    /// # Errors
449    ///
450    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the target
451    /// range is empty or non-finite.
452    pub fn reparameterized(&self, start: f64, end: f64) -> OgeomResult<Self> {
453        if !start.is_finite() || !end.is_finite() || end <= start {
454            ogeom_bail!(Construction, "target range [{start}, {end}] is empty");
455        }
456        let (a, b) = self.domain();
457        let scale = (end - start) / (b - a);
458        // Map, then overwrite the repeated end knots with the exact endpoints:
459        // the arithmetic would otherwise give each copy a slightly different
460        // value and silently destroy the clamping.
461        let mut knots: Vec<f64> = self
462            .knots
463            .iter()
464            .map(|k| (k - a).mul_add(scale, start))
465            .collect();
466        for k in &mut knots {
467            if *k <= start {
468                *k = start;
469            } else if *k >= end {
470                *k = end;
471            }
472        }
473        let last = knots.len() - 1;
474        for i in 0..self.knots.len() {
475            if self.knots[i] == a {
476                knots[i] = start;
477            }
478            if self.knots[last - i] == b {
479                knots[last - i] = end;
480            }
481        }
482        Self::new(knots, self.degree)
483    }
484
485    /// This vector with the parameter direction reversed.
486    ///
487    /// The domain is preserved and the sequence of interior spacings is
488    /// mirrored. Reversing a curve reverses its knots and its control points
489    /// together.
490    ///
491    /// Multiplicity is preserved *exactly* (equal knots map through the same
492    /// arithmetic and so stay equal), which is what continuity depends on. The
493    /// interior knot *values* are not bit-exactly restored by reversing twice,
494    /// since `a + b - k` is not an exact involution in floating point; they
495    /// return to within one ulp.
496    #[must_use]
497    pub fn reversed(&self) -> Self {
498        let (a, b) = self.domain();
499        let sum = a + b;
500        let mut knots: Vec<f64> = self.knots.iter().rev().map(|k| sum - k).collect();
501        // Same reasoning as `reparameterized`: restore the endpoints exactly.
502        let last = knots.len() - 1;
503        for i in 0..knots.len() {
504            if knots[i] <= a {
505                knots[i] = a;
506            }
507            if knots[last - i] >= b {
508                knots[last - i] = b;
509            }
510        }
511        Self {
512            knots,
513            degree: self.degree,
514        }
515    }
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod tests {
521    use super::*;
522    use approx::assert_relative_eq;
523
524    const T: Tolerances = Tolerances::millimetres();
525
526    fn cubic() -> KnotVector {
527        // Degree 3, 7 control points, two interior knots.
528        KnotVector::new(
529            vec![0.0, 0.0, 0.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0, 1.0, 1.0],
530            3,
531        )
532        .unwrap()
533    }
534
535    #[test]
536    fn malformed_vectors_are_refused() {
537        assert!(KnotVector::new(vec![0.0, 1.0], 3).is_err(), "too short");
538        assert!(
539            KnotVector::new(vec![0.0, 0.0, 1.0, 0.5, 1.0, 1.0], 2).is_err(),
540            "not non-decreasing"
541        );
542        assert!(
543            KnotVector::new(vec![0.0, 0.0, f64::NAN, 1.0, 1.0, 1.0], 2).is_err(),
544            "non-finite"
545        );
546        assert!(KnotVector::new(vec![0.0; 8], 3).is_err(), "empty domain");
547        assert!(KnotVector::new(vec![], 0).is_err(), "degree zero");
548        // Interior multiplicity above the degree disconnects the curve.
549        assert!(KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0], 2).is_err());
550        // At the degree it is merely a corner, which is legitimate.
551        assert!(KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0], 2).is_ok());
552    }
553
554    #[test]
555    fn clamped_uniform_has_the_expected_shape() {
556        let k = KnotVector::clamped_uniform(3, 7).unwrap();
557        assert_eq!(k.knots().len(), 11);
558        assert_eq!(k.control_point_count(), 7);
559        assert_eq!(k.domain(), (0.0, 1.0));
560        assert!(k.is_clamped());
561        assert_eq!(k.multiplicity_of(0.0), 4);
562        assert_eq!(k.multiplicity_of(1.0), 4);
563        assert_relative_eq!(k.knots()[4], 1.0 / 4.0);
564
565        // A Bezier: no interior knots at all.
566        let b = KnotVector::clamped_uniform(3, 4).unwrap();
567        assert_eq!(b.knots(), &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
568        assert!(KnotVector::clamped_uniform(3, 3).is_err());
569    }
570
571    #[test]
572    fn span_lookup_handles_both_ends_of_the_domain() {
573        let k = cubic();
574        assert_eq!(k.span(0.0, T).unwrap(), 3, "domain start");
575        assert_eq!(k.span(0.1, T).unwrap(), 3);
576        assert_eq!(k.span(0.25, T).unwrap(), 4, "on a knot, span to its right");
577        assert_eq!(k.span(0.3, T).unwrap(), 4);
578        assert_eq!(k.span(0.9, T).unwrap(), 6);
579        // The domain end must resolve to the last span, not one past it.
580        assert_eq!(k.span(1.0, T).unwrap(), 6);
581        assert!(k.span(-0.1, T).is_err());
582        assert!(k.span(1.1, T).is_err());
583        assert!(k.span(f64::NAN, T).is_err());
584    }
585
586    #[test]
587    fn basis_functions_form_a_partition_of_unity() {
588        let k = cubic();
589        for i in 0..=200 {
590            let u = f64::from(i) / 200.0;
591            let span = k.span(u, T).unwrap();
592            let n = k.basis(span, u);
593            assert_eq!(n.len(), 4);
594            let sum: f64 = n.iter().sum();
595            assert_relative_eq!(sum, 1.0, epsilon = 1e-14);
596            assert!(n.iter().all(|v| *v >= -1e-15), "basis must be non-negative");
597        }
598    }
599
600    #[test]
601    fn basis_matches_the_bernstein_polynomials_for_a_bezier() {
602        // With no interior knots the B-spline basis is exactly Bernstein.
603        let k = KnotVector::clamped_uniform(3, 4).unwrap();
604        for i in 0..=20 {
605            let u = f64::from(i) / 20.0;
606            let span = k.span(u, T).unwrap();
607            let n = k.basis(span, u);
608            let v = 1.0 - u;
609            let expected = [v * v * v, 3.0 * u * v * v, 3.0 * u * u * v, u * u * u];
610            for (got, want) in n.iter().zip(expected) {
611                assert_relative_eq!(*got, want, epsilon = 1e-14);
612            }
613        }
614    }
615
616    #[test]
617    fn basis_is_an_interpolant_at_a_clamped_end() {
618        let k = cubic();
619        let n = k.basis(k.span(0.0, T).unwrap(), 0.0);
620        assert_relative_eq!(n[0], 1.0, epsilon = 1e-15);
621        assert!(n[1..].iter().all(|v| v.abs() < 1e-15));
622
623        let n = k.basis(k.span(1.0, T).unwrap(), 1.0);
624        assert_relative_eq!(n[3], 1.0, epsilon = 1e-15);
625        assert!(n[..3].iter().all(|v| v.abs() < 1e-15));
626    }
627
628    #[test]
629    fn basis_derivatives_agree_with_finite_differences() {
630        let k = cubic();
631        let h = 1e-6;
632        for i in 1..20 {
633            let u = f64::from(i) / 20.0;
634            let span = k.span(u, T).unwrap();
635            let d = k.basis_derivatives(span, u, 2);
636
637            // Zeroth order must reproduce the plain basis.
638            let plain = k.basis(span, u);
639            for (a, b) in d[0].iter().zip(plain.iter()) {
640                assert_relative_eq!(a, b, epsilon = 1e-14);
641            }
642
643            // First order against a central difference, evaluated in the same
644            // span so the basis indices line up.
645            let ahead = k.basis(span, u + h);
646            let behind = k.basis(span, u - h);
647            for j in 0..=k.degree() {
648                let numeric = (ahead[j] - behind[j]) / (2.0 * h);
649                assert_relative_eq!(d[1][j], numeric, epsilon = 1e-5);
650            }
651        }
652    }
653
654    #[test]
655    fn basis_derivatives_sum_to_zero() {
656        // The basis sums to one everywhere, so every derivative of that sum is
657        // identically zero: a strong check on the whole recurrence.
658        let k = cubic();
659        for i in 0..=50 {
660            let u = f64::from(i) / 50.0;
661            let span = k.span(u, T).unwrap();
662            let d = k.basis_derivatives(span, u, 3);
663            assert_relative_eq!(d[0].iter().sum::<f64>(), 1.0, epsilon = 1e-13);
664            for (order, row) in d.iter().enumerate().skip(1) {
665                let sum: f64 = row.iter().sum();
666                assert!(sum.abs() < 1e-8, "order {order} sums to {sum}");
667            }
668        }
669    }
670
671    #[test]
672    fn derivatives_above_the_degree_are_zero() {
673        let k = cubic();
674        let span = k.span(0.4, T).unwrap();
675        let d = k.basis_derivatives(span, 0.4, 5);
676        assert_eq!(d.len(), 6);
677        for (order, row) in d.iter().enumerate().skip(k.degree() + 1) {
678            assert!(row.iter().all(|v| *v == 0.0), "order {order} is not zero");
679        }
680    }
681
682    #[test]
683    fn multiplicity_and_distinct_knots() {
684        let k = cubic();
685        assert_eq!(k.multiplicity_of(0.0), 4);
686        assert_eq!(k.multiplicity_of(0.5), 1);
687        assert_eq!(k.multiplicity_of(0.6), 0);
688        assert_eq!(k.multiplicity_at(0), 4);
689        assert_eq!(
690            k.distinct(),
691            vec![(0.0, 4), (0.25, 1), (0.5, 1), (0.75, 1), (1.0, 4)]
692        );
693    }
694
695    #[test]
696    fn knot_insertion_raises_multiplicity() {
697        let k = cubic();
698        let inserted = k.with_knot_inserted(0.5, 2).unwrap();
699        assert_eq!(inserted.multiplicity_of(0.5), 3);
700        assert_eq!(inserted.knots().len(), k.knots().len() + 2);
701        assert_eq!(inserted.domain(), k.domain());
702        // Beyond the degree it would disconnect the curve.
703        assert!(k.with_knot_inserted(0.5, 3).is_err());
704    }
705
706    #[test]
707    fn reparameterization_preserves_clamping_exactly() {
708        let k = cubic();
709        let r = k.reparameterized(-2.0, 6.0).unwrap();
710        assert_eq!(r.domain(), (-2.0, 6.0));
711        assert!(r.is_clamped(), "the repeated end knots must stay identical");
712        assert_eq!(r.multiplicity_of(-2.0), 4);
713        assert_eq!(r.multiplicity_of(6.0), 4);
714        // Interior knots map proportionally.
715        assert_relative_eq!(r.knots()[4], 0.0, epsilon = 1e-12);
716        assert!(k.reparameterized(1.0, 1.0).is_err());
717        assert!(k.reparameterized(1.0, f64::NAN).is_err());
718    }
719
720    #[test]
721    fn reversal_mirrors_the_spacing_and_keeps_the_domain() {
722        // Deliberately uneven interior spacing, so a mirror is observable.
723        let k = KnotVector::new(vec![0.0, 0.0, 0.0, 0.1, 0.8, 1.0, 1.0, 1.0], 2).unwrap();
724        let r = k.reversed();
725        assert_eq!(r.domain(), k.domain());
726        assert!(r.is_clamped());
727        assert_relative_eq!(r.knots()[3], 0.2, epsilon = 1e-15);
728        assert_relative_eq!(r.knots()[4], 0.9, epsilon = 1e-15);
729        // Reversing twice restores the vector to within rounding. Not exactly:
730        // `a + b - k` is not an exact involution in floating point.
731        for (got, want) in r.reversed().knots().iter().zip(k.knots()) {
732            assert_relative_eq!(got, want, epsilon = 1e-15);
733        }
734        // What must hold exactly is multiplicity, since continuity depends on
735        // it: two knots that were equal stay equal through any number of
736        // reversals.
737        let multiplicities: Vec<usize> = r.distinct().iter().map(|(_, m)| *m).collect();
738        let original: Vec<usize> = k.distinct().iter().map(|(_, m)| *m).collect();
739        assert_eq!(multiplicities, original);
740    }
741
742    #[test]
743    fn averaged_knots_follow_the_parameters() {
744        let params = [0.0, 0.1, 0.4, 0.9, 1.0];
745        let k = KnotVector::averaged(3, &params).unwrap();
746        assert_eq!(k.control_point_count(), 5);
747        assert_eq!(k.domain(), (0.0, 1.0));
748        assert!(k.is_clamped());
749        // One interior knot, the mean of parameters 1..4.
750        assert_relative_eq!(k.knots()[4], (0.1 + 0.4 + 0.9) / 3.0, epsilon = 1e-15);
751        assert!(KnotVector::averaged(3, &[0.0, 1.0]).is_err());
752        assert!(
753            KnotVector::averaged(2, &[0.0, 0.5, 0.5, 1.0]).is_err(),
754            "not increasing"
755        );
756    }
757
758    #[test]
759    fn basis_at_a_repeated_interior_knot_is_still_a_partition_of_unity() {
760        // Multiplicity equal to the degree: a corner, where the recurrence has
761        // the most opportunity to divide by something vanishing.
762        let k = KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0], 2).unwrap();
763        for u in [0.0_f64, 0.25, 0.499_999, 0.5, 0.500_001, 0.75, 1.0] {
764            let span = k.span(u, T).unwrap();
765            let n = k.basis(span, u);
766            assert_relative_eq!(n.iter().sum::<f64>(), 1.0, epsilon = 1e-14);
767            assert!(n.iter().all(|v| v.is_finite()), "non-finite basis at {u}");
768        }
769    }
770}