Skip to main content

ogeom_algo/
fit.rs

1//! Fitting a B-spline to points.
2//!
3//! Two jobs that look alike and are not. [`interpolate`] makes a curve that
4//! passes through every point exactly. [`approximate`] makes one that passes
5//! near them, with fewer control points than there are points to fit.
6//!
7//! # Which one you want
8//!
9//! Interpolation is right when the points are exact: corners of a profile, a
10//! path a machine must visit. It is wrong for measured data, because it fits
11//! the noise as faithfully as the signal, and the wiggles it invents between
12//! samples can be large.
13//!
14//! Approximation is right when the points are samples of something smoother
15//! than they are. It also *cannot* be told to use as many control points as
16//! there are data points; at that ratio the least-squares system is the
17//! interpolation system, and calling one function and getting the other is a
18//! trap. That case is refused with a message pointing at [`interpolate`].
19//!
20//! # Parameterization
21//!
22//! Centripetal by default: parameter spacing goes as the square root of the
23//! chord, not the chord. Uniform spacing produces visible loops when the points
24//! are unevenly spread, and plain chord length overshoots on sharp turns.
25//! Centripetal is the standard compromise and is what a CAD user expects a
26//! fitted curve to look like.
27//!
28//! # Choosing for you
29//!
30//! [`approximate_within`] is the one that decides: it takes an error target
31//! instead of a control-point count, refines its knots where the error
32//! concentrates until the target is met, and reports the error it actually
33//! reached. The machinery lives in [`ogeom_geom::fit`], where the intersector's
34//! approximation stage shares it.
35
36use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_geom::BSplineCurve;
38use ogeom_math::{KnotVector, Point};
39
40/// How to spread parameters over the points.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum Spacing {
43    /// Parameter spacing goes as the square root of the chord.
44    ///
45    /// The default, and what a fitted curve is expected to look like: it
46    /// neither loops the way uniform spacing does on unevenly spread points nor
47    /// overshoots the way chord length does at a sharp turn.
48    #[default]
49    Centripetal,
50    /// Parameter spacing proportional to the chord.
51    Chordal,
52    /// Equal parameter spacing, ignoring the points entirely.
53    ///
54    /// Correct only when the points really are evenly spread; otherwise it is
55    /// the one that loops.
56    Uniform,
57}
58
59/// Fit a B-spline to within a stated error, choosing the knots itself.
60///
61/// The half of fitting the fixed-count functions cannot do: the caller names
62/// how wrong the curve may be, and the fit decides how many control points
63/// that costs, refining where the error concentrates, so a profile that is
64/// straight with one tight corner gets its knots in the corner.
65///
66/// Returns the curve and the error actually reached. If the target could not
67/// be met with the points given, [`Fitted::met`](ogeom_geom::fit::Fitted) says so
68/// rather than the error being rounded up to success.
69///
70/// # Errors
71///
72/// As [`ogeom_geom::fit::fit_points`].
73pub fn approximate_within(
74    points: &[Point],
75    tolerance: f64,
76    tol: Tolerances,
77) -> OgeomResult<ogeom_geom::fit::Fitted<BSplineCurve>> {
78    ogeom_geom::fit::fit_points(points, 3, tolerance, tol)
79}
80
81/// Fit a B-spline that passes through every point.
82///
83/// # Errors
84///
85/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are fewer
86/// points than the degree requires, if two consecutive points coincide (which
87/// leaves a parameter interval of zero and a singular system), or if the system
88/// turns out singular anyway.
89pub fn interpolate(
90    points: &[Point],
91    degree: usize,
92    spacing: Spacing,
93    tol: Tolerances,
94) -> OgeomResult<BSplineCurve> {
95    if degree == 0 {
96        ogeom_bail!(
97            Construction,
98            "a curve of degree zero is a point, not a curve"
99        );
100    }
101    if points.len() <= degree {
102        ogeom_bail!(
103            Construction,
104            "interpolating a degree-{degree} curve needs at least {} points, \
105             got {}",
106            degree + 1,
107            points.len()
108        );
109    }
110
111    let parameters = parameterize(points, spacing, tol)?;
112    interpolate_at(points, &parameters, degree, tol)
113}
114
115/// The parameters [`interpolate`] would give points under a spacing.
116pub(crate) fn spaced(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
117    parameterize(points, spacing, tol)
118}
119
120/// Interpolate points at parameters of the caller's own, the knots
121/// averaged from them: curves interpolated at one set of parameters share
122/// their knots, and so run together parameter for parameter.
123pub(crate) fn interpolate_at(
124    points: &[Point],
125    parameters: &[f64],
126    degree: usize,
127    tol: Tolerances,
128) -> OgeomResult<BSplineCurve> {
129    if points.len() <= degree || parameters.len() != points.len() {
130        ogeom_bail!(
131            Construction,
132            "interpolating a degree-{degree} curve needs more than {degree} points, one parameter each"
133        );
134    }
135    let knots = KnotVector::averaged(degree, parameters)?;
136
137    // The collocation system: row k says "the curve at parameter t_k is point
138    // k", which in the basis is a weighted sum of the control points.
139    let n = points.len();
140    let mut rows: Vec<(usize, Vec<f64>)> = Vec::with_capacity(n);
141    for &t in parameters {
142        let span = knots.span(t, tol)?;
143        rows.push((span - degree, knots.basis(span, t).to_vec()));
144    }
145    let control = match solve_banded(&rows, points, degree) {
146        Some(control) => control,
147        None => {
148            let mut matrix = nalgebra::DMatrix::<f64>::zeros(n, n);
149            for (row, (first, basis)) in rows.iter().enumerate() {
150                for (j, value) in basis.iter().enumerate() {
151                    matrix[(row, first + j)] = *value;
152                }
153            }
154            solve(&matrix, points)?
155        }
156    };
157    BSplineCurve::new(knots, control, tol)
158}
159
160/// The collocation system solved in its band: with the knots averaged from
161/// the parameters every row's entries stand within `degree` of the
162/// diagonal, and the matrix is totally positive, so elimination needs no
163/// pivoting and costs the band's width per row rather than the size
164/// cubed. `None` where a row leaves the band or a pivot vanishes, for the
165/// dense solve to answer.
166fn solve_banded(rows: &[(usize, Vec<f64>)], rhs: &[Point], degree: usize) -> Option<Vec<Point>> {
167    let n = rows.len();
168    let width = 2 * degree + 1;
169    let mut band = vec![vec![0.0; width]; n];
170    for (i, (first, basis)) in rows.iter().enumerate() {
171        for (j, value) in basis.iter().enumerate() {
172            let column = first + j;
173            let offset = (column + degree).checked_sub(i)?;
174            if offset >= width {
175                return None;
176            }
177            band[i][offset] = *value;
178        }
179    }
180    let mut b: Vec<[f64; 3]> = rhs.iter().map(|p| [p.x, p.y, p.z]).collect();
181    for k in 0..n {
182        let pivot = band[k][degree];
183        if pivot.abs() <= f64::EPSILON {
184            return None;
185        }
186        for i in (k + 1)..n.min(k + degree + 1) {
187            let factor = band[i][k + degree - i] / pivot;
188            if factor == 0.0 {
189                continue;
190            }
191            for j in k..n.min(k + degree + 1) {
192                band[i][j + degree - i] -= factor * band[k][j + degree - k];
193            }
194            let row = b[k];
195            for (x, r) in b[i].iter_mut().zip(row) {
196                *x -= factor * r;
197            }
198        }
199    }
200    let mut x = vec![[0.0; 3]; n];
201    for k in (0..n).rev() {
202        let mut sum = b[k];
203        for j in (k + 1)..n.min(k + degree + 1) {
204            for (s, v) in sum.iter_mut().zip(x[j]) {
205                *s -= band[k][j + degree - k] * v;
206            }
207        }
208        x[k] = sum.map(|s| s / band[k][degree]);
209    }
210    Some(x.into_iter().map(|[a, b, c]| Point::new(a, b, c)).collect())
211}
212
213/// Fit a B-spline that passes *near* the points, with `control_count` control
214/// points.
215///
216/// The first and last points are interpolated exactly: a fitted curve that
217/// does not start where the data starts is almost never wanted, and the ends
218/// are where a free least-squares fit goes worst.
219///
220/// # Errors
221///
222/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are too
223/// few points, if `control_count` does not leave the system overdetermined, or
224/// if the normal equations are singular.
225pub fn approximate(
226    points: &[Point],
227    degree: usize,
228    control_count: usize,
229    spacing: Spacing,
230    tol: Tolerances,
231) -> OgeomResult<BSplineCurve> {
232    if degree == 0 {
233        ogeom_bail!(
234            Construction,
235            "a curve of degree zero is a point, not a curve"
236        );
237    }
238    if control_count <= degree {
239        ogeom_bail!(
240            Construction,
241            "a degree-{degree} curve needs more than {degree} control points, \
242             asked for {control_count}"
243        );
244    }
245    if points.len() <= control_count {
246        ogeom_bail!(
247            Construction,
248            "approximating {} points with {control_count} control points is not \
249             an approximation: at that ratio the least-squares system *is* the \
250             interpolation system. Use `interpolate`",
251            points.len()
252        );
253    }
254
255    let parameters = parameterize(points, spacing, tol)?;
256    // Knots spread over the parameter range rather than averaged: averaging is
257    // for interpolation, where there is one knot per point. Here there are
258    // fewer control points than points, and the knots have to be placed so
259    // every span holds at least one of them or the system is singular.
260    let knots = spread(degree, control_count, &parameters)?;
261
262    let free = control_count - 2;
263    let inner = points.len() - 2;
264    let mut matrix = nalgebra::DMatrix::<f64>::zeros(inner, free);
265    let mut rhs = vec![Point::ORIGIN; inner];
266
267    for k in 1..points.len() - 1 {
268        let t = parameters[k];
269        let span = knots.span(t, tol)?;
270        let basis = knots.basis(span, t);
271
272        // The two interpolated ends are known, so their contribution moves to
273        // the right-hand side instead of being solved for.
274        let mut residual = points[k].to_vector();
275        for (j, value) in basis.iter().enumerate() {
276            let column = span - degree + j;
277            if column == 0 {
278                residual -= points[0].to_vector() * *value;
279            } else if column == control_count - 1 {
280                residual -= points[points.len() - 1].to_vector() * *value;
281            } else {
282                matrix[(k - 1, column - 1)] = *value;
283            }
284        }
285        rhs[k - 1] = Point::ORIGIN + residual;
286    }
287
288    // Normal equations. Forming them squares the condition number, which for a
289    // well-spread fit of the sizes this is used at costs a few digits and buys
290    // a much smaller solve than a QR of the full system.
291    let normal = matrix.transpose() * &matrix;
292    let projected = project(&matrix, &rhs);
293    let middle = solve(&normal, &projected)?;
294
295    let mut control = Vec::with_capacity(control_count);
296    control.push(points[0]);
297    control.extend(middle);
298    control.push(points[points.len() - 1]);
299    BSplineCurve::new(knots, control, tol)
300}
301
302/// Parameters for the points, by the chosen spacing.
303fn parameterize(points: &[Point], spacing: Spacing, tol: Tolerances) -> OgeomResult<Vec<f64>> {
304    let n = points.len();
305    if n < 2 {
306        ogeom_bail!(Construction, "fitting needs at least two points");
307    }
308    if spacing == Spacing::Uniform {
309        #[allow(clippy::cast_precision_loss)]
310        return Ok((0..n).map(|i| i as f64 / (n - 1) as f64).collect());
311    }
312
313    let mut weights = Vec::with_capacity(n - 1);
314    for w in points.windows(2) {
315        let chord = w[0].distance(w[1]);
316        if chord <= tol.confusion() {
317            ogeom_bail!(
318                Construction,
319                "two consecutive points coincide, which leaves a parameter \
320                 interval of zero and a system with no solution; remove the \
321                 duplicate before fitting"
322            );
323        }
324        weights.push(if spacing == Spacing::Centripetal {
325            chord.sqrt()
326        } else {
327            chord
328        });
329    }
330
331    let total: f64 = weights.iter().sum();
332    let mut parameters = Vec::with_capacity(n);
333    parameters.push(0.0);
334    let mut running = 0.0;
335    for w in &weights {
336        running += w;
337        parameters.push(running / total);
338    }
339    // The last is 1.0 by construction, but only to within rounding, and the
340    // knot vector's domain end has to match it exactly or the final point sits
341    // a hair outside the curve's domain.
342    let last = parameters.len() - 1;
343    parameters[last] = 1.0;
344    Ok(parameters)
345}
346
347/// A clamped knot vector for an approximation.
348///
349/// Interior knots are placed so each spans an equal share of the *data*, which
350/// is what keeps every span occupied. A knot span with no data point in it
351/// leaves a column of zeros in the system and no unique answer.
352fn spread(degree: usize, control_count: usize, parameters: &[f64]) -> OgeomResult<KnotVector> {
353    let mut knots = vec![0.0; degree + 1];
354    let interior = control_count - degree - 1;
355
356    #[allow(clippy::cast_precision_loss)]
357    let step = (parameters.len() - 1) as f64 / (control_count - degree) as f64;
358    for j in 1..=interior {
359        #[allow(
360            clippy::cast_precision_loss,
361            clippy::cast_possible_truncation,
362            clippy::cast_sign_loss
363        )]
364        let at = (j as f64 * step) as usize;
365        #[allow(clippy::cast_precision_loss)]
366        let fraction = j as f64 * step - at as f64;
367        let a = parameters[at.min(parameters.len() - 1)];
368        let b = parameters[(at + 1).min(parameters.len() - 1)];
369        knots.push(fraction.mul_add(b - a, a));
370    }
371    knots.extend(std::iter::repeat_n(1.0, degree + 1));
372    KnotVector::new(knots, degree)
373}
374
375/// `Máµ€ b`, one column of points at a time.
376fn project(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> Vec<Point> {
377    let mut out = vec![Point::ORIGIN; matrix.ncols()];
378    for (column, slot) in out.iter_mut().enumerate() {
379        let mut sum = ogeom_math::Vector::ZERO;
380        for (row, point) in rhs.iter().enumerate() {
381            sum += point.to_vector() * matrix[(row, column)];
382        }
383        *slot = Point::ORIGIN + sum;
384    }
385    out
386}
387
388/// Solve `M x = points` for the three coordinates at once.
389fn solve(matrix: &nalgebra::DMatrix<f64>, rhs: &[Point]) -> OgeomResult<Vec<Point>> {
390    let n = rhs.len();
391    let mut b = nalgebra::DMatrix::<f64>::zeros(n, 3);
392    for (row, point) in rhs.iter().enumerate() {
393        b[(row, 0)] = point.x;
394        b[(row, 1)] = point.y;
395        b[(row, 2)] = point.z;
396    }
397
398    // LU with partial pivoting. The collocation matrix is banded and diagonally
399    // dominant for a sensible parameterization, so this is stable; the failure
400    // it does report (a singular system) means the points or the knots were
401    // degenerate, which is worth an error rather than a plausible answer.
402    let Some(x) = matrix.clone().lu().solve(&b) else {
403        ogeom_bail!(
404            Construction,
405            "the fitting system has no unique solution; the points are \
406             degenerate, or the knots leave a span with no point in it"
407        );
408    };
409    Ok((0..n)
410        .map(|i| Point::new(x[(i, 0)], x[(i, 1)], x[(i, 2)]))
411        .collect())
412}
413
414#[cfg(test)]
415#[allow(clippy::unwrap_used, clippy::expect_used)]
416mod tests {
417    use super::*;
418    use ogeom_geom::Curve3d;
419
420    const T: Tolerances = Tolerances::millimetres();
421
422    fn helix(n: usize) -> Vec<Point> {
423        (0..n)
424            .map(|i| {
425                #[allow(clippy::cast_precision_loss)]
426                let t = i as f64 / (n - 1) as f64 * std::f64::consts::TAU;
427                Point::new(t.cos() * 5.0, t.sin() * 5.0, t * 0.5)
428            })
429            .collect()
430    }
431
432    #[test]
433    fn an_interpolant_passes_through_every_point() {
434        // The defining property. A fit that is merely close is an
435        // approximation, and the two are not interchangeable.
436        for degree in [2, 3, 5] {
437            let points = helix(12);
438            let curve = interpolate(&points, degree, Spacing::Centripetal, T).unwrap();
439            let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
440
441            for (point, t) in points.iter().zip(&parameters) {
442                let on_curve = curve.point_at(*t, T).unwrap();
443                assert!(
444                    on_curve.distance(*point) < 1e-9,
445                    "degree {degree}: missed by {}",
446                    on_curve.distance(*point)
447                );
448            }
449        }
450    }
451
452    #[test]
453    fn an_interpolant_through_collinear_points_is_the_line_they_lie_on() {
454        // A curve that wanders off a straight run of points is the classic
455        // parameterization failure, and it is invisible at the points
456        // themselves, only between them.
457        let points: Vec<Point> = (0..8).map(|i| Point::new(f64::from(i), 0.0, 0.0)).collect();
458        let curve = interpolate(&points, 3, Spacing::Centripetal, T).unwrap();
459
460        for i in 0..=40 {
461            let t = f64::from(i) / 40.0;
462            let p = curve.point_at(t, T).unwrap();
463            assert!(p.y.abs() < 1e-9 && p.z.abs() < 1e-9, "wandered to {p:?}");
464        }
465    }
466
467    #[test]
468    fn an_approximation_uses_the_control_points_it_was_given_and_hits_the_ends() {
469        let points = helix(60);
470        let curve = approximate(&points, 3, 10, Spacing::Centripetal, T).unwrap();
471        assert_eq!(curve.control_points().len(), 10);
472
473        let (a, b) = curve.domain();
474        assert!(curve.point_at(a, T).unwrap().distance(points[0]) < 1e-9);
475        assert!(
476            curve
477                .point_at(b, T)
478                .unwrap()
479                .distance(points[points.len() - 1])
480                < 1e-9
481        );
482    }
483
484    #[test]
485    fn more_control_points_fit_the_data_more_closely() {
486        // The property that makes an approximation useful: it is a knob, and
487        // turning it has to do what it says.
488        let points = helix(80);
489        let parameters = parameterize(&points, Spacing::Centripetal, T).unwrap();
490        let mut previous = f64::INFINITY;
491
492        for count in [6, 10, 20, 40] {
493            let curve = approximate(&points, 3, count, Spacing::Centripetal, T).unwrap();
494            let worst = points
495                .iter()
496                .zip(&parameters)
497                .map(|(p, t)| curve.point_at(*t, T).unwrap().distance(*p))
498                .fold(0.0_f64, f64::max);
499            assert!(
500                worst < previous,
501                "{count} control points fit worse than the previous step: \
502                 {worst} against {previous}"
503            );
504            previous = worst;
505        }
506        assert!(
507            previous < 0.05,
508            "40 control points should fit well, got {previous}"
509        );
510    }
511
512    #[test]
513    fn centripetal_spacing_beats_uniform_on_unevenly_spread_points() {
514        // The reason it is the default. Uniform spacing on points that bunch
515        // and then spread makes the curve loop between the spread ones, and the
516        // loop is far larger than any tolerance would allow.
517        let mut points = vec![Point::ORIGIN];
518        for i in 1..=5 {
519            points.push(Point::new(f64::from(i) * 0.1, 0.0, 0.0));
520        }
521        points.push(Point::new(20.0, 0.0, 0.0));
522        points.push(Point::new(40.0, 0.0, 0.0));
523
524        let excursion = |spacing| {
525            let curve = interpolate(&points, 3, spacing, T).unwrap();
526            (0..=200)
527                .map(|i| {
528                    let t = f64::from(i) / 200.0;
529                    let p = curve.point_at(t, T).unwrap();
530                    p.y.hypot(p.z)
531                })
532                .fold(0.0_f64, f64::max)
533        };
534        assert!(
535            excursion(Spacing::Centripetal) <= excursion(Spacing::Uniform) + 1e-12,
536            "centripetal should be no worse than uniform"
537        );
538    }
539
540    #[test]
541    fn asking_for_an_approximation_that_is_an_interpolation_is_refused() {
542        // Silently returning an interpolant would be worse than failing: the
543        // caller asked for smoothing and would get the noise back, fitted
544        // exactly, with nothing to say so.
545        let points = helix(10);
546        let refused = approximate(&points, 3, 10, Spacing::Centripetal, T);
547        assert!(refused.is_err());
548        assert!(
549            format!("{}", refused.unwrap_err()).contains("Use `interpolate`"),
550            "the message should point at the function that does want this"
551        );
552    }
553
554    #[test]
555    fn coincident_points_are_refused_rather_than_solved_around() {
556        // Two points at one place leave a parameter interval of zero, and the
557        // system has no unique answer. Nudging one apart silently would move
558        // data the caller supplied.
559        let points = vec![
560            Point::ORIGIN,
561            Point::new(1.0, 0.0, 0.0),
562            Point::new(1.0, 0.0, 0.0),
563            Point::new(2.0, 0.0, 0.0),
564        ];
565        let refused = interpolate(&points, 2, Spacing::Centripetal, T);
566        assert!(refused.is_err());
567        assert!(format!("{}", refused.unwrap_err()).contains("coincide"));
568    }
569
570    #[test]
571    fn too_few_points_for_the_degree_is_refused() {
572        let points = helix(3);
573        assert!(interpolate(&points, 5, Spacing::Centripetal, T).is_err());
574        assert!(interpolate(&points, 0, Spacing::Centripetal, T).is_err());
575        assert!(approximate(&points, 3, 3, Spacing::Centripetal, T).is_err());
576    }
577
578    #[test]
579    fn a_degree_one_interpolant_is_the_polyline_itself() {
580        let points = helix(6);
581        let curve = interpolate(&points, 1, Spacing::Chordal, T).unwrap();
582        assert_eq!(curve.control_points().len(), points.len());
583        for (control, point) in curve.control_points().iter().zip(&points) {
584            assert!(control.scaled.distance(*point) < 1e-12);
585        }
586    }
587}