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