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