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