Skip to main content

ogeom_math/
bspline.rs

1//! B-spline algorithms over control points: evaluation, refinement, elevation.
2//!
3//! Everything here is generic over [`Blend`], the affine structure a control
4//! point needs. That is what lets one implementation serve curves and surfaces,
5//! 2D and 3D, and (through the homogeneous trick) rational and non-rational
6//! alike, instead of four near-copies that drift apart.
7//!
8//! # Rational curves
9//!
10//! A rational B-spline is a non-rational one in one higher dimension: weight
11//! each control point, carry the weight as an extra coordinate, evaluate as
12//! usual, then divide through. Every algorithm here therefore applies unchanged
13//! to rational geometry via [`Weighted`], which matters because exact circles,
14//! cylinders and spheres are *only* representable rationally.
15
16use smallvec::SmallVec;
17
18/// Derivatives up to a small order in each direction, inline: the kernel
19/// asks for jets of order two, and the innermost evaluation loops must not
20/// pay heap for their own scratch.
21pub type DerivativeGrid<P> = SmallVec<[SmallVec<[P; 4]>; 4]>;
22use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
23
24use crate::{KnotVector, Point, Point2, Vector, Vector2};
25
26/// The affine structure a control point needs: scaling and addition.
27///
28/// Implemented for vectors, points and scalars. de Boor and the refinement
29/// algorithms take only affine combinations (coefficients summing to one), so
30/// applying them to positions is meaningful even though positions have no
31/// meaningful sum on their own.
32pub trait Blend: Copy {
33    /// The additive identity.
34    fn zero() -> Self;
35    /// Scale by a factor.
36    fn scale(self, k: f64) -> Self;
37    /// Add another value.
38    fn add(self, other: Self) -> Self;
39
40    /// `self * (1 - t) + other * t`.
41    #[must_use]
42    fn lerp(self, other: Self, t: f64) -> Self {
43        self.scale(1.0 - t).add(other.scale(t))
44    }
45
46    /// Subtract, via scaling by `-1`.
47    #[must_use]
48    fn sub(self, other: Self) -> Self {
49        self.add(other.scale(-1.0))
50    }
51}
52
53impl Blend for f64 {
54    fn zero() -> Self {
55        0.0
56    }
57    fn scale(self, k: f64) -> Self {
58        self * k
59    }
60    fn add(self, other: Self) -> Self {
61        self + other
62    }
63}
64
65impl Blend for Vector {
66    fn zero() -> Self {
67        Self::ZERO
68    }
69    fn scale(self, k: f64) -> Self {
70        self * k
71    }
72    fn add(self, other: Self) -> Self {
73        self + other
74    }
75}
76
77impl Blend for Vector2 {
78    fn zero() -> Self {
79        Self::ZERO
80    }
81    fn scale(self, k: f64) -> Self {
82        self * k
83    }
84    fn add(self, other: Self) -> Self {
85        self + other
86    }
87}
88
89impl Blend for Point {
90    fn zero() -> Self {
91        Self::ORIGIN
92    }
93    fn scale(self, k: f64) -> Self {
94        Self::from_vector(self.to_vector() * k)
95    }
96    fn add(self, other: Self) -> Self {
97        Self::from_vector(self.to_vector() + other.to_vector())
98    }
99}
100
101impl Blend for Point2 {
102    fn zero() -> Self {
103        Self::ORIGIN
104    }
105    fn scale(self, k: f64) -> Self {
106        Self::from_vector(self.to_vector() * k)
107    }
108    fn add(self, other: Self) -> Self {
109        Self::from_vector(self.to_vector() + other.to_vector())
110    }
111}
112
113/// A control point carrying a weight, for rational geometry.
114///
115/// Stored in *homogeneous* form (the point is already multiplied through by
116/// the weight) because that is the form every algorithm needs, and converting
117/// on each access would be both slower and a source of drift.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct Weighted<P> {
120    /// The point scaled by the weight.
121    pub scaled: P,
122    /// The weight.
123    pub weight: f64,
124}
125
126impl<P: Blend> Weighted<P> {
127    /// A weighted control point from a position and a weight.
128    ///
129    /// # Errors
130    ///
131    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the weight
132    /// is not finite and positive. A zero weight makes the projection undefined
133    /// and a negative one makes the curve leave its control polygon's convex
134    /// hull, so neither is admitted.
135    pub fn new(point: P, weight: f64, tol: Tolerances) -> OgeomResult<Self> {
136        if !weight.is_finite() || weight <= tol.confusion() {
137            ogeom_bail!(
138                Construction,
139                "control point weight {weight} must be finite and positive"
140            );
141        }
142        Ok(Self {
143            scaled: point.scale(weight),
144            weight,
145        })
146    }
147
148    /// The unweighted position.
149    #[must_use]
150    pub fn point(self) -> P {
151        self.scaled.scale(1.0 / self.weight)
152    }
153}
154
155impl<P: Blend> Blend for Weighted<P> {
156    fn zero() -> Self {
157        Self {
158            scaled: P::zero(),
159            weight: 0.0,
160        }
161    }
162    fn scale(self, k: f64) -> Self {
163        Self {
164            scaled: self.scaled.scale(k),
165            weight: self.weight * k,
166        }
167    }
168    fn add(self, other: Self) -> Self {
169        Self {
170            scaled: self.scaled.add(other.scaled),
171            weight: self.weight + other.weight,
172        }
173    }
174}
175
176/// Check that a control point count matches a knot vector.
177fn check_shape<P>(knots: &KnotVector, control: &[P]) -> OgeomResult<()> {
178    if control.len() != knots.control_point_count() {
179        ogeom_bail!(
180            Dimension,
181            "knot vector describes {} control points, got {}",
182            knots.control_point_count(),
183            control.len()
184        );
185    }
186    Ok(())
187}
188
189/// Evaluate a B-spline at `u` by de Boor's algorithm.
190///
191/// Numerically the right way to do it: a sequence of convex combinations of
192/// control points, so the result stays inside their hull and no intermediate
193/// can blow up. Evaluating the basis functions and taking a weighted sum gives
194/// the same answer in exact arithmetic but is less stable, and expanding the
195/// polynomial in monomials is far worse.
196///
197/// # Errors
198///
199/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the control point
200/// count disagrees with the knot vector; [`OgeomError::Domain`](ogeom_core::OgeomError::Domain)
201/// if `u` is outside the domain.
202pub fn evaluate<P: Blend>(
203    knots: &KnotVector,
204    control: &[P],
205    u: f64,
206    tol: Tolerances,
207) -> OgeomResult<P> {
208    check_shape(knots, control)?;
209    let span = knots.span(u, tol)?;
210    let p = knots.degree();
211
212    // On the stack up to degree seven, which is every spline a model
213    // carries in practice: this runs for every point of every curve.
214    let mut d: smallvec::SmallVec<[P; 8]> = (0..=p).map(|i| control[span - p + i]).collect();
215    let k = knots.knots();
216    for r in 1..=p {
217        for j in (r..=p).rev() {
218            let left = k[span + j - p];
219            let right = k[span + j + 1 - r];
220            // The span lookup guarantees this width is positive: a zero would
221            // mean a knot of multiplicity above the degree, which the knot
222            // vector's own validation rejects.
223            let alpha = (u - left) / (right - left);
224            d[j] = d[j - 1].lerp(d[j], alpha);
225        }
226    }
227    Ok(d[p])
228}
229
230/// Evaluate a B-spline and its derivatives up to order `n`.
231///
232/// `result[0]` is the point; `result[k]` is the `k`th derivative. Orders above
233/// the degree are zero.
234///
235/// # Errors
236///
237/// As [`evaluate`].
238pub fn derivatives<P: Blend>(
239    knots: &KnotVector,
240    control: &[P],
241    u: f64,
242    n: usize,
243    tol: Tolerances,
244) -> OgeomResult<Vec<P>> {
245    check_shape(knots, control)?;
246    let span = knots.span(u, tol)?;
247    let p = knots.degree();
248    let basis = knots.basis_derivatives(span, u, n);
249
250    Ok((0..=n)
251        .map(|order| {
252            let mut sum = P::zero();
253            for i in 0..=p {
254                sum = sum.add(control[span - p + i].scale(basis[order][i]));
255            }
256            sum
257        })
258        .collect())
259}
260
261/// Insert `value` into the knot vector `count` times, adjusting control points
262/// so the curve is unchanged.
263///
264/// Boehm's algorithm. The foundation of nearly everything else: splitting a
265/// curve, converting to Bézier form, and raising continuity constraints all
266/// reduce to knot insertion.
267///
268/// # Errors
269///
270/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch,
271/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `value` is outside the
272/// domain, and [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
273/// insertion would push a multiplicity above the degree.
274pub fn insert_knot<P: Blend>(
275    knots: &KnotVector,
276    control: &[P],
277    value: f64,
278    count: usize,
279    tol: Tolerances,
280) -> OgeomResult<Spline<P>> {
281    check_shape(knots, control)?;
282    if count == 0 {
283        return Ok((knots.clone(), control.to_vec()));
284    }
285    let span = knots.span(value, tol)?;
286    let p = knots.degree();
287    let existing = knots.multiplicity_of(value);
288    if existing + count > p {
289        ogeom_bail!(
290            Construction,
291            "inserting {count} copies of {value} would reach multiplicity {}, above degree {p}",
292            existing + count
293        );
294    }
295
296    let new_knots = knots.with_knot_inserted(value, count)?;
297    let last = control.len() - 1;
298    let k = knots.knots();
299    let (s, r) = (existing, count);
300
301    let mut points: Vec<P> = vec![P::zero(); control.len() + r];
302    // Control points outside the affected window are unchanged. Those before it
303    // keep their index, those after it shift right by the number inserted.
304    points[..=span - p].copy_from_slice(&control[..=span - p]);
305    points[span - s + r..=last + r].copy_from_slice(&control[span - s..=last]);
306
307    // The window that the insertion actually reworks, refined in place. Each
308    // pass is a set of convex combinations, so the points stay in the hull.
309    let mut window: Vec<P> = (0..=p - s).map(|i| control[span - p + i]).collect();
310    let mut window_start = span - p;
311    for j in 1..=r {
312        window_start = span - p + j;
313        for i in 0..=p - j - s {
314            let left = k[window_start + i];
315            let right = k[i + span + 1];
316            let alpha = (value - left) / (right - left);
317            window[i] = window[i].lerp(window[i + 1], alpha);
318        }
319        points[window_start] = window[0];
320        points[span + r - j - s] = window[p - j - s];
321    }
322
323    // Whatever the passes left in the middle of the window.
324    if window_start + 1 < span - s {
325        let width = (span - s) - (window_start + 1);
326        points[window_start + 1..span - s].copy_from_slice(&window[1..=width]);
327    }
328
329    Ok((new_knots, points))
330}
331
332/// A B-spline: a knot vector paired with its control points.
333pub type Spline<P> = (KnotVector, Vec<P>);
334
335/// A Bézier segment: the parameter interval it covers, and its control points.
336pub type BezierSegment<P> = ((f64, f64), Vec<P>);
337
338/// Join two clamped B-splines of one degree end to start into one.
339///
340/// `a`'s last control point and `b`'s first are taken to be the same
341/// point (the caller checks, since a control point is whatever blends)
342/// and become one control. The join knot is left at multiplicity `degree`,
343/// so the curve passes through it and continues with `b`'s parameter
344/// shifted to begin where `a`'s ends. The domain is the two domains laid
345/// end to end.
346///
347/// # Errors
348///
349/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
350/// degrees differ, either knot vector is not clamped, or a knot vector does
351/// not fit its control points.
352pub fn join<P: Blend>(a: &Spline<P>, b: &Spline<P>) -> OgeomResult<Spline<P>> {
353    let ((ak, ac), (bk, bc)) = (a, b);
354    check_shape(ak, ac)?;
355    check_shape(bk, bc)?;
356    let p = ak.degree();
357    if bk.degree() != p {
358        ogeom_bail!(
359            Construction,
360            "cannot join a degree {p} B-spline to a degree {} one",
361            bk.degree()
362        );
363    }
364    if !ak.is_clamped() || !bk.is_clamped() {
365        ogeom_bail!(Construction, "only clamped B-splines join");
366    }
367    let shift = ak.domain_end() - bk.domain_start();
368    let mut knots: Vec<f64> = ak.knots()[..ak.knots().len() - 1].to_vec();
369    knots.extend(bk.knots()[p + 1..].iter().map(|k| k + shift));
370    let mut control: Vec<P> = ac[..ac.len() - 1].to_vec();
371    control.extend_from_slice(bc);
372    Ok((KnotVector::new(knots, p)?, control))
373}
374
375/// Continue a clamped B-spline past one end by `span` in parameter: the
376/// polynomial continuation of the curve's own end derivatives, joined on.
377///
378/// The continuation is the Taylor polynomial of order `continuity` at the
379/// end (the polynomial whose derivatives up to that order agree with the
380/// curve's there), expressed in Bernstein form over the new span, raised to
381/// the spline's degree and joined on with the knot at multiplicity
382/// `degree`. The curve is continued rather than approximated: a polynomial
383/// spline of degree at most `continuity` continues *as itself*, and so does
384/// a rational curve's homogeneous polynomial: a rational circle arc
385/// continued at order two stays on its circle. Orders above the degree are
386/// held to the degree, which is as smooth as the spline itself is.
387///
388/// Extended at the start, the original run keeps its parameters and the
389/// domain grows downward. Extended at the end, it grows upward.
390///
391/// # Errors
392///
393/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
394/// knot vector is not clamped or the span is not positive and finite; as
395/// [`derivatives`] on a shape mismatch.
396pub fn extend<P: Blend>(
397    knots: &KnotVector,
398    control: &[P],
399    at_end: bool,
400    span: f64,
401    continuity: usize,
402    tol: Tolerances,
403) -> OgeomResult<Spline<P>> {
404    check_shape(knots, control)?;
405    if !knots.is_clamped() {
406        ogeom_bail!(Construction, "only clamped B-splines extend");
407    }
408    if !(span > 0.0 && span.is_finite()) {
409        ogeom_bail!(
410            Construction,
411            "an extension needs a positive, finite span; got {span}"
412        );
413    }
414    if !at_end {
415        // The start is the end of the reversed curve. Reversed back, the
416        // extension stands before the original, which keeps its parameters
417        // once the whole is slid down by the span.
418        let (rk, rc) = reverse(knots, control);
419        let (ek, ec) = extend(&rk, &rc, true, span, continuity, tol)?;
420        let (bk, bc) = reverse(&ek, &ec);
421        let (lo, hi) = knots.domain();
422        return Ok((bk.reparameterized(lo - span, hi)?, bc));
423    }
424    let p = knots.degree();
425    let k = continuity.min(p);
426    let end = knots.domain_end();
427    let jet = derivatives(knots, control, end, k, tol)?;
428    // Monomial coefficients `D_i / i!` on `s` in `[0, span]`, in Bernstein
429    // form: `b_j = sum over i <= j of C(j, i) / C(k, i) * a_i * span^i`.
430    let mut bezier: Vec<P> = Vec::with_capacity(k + 1);
431    for j in 0..=k {
432        let mut b = P::zero();
433        let (mut factorial, mut power) = (1.0_f64, 1.0_f64);
434        for (i, derivative) in jet.iter().enumerate().take(j + 1) {
435            if i > 0 {
436                #[allow(clippy::cast_precision_loss)]
437                {
438                    factorial *= i as f64;
439                }
440                power *= span;
441            }
442            #[allow(clippy::cast_precision_loss)]
443            let ratio = binomial_coefficient(j, i) as f64 / binomial_coefficient(k, i) as f64;
444            b = b.add(derivative.scale(ratio * power / factorial));
445        }
446        bezier.push(b);
447    }
448    let mut piece_knots: Vec<f64> = Vec::with_capacity(2 * (k + 1));
449    piece_knots.extend(core::iter::repeat_n(end, k + 1));
450    piece_knots.extend(core::iter::repeat_n(end + span, k + 1));
451    let mut piece: Spline<P> = (KnotVector::new(piece_knots, k)?, bezier);
452    for _ in k..p {
453        piece = elevate_degree(&piece.0, &piece.1, tol)?;
454    }
455    join(&(knots.clone(), control.to_vec()), &piece)
456}
457
458/// Continue a clamped B-spline past one end to `target`, over `span` in
459/// parameter: a Bézier piece whose first `continuity + 1` controls carry
460/// the curve's own end derivatives (as [`extend`] does) and whose last is
461/// `target`, joined on. The curve is raised a degree first where the piece
462/// needs one more than it has.
463///
464/// # Errors
465///
466/// As [`extend`].
467pub fn extend_to<P: Blend>(
468    knots: &KnotVector,
469    control: &[P],
470    at_end: bool,
471    target: P,
472    span: f64,
473    continuity: usize,
474    tol: Tolerances,
475) -> OgeomResult<Spline<P>> {
476    check_shape(knots, control)?;
477    if !knots.is_clamped() {
478        ogeom_bail!(Construction, "only clamped B-splines extend");
479    }
480    if !(span > 0.0 && span.is_finite()) {
481        ogeom_bail!(
482            Construction,
483            "an extension needs a positive, finite span; got {span}"
484        );
485    }
486    if !at_end {
487        let (rk, rc) = reverse(knots, control);
488        let (ek, ec) = extend_to(&rk, &rc, true, target, span, continuity, tol)?;
489        let (bk, bc) = reverse(&ek, &ec);
490        let (lo, hi) = knots.domain();
491        return Ok((bk.reparameterized(lo - span, hi)?, bc));
492    }
493    let mut base: Spline<P> = (knots.clone(), control.to_vec());
494    let k = continuity.min(base.0.degree());
495    let n = k + 1;
496    while base.0.degree() < n {
497        base = elevate_degree(&base.0, &base.1, tol)?;
498    }
499    let end = base.0.domain_end();
500    let jet = derivatives(&base.0, &base.1, end, k, tol)?;
501    // Bernstein controls of degree `n` for the Taylor data through order
502    // `k`, then the target in the last place.
503    let mut bezier: Vec<P> = Vec::with_capacity(n + 1);
504    for j in 0..=k {
505        let mut b = P::zero();
506        let (mut factorial, mut power) = (1.0_f64, 1.0_f64);
507        for (i, derivative) in jet.iter().enumerate().take(j + 1) {
508            if i > 0 {
509                #[allow(clippy::cast_precision_loss)]
510                {
511                    factorial *= i as f64;
512                }
513                power *= span;
514            }
515            #[allow(clippy::cast_precision_loss)]
516            let ratio = binomial_coefficient(j, i) as f64 / binomial_coefficient(n, i) as f64;
517            b = b.add(derivative.scale(ratio * power / factorial));
518        }
519        bezier.push(b);
520    }
521    bezier.push(target);
522    let mut piece_knots: Vec<f64> = Vec::with_capacity(2 * (n + 1));
523    piece_knots.extend(core::iter::repeat_n(end, n + 1));
524    piece_knots.extend(core::iter::repeat_n(end + span, n + 1));
525    let mut piece: Spline<P> = (KnotVector::new(piece_knots, n)?, bezier);
526    for _ in n..base.0.degree() {
527        piece = elevate_degree(&piece.0, &piece.1, tol)?;
528    }
529    join(&base, &piece)
530}
531
532/// Split a B-spline at `u` into two, each with its own clamped knot vector.
533///
534/// Works by raising the multiplicity at `u` to the degree, at which point the
535/// control points either side are already independent.
536///
537/// # Errors
538///
539/// As [`insert_knot`], plus [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if
540/// `u` is at either end of the domain, where one half would be empty.
541pub fn split<P: Blend>(
542    knots: &KnotVector,
543    control: &[P],
544    u: f64,
545    tol: Tolerances,
546) -> OgeomResult<(Spline<P>, Spline<P>)> {
547    check_shape(knots, control)?;
548    let (start, end) = knots.domain();
549    if u <= start + tol.parametric() || u >= end - tol.parametric() {
550        ogeom_bail!(
551            Domain,
552            "cannot split at {u}, an end of the domain [{start}, {end}]"
553        );
554    }
555    let p = knots.degree();
556    let existing = knots.multiplicity_of(u);
557    let (refined, points) = insert_knot(knots, control, u, p - existing, tol)?;
558
559    // After refinement the two halves meet at a control point they share.
560    let cut = refined.knots().partition_point(|k| *k < u);
561    let left_points = points[..cut].to_vec();
562    let right_points = points[cut - 1..].to_vec();
563
564    let mut left_knots = refined.knots()[..cut + p].to_vec();
565    left_knots.push(u);
566    let mut right_knots = vec![u];
567    right_knots.extend_from_slice(&refined.knots()[cut..]);
568
569    Ok((
570        (KnotVector::new(left_knots, p)?, left_points),
571        (KnotVector::new(right_knots, p)?, right_points),
572    ))
573}
574
575/// Decompose a B-spline into its Bézier segments.
576///
577/// Returns one control-point array per segment, each of `degree + 1` points,
578/// together with the parameter interval it covers. Many algorithms (plotting,
579/// intersection, conversion to exchange formats) are far simpler on Bézier
580/// pieces than on the whole spline.
581///
582/// # Errors
583///
584/// As [`insert_knot`].
585pub fn to_bezier_segments<P: Blend>(
586    knots: &KnotVector,
587    control: &[P],
588    tol: Tolerances,
589) -> OgeomResult<Vec<BezierSegment<P>>> {
590    check_shape(knots, control)?;
591    let p = knots.degree();
592    let end = knots.domain().1;
593
594    // Raise every knot of the domain to full multiplicity, its ends too: an
595    // unclamped vector's curve starts and ends inside its first and last
596    // spans' hulls, and only a clamped end makes a segment's first control
597    // point its first point. The start is raised in place. The end is the
598    // start of the reversed curve.
599    let clamp_start = |knots: &KnotVector, control: &[P]| -> OgeomResult<Spline<P>> {
600        let (start, _) = knots.domain();
601        let needed = p.saturating_sub(knots.multiplicity_of(start));
602        insert_knot(knots, control, start, needed, tol)
603    };
604    let (mut current_knots, mut current_points) = clamp_start(knots, control)?;
605    if current_knots.multiplicity_of(end) < p {
606        let (reversed_knots, reversed_points) = reverse(&current_knots, &current_points);
607        let (reversed_knots, reversed_points) = clamp_start(&reversed_knots, &reversed_points)?;
608        (current_knots, current_points) = reverse(&reversed_knots, &reversed_points);
609    }
610    let (start, end) = current_knots.domain();
611    for (value, multiplicity) in current_knots.clone().distinct() {
612        if value <= start || value >= end {
613            continue;
614        }
615        let needed = p.saturating_sub(multiplicity);
616        if needed > 0 {
617            let (k, c) = insert_knot(&current_knots, &current_points, value, needed, tol)?;
618            current_knots = k;
619            current_points = c;
620        }
621    }
622
623    let breaks: Vec<f64> = core::iter::once(start)
624        .chain(
625            current_knots
626                .distinct()
627                .into_iter()
628                .filter(|(v, _)| *v > start && *v < end)
629                .map(|(v, _)| v),
630        )
631        .chain(core::iter::once(end))
632        .collect();
633
634    // Each segment's points are the `p + 1` its span reads.
635    let mut out = Vec::with_capacity(breaks.len() - 1);
636    for w in breaks.windows(2) {
637        let span = current_knots.span(f64::midpoint(w[0], w[1]), tol)?;
638        out.push(((w[0], w[1]), current_points[span - p..=span].to_vec()));
639    }
640    Ok(out)
641}
642
643/// Raise the degree by one, leaving the curve unchanged.
644///
645/// Works segment by segment on the Bézier decomposition, where degree elevation
646/// is the exact closed form `Q[i] = (i/(p+1)) P[i-1] + (1 - i/(p+1)) P[i]`, and
647/// reassembles by removing the knots that were introduced: an interior knot of
648/// multiplicity `m` comes back with `m + 1`, so the curve keeps its continuity
649/// there.
650///
651/// # Errors
652///
653/// As [`to_bezier_segments`].
654pub fn elevate_degree<P: Blend>(
655    knots: &KnotVector,
656    control: &[P],
657    tol: Tolerances,
658) -> OgeomResult<Spline<P>> {
659    check_shape(knots, control)?;
660    let p = knots.degree();
661    let segments = to_bezier_segments(knots, control, tol)?;
662
663    let mut points: Vec<P> = Vec::with_capacity(segments.len() * (p + 1) + 1);
664    let mut new_knots: Vec<f64> = Vec::new();
665
666    for (index, ((a, b), segment)) in segments.iter().enumerate() {
667        // The elevated Bezier segment has p + 2 control points.
668        let mut elevated: Vec<P> = Vec::with_capacity(p + 2);
669        elevated.push(segment[0]);
670        #[allow(clippy::cast_precision_loss)]
671        for i in 1..=p {
672            let t = i as f64 / (p + 1) as f64;
673            elevated.push(segment[i - 1].lerp(segment[i], 1.0 - t));
674        }
675        elevated.push(segment[p]);
676
677        if index == 0 {
678            points.extend_from_slice(&elevated);
679            new_knots.extend(core::iter::repeat_n(*a, p + 2));
680        } else {
681            // The shared endpoint is already present.
682            points.extend_from_slice(&elevated[1..]);
683            new_knots.extend(core::iter::repeat_n(*a, p + 1));
684        }
685        if index == segments.len() - 1 {
686            new_knots.extend(core::iter::repeat_n(*b, p + 2));
687        }
688    }
689
690    // Each interior knot stands at full multiplicity, `p + 1` in degree
691    // `p + 1`. The curve is as smooth there as it was, so all but `m + 1`
692    // come out exactly.
693    for (value, multiplicity) in knots.distinct() {
694        let (start, end) = knots.domain();
695        if value <= start || value >= end {
696            continue;
697        }
698        for _ in multiplicity..p {
699            (new_knots, points) = remove_knot_once(&new_knots, &points, p + 1, value);
700        }
701    }
702
703    Ok((KnotVector::new(new_knots, p + 1)?, points))
704}
705
706/// One occurrence of the interior knot `u` taken out of a degree-`p` spline
707/// the knot is exactly removable from: the inverse of inserting it, solved
708/// from both ends of the points it touches and met in the middle.
709fn remove_knot_once<P: Blend>(
710    knots: &[f64],
711    control: &[P],
712    p: usize,
713    u: f64,
714) -> (Vec<f64>, Vec<P>) {
715    let Some(r) = knots.iter().rposition(|k| *k == u) else {
716        return (knots.to_vec(), control.to_vec());
717    };
718    let left = knots.iter().filter(|k| **k == u).count() - 1;
719    let mut reduced = knots.to_vec();
720    reduced.remove(r);
721    // Inserting `u` into the reduced spline rewrites its points `lo..=hi`:
722    // `P[i] = a[i] Q[i] + (1 - a[i]) Q[i - 1]`, keeping those before and
723    // shifting those after by one.
724    let k = r - 1;
725    let (lo, hi) = (k + 1 - p, k - left);
726    let alpha = |i: usize| (u - reduced[i]) / (reduced[i + p] - reduced[i]);
727    let mut q: Vec<P> = Vec::with_capacity(control.len() - 1);
728    q.extend_from_slice(&control[..lo]);
729    let mut forward: Vec<P> = Vec::with_capacity(hi - lo);
730    let mut previous = control[lo - 1];
731    for (i, point) in control.iter().enumerate().take(hi).skip(lo) {
732        let a = alpha(i);
733        previous = point.sub(previous.scale(1.0 - a)).scale(1.0 / a);
734        forward.push(previous);
735    }
736    let mut backward: Vec<P> = vec![P::zero(); hi - lo];
737    let mut next = control[hi + 1];
738    for i in (lo + 1..=hi).rev() {
739        let a = alpha(i);
740        next = control[i].sub(next.scale(a)).scale(1.0 / (1.0 - a));
741        backward[i - 1 - lo] = next;
742    }
743    let middle = (hi - lo) / 2;
744    for j in 0..hi - lo {
745        q.push(if j < middle { forward[j] } else { backward[j] });
746    }
747    q.extend_from_slice(&control[hi + 1..]);
748    (reduced, q)
749}
750
751/// Reverse the parameter direction, leaving the curve's shape unchanged.
752#[must_use]
753pub fn reverse<P: Blend>(knots: &KnotVector, control: &[P]) -> Spline<P> {
754    let mut points = control.to_vec();
755    points.reverse();
756    (knots.reversed(), points)
757}
758
759/// Evaluate a rational B-spline: de Boor in homogeneous coordinates, then
760/// divide through by the weight.
761///
762/// # Errors
763///
764/// As [`evaluate`], plus [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the
765/// accumulated weight vanishes, which positive input weights make impossible.
766pub fn evaluate_rational<P: Blend>(
767    knots: &KnotVector,
768    control: &[Weighted<P>],
769    u: f64,
770    tol: Tolerances,
771) -> OgeomResult<P> {
772    let h = evaluate(knots, control, u, tol)?;
773    if h.weight.abs() <= tol.confusion() {
774        ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
775    }
776    Ok(h.point())
777}
778
779/// Evaluate a rational B-spline and its derivatives up to order `n`.
780///
781/// The quotient rule applied to the homogeneous form. Differentiating the
782/// projected curve directly is not an option: the projection is a quotient, so
783/// its derivatives mix all lower orders.
784///
785/// # Errors
786///
787/// As [`evaluate_rational`].
788pub fn rational_derivatives<P: Blend>(
789    knots: &KnotVector,
790    control: &[Weighted<P>],
791    u: f64,
792    n: usize,
793    tol: Tolerances,
794) -> OgeomResult<Vec<P>> {
795    let homogeneous = derivatives(knots, control, u, n, tol)?;
796    if homogeneous[0].weight.abs() <= tol.confusion() {
797        ogeom_bail!(Numeric, "rational evaluation produced a vanishing weight");
798    }
799
800    // C^(k) = ( A^(k) - sum_{i=1..k} C(k,i) w^(i) C^(k-i) ) / w
801    let mut out: Vec<P> = Vec::with_capacity(n + 1);
802    for (order, term) in homogeneous.iter().enumerate() {
803        let mut value = term.scaled;
804        for i in 1..=order {
805            #[allow(clippy::cast_precision_loss)]
806            let binomial = binomial_coefficient(order, i) as f64;
807            value = value.sub(out[order - i].scale(binomial * homogeneous[i].weight));
808        }
809        out.push(value.scale(1.0 / homogeneous[0].weight));
810    }
811    Ok(out)
812}
813
814/// `n choose k`, computed multiplicatively so it stays exact for the small
815/// values derivative formulas need.
816#[must_use]
817pub fn binomial_coefficient(n: usize, k: usize) -> u64 {
818    if k > n {
819        return 0;
820    }
821    let k = k.min(n - k);
822    let mut result = 1_u64;
823    for i in 0..k {
824        result = result * (n - i) as u64 / (i as u64 + 1);
825    }
826    result
827}
828
829#[cfg(test)]
830#[allow(clippy::unwrap_used)]
831mod join_tests {
832    use super::*;
833    use crate::Point;
834
835    #[test]
836    fn a_joined_spline_evaluates_as_its_two_halves_did() {
837        let tol = Tolerances::millimetres();
838        let control: Vec<Point> = (0..6)
839            .map(|i| Point::new(f64::from(i), f64::from(i * i % 5), 0.0))
840            .collect();
841        let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
842        let ((lk, lc), (rk, rc)) = split(&knots, &control, 0.4, tol).unwrap();
843        let (jk, jc) = join(&(lk, lc), &(rk, rc)).unwrap();
844        assert_eq!(
845            jk.domain(),
846            knots.domain(),
847            "the domain is the two laid end to end"
848        );
849        assert_eq!(
850            jc.len() + 3 + 1,
851            jk.knots().len(),
852            "the knots fit the controls"
853        );
854        for i in 0..=20 {
855            let u = f64::from(i) / 20.0;
856            let before = evaluate(&knots, &control, u, tol).unwrap();
857            let after = evaluate(&jk, &jc, u, tol).unwrap();
858            assert!(
859                before.is_equal(after, tol),
860                "at {u}: {before:?} became {after:?}"
861            );
862        }
863    }
864}
865
866#[cfg(test)]
867#[allow(clippy::unwrap_used)]
868mod tests {
869    use super::*;
870    use approx::assert_relative_eq;
871
872    const T: Tolerances = Tolerances::millimetres();
873
874    /// An extension continues the curve: the original run evaluates as it
875    /// did, the derivatives agree at the join to the order asked, and the
876    /// domain grows by the span at the end asked for.
877    #[test]
878    fn an_extension_continues_the_curve_to_its_order() {
879        let knots = KnotVector::clamped_uniform(3, 6).unwrap();
880        let control = vec![
881            Point::new(0.0, 0.0, 0.0),
882            Point::new(1.0, 2.0, 0.5),
883            Point::new(2.5, 1.0, -0.5),
884            Point::new(4.0, 3.0, 1.0),
885            Point::new(5.0, 0.5, 0.0),
886            Point::new(6.0, 2.0, 2.0),
887        ];
888        let (lo, hi) = knots.domain();
889        for at_end in [true, false] {
890            let (ek, ec) = extend(&knots, &control, at_end, 0.4, 2, T).unwrap();
891            let (elo, ehi) = ek.domain();
892            if at_end {
893                assert!((elo - lo).abs() < 1e-12 && (ehi - (hi + 0.4)).abs() < 1e-12);
894            } else {
895                assert!((elo - (lo - 0.4)).abs() < 1e-12 && (ehi - hi).abs() < 1e-12);
896            }
897            for i in 0..=10 {
898                let u = lo + (hi - lo) * f64::from(i) / 10.0;
899                let was = evaluate(&knots, &control, u, T).unwrap();
900                let now = evaluate(&ek, &ec, u, T).unwrap();
901                assert!(
902                    was.distance(now) < 1e-9,
903                    "the original run at {u}: {was:?} vs {now:?}"
904                );
905            }
906            // A hair either side of the join: the jets agree to the order
907            // asked, up to the next derivative's step across the hair.
908            let join_at = if at_end { hi } else { lo };
909            let step = if at_end { 1e-7 } else { -1e-7 };
910            let inside = derivatives(&knots, &control, join_at - step, 2, T).unwrap();
911            let outside = derivatives(&ek, &ec, join_at + step, 2, T).unwrap();
912            for order in 0..=2 {
913                let (a, b) = (inside[order], outside[order]);
914                let gap = a.to_vector().sub(b.to_vector()).magnitude();
915                let scale = a.to_vector().magnitude().max(1.0);
916                assert!(
917                    gap < scale * 1e-4,
918                    "order {order} across the join: {a:?} vs {b:?}"
919                );
920            }
921        }
922    }
923
924    fn cubic_curve() -> (KnotVector, Vec<Point>) {
925        let control = vec![
926            Point::new(0.0, 0.0, 0.0),
927            Point::new(1.0, 2.0, 0.0),
928            Point::new(3.0, 3.0, 1.0),
929            Point::new(5.0, 1.0, 2.0),
930            Point::new(6.0, -1.0, 1.0),
931            Point::new(8.0, 0.0, 0.0),
932        ];
933        (
934            KnotVector::clamped_uniform(3, control.len()).unwrap(),
935            control,
936        )
937    }
938
939    fn sample(knots: &KnotVector, control: &[Point], n: usize) -> Vec<Point> {
940        let (a, b) = knots.domain();
941        (0..=n)
942            .map(|i| {
943                #[allow(clippy::cast_precision_loss)]
944                let u = a + (b - a) * (i as f64 / n as f64);
945                evaluate(knots, control, u, T).unwrap()
946            })
947            .collect()
948    }
949
950    #[test]
951    fn a_clamped_curve_interpolates_its_end_points() {
952        let (k, c) = cubic_curve();
953        let (a, b) = k.domain();
954        assert!(evaluate(&k, &c, a, T).unwrap().is_equal(c[0], T));
955        assert!(evaluate(&k, &c, b, T).unwrap().is_equal(c[c.len() - 1], T));
956    }
957
958    #[test]
959    fn de_boor_agrees_with_the_basis_function_sum() {
960        // Two independent routes to the same value. They must agree.
961        let (k, c) = cubic_curve();
962        for i in 0..=50 {
963            let u = f64::from(i) / 50.0;
964            let span = k.span(u, T).unwrap();
965            let basis = k.basis(span, u);
966            let mut sum = Vector::ZERO;
967            for j in 0..=k.degree() {
968                sum += c[span - k.degree() + j].to_vector() * basis[j];
969            }
970            let de_boor = evaluate(&k, &c, u, T).unwrap();
971            assert!(de_boor.is_equal(Point::from_vector(sum), T), "at u = {u}");
972        }
973    }
974
975    #[test]
976    fn shape_mismatches_and_out_of_domain_parameters_are_refused() {
977        let (k, c) = cubic_curve();
978        assert!(
979            evaluate(&k, &c[..3], 0.5, T).is_err(),
980            "too few control points"
981        );
982        assert!(evaluate(&k, &c, -0.1, T).is_err());
983        assert!(evaluate(&k, &c, 1.1, T).is_err());
984    }
985
986    #[test]
987    fn derivatives_agree_with_finite_differences() {
988        let (k, c) = cubic_curve();
989        let h = 1e-6;
990        for i in 1..20 {
991            let u = f64::from(i) / 20.0;
992            let d = derivatives(&k, &c, u, 2, T).unwrap();
993            assert!(d[0].is_equal(evaluate(&k, &c, u, T).unwrap(), T));
994
995            let ahead = evaluate(&k, &c, u + h, T).unwrap();
996            let behind = evaluate(&k, &c, u - h, T).unwrap();
997            let numeric = (ahead - behind) * (1.0 / (2.0 * h));
998            assert!(
999                (d[1].to_vector() - numeric).magnitude() < 1e-5,
1000                "first derivative disagrees at {u}"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn knot_insertion_does_not_move_the_curve() {
1007        let (k, c) = cubic_curve();
1008        let before = sample(&k, &c, 100);
1009        for (value, count) in [(0.25, 1), (0.5, 2), (0.75, 3), (0.1, 1)] {
1010            let (k2, c2) = insert_knot(&k, &c, value, count, T).unwrap();
1011            assert_eq!(c2.len(), c.len() + count);
1012            assert_eq!(k2.multiplicity_of(value), k.multiplicity_of(value) + count);
1013            let after = sample(&k2, &c2, 100);
1014            for (a, b) in before.iter().zip(&after) {
1015                assert!(
1016                    a.is_equal(*b, T),
1017                    "inserting {count} at {value} moved the curve"
1018                );
1019            }
1020        }
1021    }
1022
1023    #[test]
1024    fn repeated_insertion_matches_a_single_multiple_insertion() {
1025        let (k, c) = cubic_curve();
1026        let (ka, ca) = insert_knot(&k, &c, 0.4, 3, T).unwrap();
1027
1028        let (k1, c1) = insert_knot(&k, &c, 0.4, 1, T).unwrap();
1029        let (k2, c2) = insert_knot(&k1, &c1, 0.4, 1, T).unwrap();
1030        let (kb, cb) = insert_knot(&k2, &c2, 0.4, 1, T).unwrap();
1031
1032        assert_eq!(ka.knots(), kb.knots());
1033        for (a, b) in ca.iter().zip(&cb) {
1034            assert!(a.is_equal(*b, T));
1035        }
1036    }
1037
1038    #[test]
1039    fn insertion_beyond_the_degree_is_refused() {
1040        let (k, c) = cubic_curve();
1041        assert!(insert_knot(&k, &c, 0.5, 4, T).is_err());
1042        assert!(insert_knot(&k, &c, 0.5, 3, T).is_ok());
1043        assert!(
1044            insert_knot(&k, &c, 2.0, 1, T).is_err(),
1045            "outside the domain"
1046        );
1047    }
1048
1049    #[test]
1050    fn splitting_reproduces_both_halves_of_the_original() {
1051        let (k, c) = cubic_curve();
1052        let cut = 0.4;
1053        let ((lk, lc), (rk, rc)) = split(&k, &c, cut, T).unwrap();
1054
1055        assert_relative_eq!(lk.domain().1, cut, epsilon = 1e-15);
1056        assert_relative_eq!(rk.domain().0, cut, epsilon = 1e-15);
1057        assert!(lk.is_clamped() && rk.is_clamped());
1058
1059        for i in 0..=40 {
1060            let t = f64::from(i) / 40.0;
1061            let left_u = lk.domain().0 + (cut - lk.domain().0) * t;
1062            let right_u = cut + (rk.domain().1 - cut) * t;
1063            assert!(
1064                evaluate(&lk, &lc, left_u, T)
1065                    .unwrap()
1066                    .is_equal(evaluate(&k, &c, left_u, T).unwrap(), T),
1067                "left half diverges at {left_u}"
1068            );
1069            assert!(
1070                evaluate(&rk, &rc, right_u, T)
1071                    .unwrap()
1072                    .is_equal(evaluate(&k, &c, right_u, T).unwrap(), T),
1073                "right half diverges at {right_u}"
1074            );
1075        }
1076    }
1077
1078    #[test]
1079    fn splitting_at_an_end_of_the_domain_is_refused() {
1080        let (k, c) = cubic_curve();
1081        assert!(split(&k, &c, 0.0, T).is_err());
1082        assert!(split(&k, &c, 1.0, T).is_err());
1083    }
1084
1085    #[test]
1086    fn an_unclamped_curve_decomposes_and_elevates_unchanged() {
1087        // A uniform cubic whose outer knots lie beyond its domain [3, 6].
1088        let knots = KnotVector::new((0..10).map(f64::from).collect(), 3).unwrap();
1089        let control = vec![
1090            Point::new(0.0, 0.0, 0.0),
1091            Point::new(5.0, 1.0, 0.0),
1092            Point::new(-2.0, 3.0, 1.0),
1093            Point::new(7.0, 2.0, 2.0),
1094            Point::new(1.0, -1.0, 1.0),
1095            Point::new(3.0, 0.0, 0.0),
1096        ];
1097        let segments = to_bezier_segments(&knots, &control, T).unwrap();
1098        assert_eq!(segments.len(), 3);
1099        for ((a, b), points) in &segments {
1100            let bezier = KnotVector::clamped_uniform(3, points.len()).unwrap();
1101            for s in [0.0, 0.25, 0.5, 1.0] {
1102                let on_segment = evaluate(&bezier, points, s, T).unwrap();
1103                let on_curve = evaluate(&knots, &control, a + (b - a) * s, T).unwrap();
1104                assert!(on_segment.distance(on_curve) < 1e-12, "[{a}, {b}] at {s}");
1105            }
1106        }
1107        let (raised, points) = elevate_degree(&knots, &control, T).unwrap();
1108        assert_eq!(raised.domain(), knots.domain());
1109        for i in 0..=30 {
1110            let u = 3.0 + f64::from(i) / 10.0;
1111            let moved = evaluate(&raised, &points, u, T)
1112                .unwrap()
1113                .distance(evaluate(&knots, &control, u, T).unwrap());
1114            assert!(moved < 1e-12, "elevation moved the curve {moved} at {u}");
1115        }
1116    }
1117
1118    #[test]
1119    fn bezier_decomposition_covers_the_curve_exactly() {
1120        let (k, c) = cubic_curve();
1121        let segments = to_bezier_segments(&k, &c, T).unwrap();
1122        // Two interior knots means three segments.
1123        assert_eq!(segments.len(), 3);
1124        for (_, points) in &segments {
1125            assert_eq!(points.len(), k.degree() + 1);
1126        }
1127
1128        // Each segment, evaluated as a Bezier, must match the original curve
1129        // over its own interval.
1130        for ((a, b), points) in &segments {
1131            let bezier = KnotVector::clamped_uniform(k.degree(), points.len())
1132                .unwrap()
1133                .reparameterized(*a, *b)
1134                .unwrap();
1135            for i in 0..=20 {
1136                let u = a + (b - a) * (f64::from(i) / 20.0);
1137                assert!(
1138                    evaluate(&bezier, points, u, T)
1139                        .unwrap()
1140                        .is_equal(evaluate(&k, &c, u, T).unwrap(), T),
1141                    "segment [{a}, {b}] diverges at {u}"
1142                );
1143            }
1144        }
1145    }
1146
1147    #[test]
1148    fn degree_elevation_does_not_move_the_curve() {
1149        let (k, c) = cubic_curve();
1150        let before = sample(&k, &c, 100);
1151        let (k2, c2) = elevate_degree(&k, &c, T).unwrap();
1152        assert_eq!(k2.degree(), k.degree() + 1);
1153        assert_eq!(k2.domain(), k.domain());
1154
1155        let after = sample(&k2, &c2, 100);
1156        for (a, b) in before.iter().zip(&after) {
1157            assert!(a.is_equal(*b, T), "elevation moved the curve");
1158        }
1159    }
1160
1161    #[test]
1162    fn elevation_keeps_the_continuity_at_every_knot() {
1163        let (k, c) = cubic_curve();
1164        let (k2, c2) = elevate_degree(&k, &c, T).unwrap();
1165        let interior = |v: &KnotVector| -> Vec<(f64, usize)> {
1166            let (a, b) = v.domain();
1167            v.distinct()
1168                .into_iter()
1169                .filter(|(x, _)| *x > a && *x < b)
1170                .collect()
1171        };
1172        let raised: Vec<(f64, usize)> = interior(&k).into_iter().map(|(x, m)| (x, m + 1)).collect();
1173        assert_eq!(interior(&k2), raised);
1174        let before = sample(&k, &c, 100);
1175        for (a, b) in before.iter().zip(&sample(&k2, &c2, 100)) {
1176            assert!(a.distance(*b) < 1e-12, "elevation moved the curve");
1177        }
1178        // Second derivatives agree from either side of a knot.
1179        for (x, _) in interior(&k2) {
1180            let left = derivatives(&k2, &c2, x - 1e-9, 2, T).unwrap()[2];
1181            let right = derivatives(&k2, &c2, x + 1e-9, 2, T).unwrap()[2];
1182            assert!(
1183                left.distance(right) < 1e-5,
1184                "{left:?} against {right:?} at {x}"
1185            );
1186        }
1187    }
1188
1189    #[test]
1190    fn elevation_twice_is_still_the_same_curve() {
1191        let (k, c) = cubic_curve();
1192        let before = sample(&k, &c, 60);
1193        let (k1, c1) = elevate_degree(&k, &c, T).unwrap();
1194        let (k2, c2) = elevate_degree(&k1, &c1, T).unwrap();
1195        assert_eq!(k2.degree(), 5);
1196        for (a, b) in before.iter().zip(&sample(&k2, &c2, 60)) {
1197            assert!(a.is_equal(*b, T));
1198        }
1199    }
1200
1201    #[test]
1202    fn reversal_traverses_the_same_points_backwards() {
1203        let (k, c) = cubic_curve();
1204        let (rk, rc) = reverse(&k, &c);
1205        let (a, b) = k.domain();
1206        for i in 0..=40 {
1207            let t = f64::from(i) / 40.0;
1208            let forward = evaluate(&k, &c, a + (b - a) * t, T).unwrap();
1209            let backward = evaluate(&rk, &rc, a + (b - a) * (1.0 - t), T).unwrap();
1210            assert!(forward.is_equal(backward, T), "at t = {t}");
1211        }
1212    }
1213
1214    /// A quarter circle, exactly, as a rational quadratic. This is the reason
1215    /// rational geometry exists: no polynomial curve is a circular arc.
1216    fn quarter_circle() -> (KnotVector, Vec<Weighted<Point>>) {
1217        let w = core::f64::consts::FRAC_1_SQRT_2;
1218        let control = vec![
1219            Weighted::new(Point::new(1.0, 0.0, 0.0), 1.0, T).unwrap(),
1220            Weighted::new(Point::new(1.0, 1.0, 0.0), w, T).unwrap(),
1221            Weighted::new(Point::new(0.0, 1.0, 0.0), 1.0, T).unwrap(),
1222        ];
1223        (KnotVector::clamped_uniform(2, 3).unwrap(), control)
1224    }
1225
1226    #[test]
1227    fn a_rational_quadratic_traces_an_exact_circular_arc() {
1228        let (k, c) = quarter_circle();
1229        for i in 0..=100 {
1230            let u = f64::from(i) / 100.0;
1231            let p = evaluate_rational(&k, &c, u, T).unwrap();
1232            // Every point is at exactly unit distance from the origin, which
1233            // no non-rational B-spline can achieve.
1234            assert_relative_eq!(p.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1235            assert_relative_eq!(p.z, 0.0, epsilon = 1e-15);
1236        }
1237        assert!(
1238            evaluate_rational(&k, &c, 0.0, T)
1239                .unwrap()
1240                .is_equal(Point::new(1.0, 0.0, 0.0), T)
1241        );
1242        assert!(
1243            evaluate_rational(&k, &c, 1.0, T)
1244                .unwrap()
1245                .is_equal(Point::new(0.0, 1.0, 0.0), T)
1246        );
1247    }
1248
1249    #[test]
1250    fn rational_derivatives_agree_with_finite_differences() {
1251        let (k, c) = quarter_circle();
1252        let h = 1e-6;
1253        for i in 1..20 {
1254            let u = f64::from(i) / 20.0;
1255            let d = rational_derivatives(&k, &c, u, 2, T).unwrap();
1256            assert!(d[0].is_equal(evaluate_rational(&k, &c, u, T).unwrap(), T));
1257
1258            let ahead = evaluate_rational(&k, &c, u + h, T).unwrap();
1259            let behind = evaluate_rational(&k, &c, u - h, T).unwrap();
1260            let numeric = (ahead - behind) * (1.0 / (2.0 * h));
1261            assert!(
1262                (d[1].to_vector() - numeric).magnitude() < 1e-5,
1263                "at u = {u}: {:?} vs {numeric:?}",
1264                d[1]
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn the_tangent_of_a_circular_arc_is_perpendicular_to_its_radius() {
1271        let (k, c) = quarter_circle();
1272        for i in 0..=20 {
1273            let u = f64::from(i) / 20.0;
1274            let d = rational_derivatives(&k, &c, u, 1, T).unwrap();
1275            let radius = d[0].to_vector();
1276            let tangent = d[1].to_vector();
1277            assert!(
1278                radius.dot(tangent).abs() < 1e-12,
1279                "not perpendicular at {u}: {}",
1280                radius.dot(tangent)
1281            );
1282        }
1283    }
1284
1285    #[test]
1286    fn knot_insertion_preserves_a_rational_curve_too() {
1287        let (k, c) = quarter_circle();
1288        let (k2, c2) = insert_knot(&k, &c, 0.5, 1, T).unwrap();
1289        for i in 0..=50 {
1290            let u = f64::from(i) / 50.0;
1291            let a = evaluate_rational(&k, &c, u, T).unwrap();
1292            let b = evaluate_rational(&k2, &c2, u, T).unwrap();
1293            assert!(a.is_equal(b, T), "at {u}");
1294            assert_relative_eq!(b.to_vector().magnitude(), 1.0, epsilon = 1e-14);
1295        }
1296    }
1297
1298    #[test]
1299    fn degenerate_weights_are_refused() {
1300        assert!(Weighted::new(Point::ORIGIN, 0.0, T).is_err());
1301        assert!(Weighted::new(Point::ORIGIN, -1.0, T).is_err());
1302        assert!(Weighted::new(Point::ORIGIN, f64::NAN, T).is_err());
1303        assert!(Weighted::new(Point::ORIGIN, f64::INFINITY, T).is_err());
1304        assert!(Weighted::new(Point::ORIGIN, 2.0, T).is_ok());
1305    }
1306
1307    #[test]
1308    fn weighted_round_trips_through_its_homogeneous_form() {
1309        let p = Point::new(3.0, -1.0, 2.0);
1310        let w = Weighted::new(p, 2.5, T).unwrap();
1311        assert!(w.point().is_equal(p, T));
1312        assert!(w.scaled.is_equal(Point::new(7.5, -2.5, 5.0), T));
1313    }
1314
1315    #[test]
1316    fn binomial_coefficients() {
1317        assert_eq!(binomial_coefficient(0, 0), 1);
1318        assert_eq!(binomial_coefficient(5, 0), 1);
1319        assert_eq!(binomial_coefficient(5, 5), 1);
1320        assert_eq!(binomial_coefficient(5, 2), 10);
1321        assert_eq!(binomial_coefficient(10, 5), 252);
1322        assert_eq!(binomial_coefficient(3, 4), 0);
1323    }
1324
1325    #[test]
1326    fn scalar_and_planar_control_points_work_too() {
1327        // The Blend abstraction has to serve every control point type, not just
1328        // 3D positions.
1329        let k = KnotVector::clamped_uniform(2, 4).unwrap();
1330        let scalars = vec![0.0_f64, 1.0, 3.0, 2.0];
1331        assert_relative_eq!(evaluate(&k, &scalars, 0.0, T).unwrap(), 0.0);
1332        assert_relative_eq!(evaluate(&k, &scalars, 1.0, T).unwrap(), 2.0);
1333
1334        let planar = vec![
1335            Point2::new(0.0, 0.0),
1336            Point2::new(1.0, 2.0),
1337            Point2::new(3.0, 1.0),
1338            Point2::new(4.0, 0.0),
1339        ];
1340        assert!(
1341            evaluate(&k, &planar, 0.0, T)
1342                .unwrap()
1343                .is_equal(planar[0], T)
1344        );
1345        assert!(
1346            evaluate(&k, &planar, 1.0, T)
1347                .unwrap()
1348                .is_equal(planar[3], T)
1349        );
1350    }
1351}
1352
1353/// A rectangular grid of control points for a tensor-product surface.
1354///
1355/// Stored row-major: `points[i * v_count + j]` is the point at `u` index `i` and
1356/// `v` index `j`. Carrying the shape with the data means the surface functions
1357/// cannot be handed a grid with the wrong stride, which is the mistake that
1358/// otherwise produces a plausible but transposed surface.
1359#[derive(Debug, Clone, PartialEq)]
1360pub struct ControlGrid<P> {
1361    points: Vec<P>,
1362    u_count: usize,
1363    v_count: usize,
1364}
1365
1366impl<P: Blend> ControlGrid<P> {
1367    /// A grid from row-major points.
1368    ///
1369    /// # Errors
1370    ///
1371    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the point count
1372    /// is not `u_count * v_count`, or either count is zero.
1373    pub fn new(points: Vec<P>, u_count: usize, v_count: usize) -> OgeomResult<Self> {
1374        if u_count == 0 || v_count == 0 {
1375            ogeom_bail!(Dimension, "control grid must be at least 1x1");
1376        }
1377        if points.len() != u_count * v_count {
1378            ogeom_bail!(
1379                Dimension,
1380                "a {u_count}x{v_count} grid needs {} points, got {}",
1381                u_count * v_count,
1382                points.len()
1383            );
1384        }
1385        Ok(Self {
1386            points,
1387            u_count,
1388            v_count,
1389        })
1390    }
1391
1392    /// Number of control points along `u`.
1393    #[must_use]
1394    pub const fn u_count(&self) -> usize {
1395        self.u_count
1396    }
1397
1398    /// Number of control points along `v`.
1399    #[must_use]
1400    pub const fn v_count(&self) -> usize {
1401        self.v_count
1402    }
1403
1404    /// The point at `(i, j)`, or `None` if either index is out of range.
1405    #[must_use]
1406    pub fn get(&self, i: usize, j: usize) -> Option<P> {
1407        if i >= self.u_count || j >= self.v_count {
1408            return None;
1409        }
1410        self.points.get(i * self.v_count + j).copied()
1411    }
1412
1413    /// All points, row-major.
1414    #[must_use]
1415    pub fn points(&self) -> &[P] {
1416        &self.points
1417    }
1418
1419    /// This grid with `u` and `v` exchanged.
1420    #[must_use]
1421    pub fn transposed(&self) -> Self {
1422        let mut points = Vec::with_capacity(self.points.len());
1423        for j in 0..self.v_count {
1424            for i in 0..self.u_count {
1425                points.push(self.points[i * self.v_count + j]);
1426            }
1427        }
1428        Self {
1429            points,
1430            u_count: self.v_count,
1431            v_count: self.u_count,
1432        }
1433    }
1434
1435    /// Apply `f` to every point.
1436    #[must_use]
1437    pub fn map<Q: Blend>(&self, f: impl Fn(P) -> Q) -> ControlGrid<Q> {
1438        ControlGrid {
1439            points: self.points.iter().map(|p| f(*p)).collect(),
1440            u_count: self.u_count,
1441            v_count: self.v_count,
1442        }
1443    }
1444}
1445
1446/// Check that a grid's shape matches its two knot vectors.
1447fn check_grid_shape<P>(ku: &KnotVector, kv: &KnotVector, grid: &ControlGrid<P>) -> OgeomResult<()> {
1448    if grid.u_count != ku.control_point_count() || grid.v_count != kv.control_point_count() {
1449        ogeom_bail!(
1450            Dimension,
1451            "knot vectors describe a {}x{} grid, got {}x{}",
1452            ku.control_point_count(),
1453            kv.control_point_count(),
1454            grid.u_count,
1455            grid.v_count
1456        );
1457    }
1458    Ok(())
1459}
1460
1461/// Evaluate a tensor-product B-spline surface at `(u, v)`.
1462///
1463/// Sums the `(p+1) x (q+1)` non-zero basis products over the control window.
1464/// Only that window contributes (the basis has local support), so cost depends
1465/// on the degrees, not on the size of the surface.
1466///
1467/// # Errors
1468///
1469/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch, and
1470/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if a parameter is outside its
1471/// knot vector's domain.
1472pub fn evaluate_surface<P: Blend>(
1473    ku: &KnotVector,
1474    kv: &KnotVector,
1475    grid: &ControlGrid<P>,
1476    u: f64,
1477    v: f64,
1478    tol: Tolerances,
1479) -> OgeomResult<P> {
1480    check_grid_shape(ku, kv, grid)?;
1481    let (p, q) = (ku.degree(), kv.degree());
1482    let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1483    let (nu, nv) = (ku.basis(su, u), kv.basis(sv, v));
1484
1485    let mut total = P::zero();
1486    for (i, &weight_u) in nu.iter().enumerate() {
1487        // Accumulate along v first, then weight the row: one multiply per row
1488        // instead of one per point.
1489        let mut row = P::zero();
1490        for (j, &weight_v) in nv.iter().enumerate() {
1491            let Some(point) = grid.get(su - p + i, sv - q + j) else {
1492                ogeom_bail!(Dimension, "control grid index out of range");
1493            };
1494            row = row.add(point.scale(weight_v));
1495        }
1496        total = total.add(row.scale(weight_u));
1497    }
1498    Ok(total)
1499}
1500
1501/// Evaluate a surface and its partial derivatives up to total order `order`.
1502///
1503/// `result[k][l]` is the derivative taken `k` times in `u` and `l` times in `v`,
1504/// so `result[0][0]` is the point itself, for `k + l <= order`; entries of
1505/// higher total order are left zero rather than computed.
1506///
1507/// # Errors
1508///
1509/// As [`evaluate_surface`].
1510pub fn surface_derivatives<P: Blend>(
1511    ku: &KnotVector,
1512    kv: &KnotVector,
1513    grid: &ControlGrid<P>,
1514    u: f64,
1515    v: f64,
1516    order: usize,
1517    tol: Tolerances,
1518) -> OgeomResult<DerivativeGrid<P>> {
1519    check_grid_shape(ku, kv, grid)?;
1520    let (p, q) = (ku.degree(), kv.degree());
1521    let (su, sv) = (ku.span(u, tol)?, kv.span(v, tol)?);
1522    let du = ku.basis_derivatives(su, u, order);
1523    let dv = kv.basis_derivatives(sv, v, order);
1524
1525    let mut out: DerivativeGrid<P> =
1526        core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1527            .take(order + 1)
1528            .collect();
1529    // Each control row's sum across v, per v order: the same for every u
1530    // order, so summed once. Only the orders a caller can use are filled,
1531    // those of total order at most `order`; the rest stay zero.
1532    let mut across: SmallVec<[SmallVec<[P; 8]>; 4]> = SmallVec::new();
1533    for weights_v in dv.iter().take(order + 1) {
1534        let mut row: SmallVec<[P; 8]> = SmallVec::new();
1535        for i in 0..=p {
1536            let mut inner = P::zero();
1537            for (j, &weight_v) in weights_v.iter().enumerate() {
1538                let Some(point) = grid.get(su - p + i, sv - q + j) else {
1539                    ogeom_bail!(Dimension, "control grid index out of range");
1540                };
1541                inner = inner.add(point.scale(weight_v));
1542            }
1543            row.push(inner);
1544        }
1545        across.push(row);
1546    }
1547    for (k, row) in out.iter_mut().enumerate() {
1548        for (l, cell) in row.iter_mut().enumerate().take(order + 1 - k) {
1549            // Derivatives past the degree in either direction vanish, and the
1550            // basis returns them as exact zeros, so this sums to zero without
1551            // needing a special case.
1552            let mut total = P::zero();
1553            for (i, &weight_u) in du[k].iter().enumerate() {
1554                total = total.add(across[l][i].scale(weight_u));
1555            }
1556            *cell = total;
1557        }
1558    }
1559    Ok(out)
1560}
1561
1562/// Evaluate a rational tensor-product surface: homogeneous evaluation, then
1563/// divide through.
1564///
1565/// # Errors
1566///
1567/// As [`evaluate_surface`], plus
1568/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the accumulated weight
1569/// vanishes, which positive input weights make impossible.
1570pub fn evaluate_rational_surface<P: Blend>(
1571    ku: &KnotVector,
1572    kv: &KnotVector,
1573    grid: &ControlGrid<Weighted<P>>,
1574    u: f64,
1575    v: f64,
1576    tol: Tolerances,
1577) -> OgeomResult<P> {
1578    let h = evaluate_surface(ku, kv, grid, u, v, tol)?;
1579    if h.weight.abs() <= tol.confusion() {
1580        ogeom_bail!(
1581            Numeric,
1582            "rational surface evaluation produced a vanishing weight"
1583        );
1584    }
1585    Ok(h.point())
1586}
1587
1588/// Evaluate a rational surface and its partial derivatives up to total order
1589/// `order`.
1590///
1591/// The two-parameter quotient rule. Each mixed partial subtracts the weight's
1592/// influence in `u`, in `v`, and in both together; dropping the last of those
1593/// three sums is the classic error, and it only shows up on genuinely rational
1594/// surfaces with mixed derivatives, which is to say, on exactly the spheres and
1595/// tori where the answer matters.
1596///
1597/// # Errors
1598///
1599/// As [`evaluate_rational_surface`].
1600pub fn rational_surface_derivatives<P: Blend>(
1601    ku: &KnotVector,
1602    kv: &KnotVector,
1603    grid: &ControlGrid<Weighted<P>>,
1604    u: f64,
1605    v: f64,
1606    order: usize,
1607    tol: Tolerances,
1608) -> OgeomResult<DerivativeGrid<P>> {
1609    let h = surface_derivatives(ku, kv, grid, u, v, order, tol)?;
1610    let w0 = h[0][0].weight;
1611    if w0.abs() <= tol.confusion() {
1612        ogeom_bail!(
1613            Numeric,
1614            "rational surface evaluation produced a vanishing weight"
1615        );
1616    }
1617
1618    let mut s: DerivativeGrid<P> =
1619        core::iter::repeat_with(|| core::iter::repeat_with(P::zero).take(order + 1).collect())
1620            .take(order + 1)
1621            .collect();
1622    for k in 0..=order {
1623        // Each value reads only lower orders in both directions, so the
1624        // total order's bound holds the recurrence closed.
1625        for l in 0..=order - k {
1626            let mut value = h[k][l].scaled;
1627            #[allow(clippy::cast_precision_loss)]
1628            for i in 1..=k {
1629                let c = binomial_coefficient(k, i) as f64;
1630                value = value.sub(s[k - i][l].scale(c * h[i][0].weight));
1631            }
1632            #[allow(clippy::cast_precision_loss)]
1633            for j in 1..=l {
1634                let c = binomial_coefficient(l, j) as f64;
1635                value = value.sub(s[k][l - j].scale(c * h[0][j].weight));
1636            }
1637            #[allow(clippy::cast_precision_loss)]
1638            for i in 1..=k {
1639                let ci = binomial_coefficient(k, i) as f64;
1640                for j in 1..=l {
1641                    let cj = binomial_coefficient(l, j) as f64;
1642                    value = value.sub(s[k - i][l - j].scale(ci * cj * h[i][j].weight));
1643                }
1644            }
1645            s[k][l] = value.scale(1.0 / w0);
1646        }
1647    }
1648    Ok(s)
1649}
1650
1651#[cfg(test)]
1652#[allow(clippy::unwrap_used)]
1653mod surface_tests {
1654    use super::*;
1655    use approx::assert_relative_eq;
1656
1657    const T: Tolerances = Tolerances::millimetres();
1658
1659    /// A bicubic patch with some genuine curvature.
1660    fn patch() -> (KnotVector, KnotVector, ControlGrid<Point>) {
1661        let (nu, nv) = (5, 4);
1662        let mut points = Vec::with_capacity(nu * nv);
1663        for i in 0..nu {
1664            for j in 0..nv {
1665                #[allow(clippy::cast_precision_loss)]
1666                let (x, y) = (i as f64, j as f64);
1667                points.push(Point::new(x, y, (x * 0.7).sin() * (y * 0.5).cos()));
1668            }
1669        }
1670        (
1671            KnotVector::clamped_uniform(3, nu).unwrap(),
1672            KnotVector::clamped_uniform(2, nv).unwrap(),
1673            ControlGrid::new(points, nu, nv).unwrap(),
1674        )
1675    }
1676
1677    #[test]
1678    fn grid_shape_is_checked_on_construction() {
1679        assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 2, 3).is_ok());
1680        assert!(ControlGrid::new(vec![Point::ORIGIN; 6], 3, 3).is_err());
1681        assert!(ControlGrid::new(Vec::<Point>::new(), 0, 3).is_err());
1682    }
1683
1684    #[test]
1685    fn grid_indexing_is_row_major_and_bounds_checked() {
1686        let g = ControlGrid::new(
1687            vec![
1688                Point::new(0.0, 0.0, 0.0),
1689                Point::new(0.0, 1.0, 0.0),
1690                Point::new(0.0, 2.0, 0.0),
1691                Point::new(1.0, 0.0, 0.0),
1692                Point::new(1.0, 1.0, 0.0),
1693                Point::new(1.0, 2.0, 0.0),
1694            ],
1695            2,
1696            3,
1697        )
1698        .unwrap();
1699        assert_eq!(g.get(1, 2), Some(Point::new(1.0, 2.0, 0.0)));
1700        assert_eq!(g.get(0, 1), Some(Point::new(0.0, 1.0, 0.0)));
1701        assert_eq!(g.get(2, 0), None);
1702        assert_eq!(g.get(0, 3), None);
1703    }
1704
1705    #[test]
1706    fn transposing_twice_is_the_identity() {
1707        let (_, _, g) = patch();
1708        let t = g.transposed();
1709        assert_eq!(t.u_count(), g.v_count());
1710        assert_eq!(t.v_count(), g.u_count());
1711        for i in 0..g.u_count() {
1712            for j in 0..g.v_count() {
1713                assert_eq!(t.get(j, i), g.get(i, j));
1714            }
1715        }
1716        assert_eq!(t.transposed(), g);
1717    }
1718
1719    #[test]
1720    fn a_clamped_patch_interpolates_its_corner_control_points() {
1721        let (ku, kv, g) = patch();
1722        let ((u0, u1), (v0, v1)) = (ku.domain(), kv.domain());
1723        let corners = [
1724            (u0, v0, g.get(0, 0).unwrap()),
1725            (u0, v1, g.get(0, g.v_count() - 1).unwrap()),
1726            (u1, v0, g.get(g.u_count() - 1, 0).unwrap()),
1727            (u1, v1, g.get(g.u_count() - 1, g.v_count() - 1).unwrap()),
1728        ];
1729        for (u, v, expected) in corners {
1730            assert!(
1731                evaluate_surface(&ku, &kv, &g, u, v, T)
1732                    .unwrap()
1733                    .is_equal(expected, T),
1734                "corner ({u}, {v})"
1735            );
1736        }
1737    }
1738
1739    #[test]
1740    fn surface_shape_mismatches_are_refused() {
1741        let (ku, kv, g) = patch();
1742        let wrong = ControlGrid::new(g.points().to_vec(), 4, 5).unwrap();
1743        assert!(evaluate_surface(&ku, &kv, &wrong, 0.5, 0.5, T).is_err());
1744        assert!(evaluate_surface(&ku, &kv, &g, 1.5, 0.5, T).is_err());
1745        assert!(evaluate_surface(&ku, &kv, &g, 0.5, -0.5, T).is_err());
1746    }
1747
1748    #[test]
1749    fn surface_partials_agree_with_finite_differences() {
1750        let (ku, kv, g) = patch();
1751        let h = 1e-6;
1752        for iu in 1..6 {
1753            for iv in 1..6 {
1754                let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1755                let d = surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1756                assert!(d[0][0].is_equal(evaluate_surface(&ku, &kv, &g, u, v, T).unwrap(), T));
1757
1758                let du = (evaluate_surface(&ku, &kv, &g, u + h, v, T).unwrap()
1759                    - evaluate_surface(&ku, &kv, &g, u - h, v, T).unwrap())
1760                    * (1.0 / (2.0 * h));
1761                let dv = (evaluate_surface(&ku, &kv, &g, u, v + h, T).unwrap()
1762                    - evaluate_surface(&ku, &kv, &g, u, v - h, T).unwrap())
1763                    * (1.0 / (2.0 * h));
1764                assert!((d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0));
1765                assert!((d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0));
1766
1767                // The mixed partial, which the naive quotient rule drops.
1768                let mixed = (evaluate_surface(&ku, &kv, &g, u + h, v + h, T).unwrap()
1769                    - evaluate_surface(&ku, &kv, &g, u + h, v - h, T).unwrap()
1770                    - (evaluate_surface(&ku, &kv, &g, u - h, v + h, T).unwrap()
1771                        - evaluate_surface(&ku, &kv, &g, u - h, v - h, T).unwrap()))
1772                    * (1.0 / (4.0 * h * h));
1773                assert!(
1774                    (d[1][1].to_vector() - mixed).magnitude() < 1e-3 * mixed.magnitude().max(1.0),
1775                    "mixed partial wrong at ({u}, {v})"
1776                );
1777            }
1778        }
1779    }
1780
1781    /// A hemisphere, exactly, as a rational biquadratic. Only a rational
1782    /// surface can be one.
1783    fn rational_hemisphere() -> (KnotVector, KnotVector, ControlGrid<Weighted<Point>>) {
1784        let w = core::f64::consts::FRAC_1_SQRT_2;
1785        // A quarter arc in u, swept through a quarter turn in v.
1786        let rows: [[(Point, f64); 3]; 3] = [
1787            [
1788                (Point::new(1.0, 0.0, 0.0), 1.0),
1789                (Point::new(1.0, 1.0, 0.0), w),
1790                (Point::new(0.0, 1.0, 0.0), 1.0),
1791            ],
1792            [
1793                (Point::new(1.0, 0.0, 1.0), w),
1794                (Point::new(1.0, 1.0, 1.0), w * w),
1795                (Point::new(0.0, 1.0, 1.0), w),
1796            ],
1797            [
1798                (Point::new(0.0, 0.0, 1.0), 1.0),
1799                (Point::new(0.0, 0.0, 1.0), w),
1800                (Point::new(0.0, 0.0, 1.0), 1.0),
1801            ],
1802        ];
1803        let points: Vec<_> = rows
1804            .iter()
1805            .flatten()
1806            .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
1807            .collect();
1808        (
1809            KnotVector::clamped_uniform(2, 3).unwrap(),
1810            KnotVector::clamped_uniform(2, 3).unwrap(),
1811            ControlGrid::new(points, 3, 3).unwrap(),
1812        )
1813    }
1814
1815    #[test]
1816    fn a_rational_biquadratic_traces_an_exact_sphere() {
1817        let (ku, kv, g) = rational_hemisphere();
1818        for iu in 0..=10 {
1819            for iv in 0..=10 {
1820                let (u, v) = (f64::from(iu) / 10.0, f64::from(iv) / 10.0);
1821                let p = evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1822                assert_relative_eq!(
1823                    p.to_vector().magnitude(),
1824                    1.0,
1825                    epsilon = 1e-13,
1826                    max_relative = 1e-13
1827                );
1828            }
1829        }
1830    }
1831
1832    #[test]
1833    fn rational_surface_partials_agree_with_finite_differences() {
1834        let (ku, kv, g) = rational_hemisphere();
1835        let h = 1e-6;
1836        let at = |u: f64, v: f64| evaluate_rational_surface(&ku, &kv, &g, u, v, T).unwrap();
1837        for iu in 1..6 {
1838            for iv in 1..6 {
1839                let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1840                let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 2, T).unwrap();
1841                assert!(d[0][0].is_equal(at(u, v), T));
1842
1843                let du = (at(u + h, v) - at(u - h, v)) * (1.0 / (2.0 * h));
1844                let dv = (at(u, v + h) - at(u, v - h)) * (1.0 / (2.0 * h));
1845                assert!(
1846                    (d[1][0].to_vector() - du).magnitude() < 1e-5 * du.magnitude().max(1.0),
1847                    "du wrong at ({u}, {v})"
1848                );
1849                assert!(
1850                    (d[0][1].to_vector() - dv).magnitude() < 1e-5 * dv.magnitude().max(1.0),
1851                    "dv wrong at ({u}, {v})"
1852                );
1853
1854                // The mixed partial is where the cross term in the two-parameter
1855                // quotient rule matters; without it this is visibly wrong.
1856                let mixed =
1857                    (at(u + h, v + h) - at(u + h, v - h) - (at(u - h, v + h) - at(u - h, v - h)))
1858                        * (1.0 / (4.0 * h * h));
1859                assert!(
1860                    (d[1][1].to_vector() - mixed).magnitude() < 1e-2 * mixed.magnitude().max(1.0),
1861                    "mixed partial wrong at ({u}, {v}): {:?} vs {mixed:?}",
1862                    d[1][1]
1863                );
1864            }
1865        }
1866    }
1867
1868    #[test]
1869    fn a_spheres_normal_is_radial() {
1870        // Independent of the derivative formulas: on a unit sphere centred at
1871        // the origin, du x dv must be parallel to the position vector.
1872        let (ku, kv, g) = rational_hemisphere();
1873        for iu in 1..8 {
1874            for iv in 1..8 {
1875                let (u, v) = (f64::from(iu) / 8.0, f64::from(iv) / 8.0);
1876                let d = rational_surface_derivatives(&ku, &kv, &g, u, v, 1, T).unwrap();
1877                let radius = d[0][0].to_vector();
1878                let normal = d[1][0].to_vector().cross(d[0][1].to_vector());
1879                assert!(
1880                    normal.magnitude() > 1e-6,
1881                    "degenerate tangents at ({u}, {v})"
1882                );
1883                let sine =
1884                    radius.cross(normal).magnitude() / (radius.magnitude() * normal.magnitude());
1885                assert!(sine < 1e-9, "normal not radial at ({u}, {v}): sine {sine}");
1886            }
1887        }
1888    }
1889
1890    #[test]
1891    fn uniform_weights_reduce_to_the_polynomial_surface() {
1892        let (ku, kv, g) = patch();
1893        let weighted = g.map(|p| Weighted {
1894            scaled: p.scale(2.0),
1895            weight: 2.0,
1896        });
1897        for iu in 0..=6 {
1898            for iv in 0..=6 {
1899                let (u, v) = (f64::from(iu) / 6.0, f64::from(iv) / 6.0);
1900                let plain = evaluate_surface(&ku, &kv, &g, u, v, T).unwrap();
1901                let rational = evaluate_rational_surface(&ku, &kv, &weighted, u, v, T).unwrap();
1902                assert!(plain.is_equal(rational, T));
1903            }
1904        }
1905    }
1906}