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