Skip to main content

ogeom_geom/
fit.rs

1//! Fitting a B-spline to points, to a stated error target.
2//!
3//! The missing half of fitting. Interpolation and fixed-count approximation
4//! exist in `ogeom-algo`; what they cannot do is *choose*: the caller names a
5//! control-point count and hopes. This module is the loop that closes that:
6//! fit, measure where the fit is worst, refine the knots exactly there, and
7//! repeat until the error target is met.
8//!
9//! `docs/PLAN.md` carries this with a warning worth repeating: a fit that
10//! silently picks its own resolution and reports success is the shape of answer
11//! that gets trusted. So the result here carries the error actually reached and
12//! whether the target was met, and a fit that ran out of room says so rather
13//! than rounding "close" up to "done".
14//!
15//! # Where the knots go
16//!
17//! Refinement is *where the error is*, not everywhere. Splitting every span
18//! doubles the control points per round and most of them buy nothing; a curve
19//! that is straight for most of its length and tight in one corner needs its
20//! knots in the corner. Each round measures the error per span and splits only
21//! the spans that exceed the target, so the knot density ends up tracking the
22//! curvature, which is where it belongs.
23//!
24//! # Who this is for
25//!
26//! The marching intersector, first: a traced branch is a polyline with a stated
27//! chord tolerance, and downstream code wants a curve, so the polyline is fitted
28//! to the same tolerance and the result is as good as the trace. But nothing
29//! here knows about intersections; it fits points, in three dimensions or two,
30//! which is also what a digitized profile or an imported polyline needs.
31
32use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
33use ogeom_math::{KnotVector, Point, Point2};
34
35use crate::curve::BSplineCurve;
36use crate::curve2d::BSpline2d;
37
38#[allow(
39    clippy::cast_precision_loss,
40    reason = "sample counts are far below 2^52"
41)]
42fn precise(n: usize) -> f64 {
43    n as f64
44}
45
46/// What a fit produced.
47#[derive(Debug, Clone, PartialEq)]
48pub struct Fitted<C> {
49    /// The curve.
50    pub curve: C,
51    /// The largest distance from any input point to the curve at its
52    /// parameter.
53    pub error: f64,
54    /// Whether the error target was met.
55    ///
56    /// A fit can run out of room (more control points than points to fit
57    /// solves nothing, since at that ratio least squares *is* interpolation),
58    /// and then this is `false` and `error` says how close it got. Reported
59    /// rather than rounded up to success.
60    pub met: bool,
61}
62
63/// Fit a spline through 3D points, refining until `tolerance` is met.
64///
65/// The first and last points are honoured exactly: they are where the curve
66/// joins whatever comes next, and a fit that drifts at its ends produces gaps
67/// at every junction built on it. A closed polyline (first point repeated at
68/// the end) therefore comes back closed.
69///
70/// # Errors
71///
72/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if there are
73/// fewer than two distinct points or the tolerance is not a positive distance.
74pub fn fit_points(
75    points: &[Point],
76    degree: usize,
77    tolerance: f64,
78    tol: Tolerances,
79) -> OgeomResult<Fitted<BSplineCurve>> {
80    let (knots, control, error, met) = fit::<3>(
81        &points.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
82        degree,
83        tolerance,
84        false,
85        tol,
86    )?;
87    let curve = BSplineCurve::new(
88        knots,
89        control
90            .into_iter()
91            .map(|c| Point::new(c[0], c[1], c[2]))
92            .collect(),
93        tol,
94    )?;
95    Ok(Fitted { curve, error, met })
96}
97
98/// Fit a *fair* curve: least squares over the points, pulled toward
99/// minimum bending energy by a smoothing weight.
100///
101/// The energy is the squared second difference of the control polygon (the
102/// discrete bending of the curve's own skeleton), added to the normal
103/// equations as `λ·DᵀD`. At `λ = 0` this is plain least squares; as `λ`
104/// grows the curve trades closeness for straightness, which is the batten
105/// a drafter flexes through points. Both ends interpolate their points
106/// exactly, whatever the weight. The reported error is the honest maximum
107/// distance from the inputs, which *rises* with `λ`: fairness is spent
108/// closeness, and the number says how much was spent.
109///
110/// # Errors
111///
112/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
113/// fewer than two distinct points arrive, the control budget cannot carry
114/// the degree, or the weight is not finite and non-negative.
115pub fn fit_points_faired(
116    points: &[Point],
117    degree: usize,
118    controls: usize,
119    smoothing: f64,
120    tol: Tolerances,
121) -> OgeomResult<Fitted<BSplineCurve>> {
122    if !smoothing.is_finite() || smoothing < 0.0 {
123        ogeom_bail!(
124            Construction,
125            "a smoothing weight of {smoothing} is not a weight"
126        );
127    }
128    if controls < degree + 1 {
129        ogeom_bail!(
130            Construction,
131            "{controls} control points cannot carry degree {degree}"
132        );
133    }
134    let m = points.len();
135    if m < 2 {
136        ogeom_bail!(Construction, "a fair curve needs at least two points");
137    }
138
139    // Chord-length parameters over the clamped knot domain.
140    let knots = KnotVector::clamped_uniform(degree, controls)?;
141    let (lo, hi) = (knots.domain_start(), knots.domain_end());
142    let mut cumulative = vec![0.0f64; m];
143    for i in 1..m {
144        cumulative[i] = cumulative[i - 1] + points[i].distance(points[i - 1]);
145    }
146    let total = cumulative[m - 1];
147    if total <= tol.confusion() {
148        ogeom_bail!(
149            Construction,
150            "the points coincide; there is no curve to fair"
151        );
152    }
153    let parameters: Vec<f64> = cumulative
154        .iter()
155        .map(|c| lo + (hi - lo) * (c / total))
156        .collect();
157
158    // The full stiffness K = AᵀA + λ·DᵀD over every control point, then the
159    // end points pinned by moving their columns to the right-hand side.
160    let n = controls;
161    let mut a = nalgebra::DMatrix::<f64>::zeros(m, n);
162    for (k, &u) in parameters.iter().enumerate() {
163        let span = knots.span_unchecked(u);
164        let basis = knots.basis(span, u);
165        let first = span - degree;
166        for (j, b) in basis.iter().enumerate() {
167            a[(k, first + j)] = *b;
168        }
169    }
170    let mut d = nalgebra::DMatrix::<f64>::zeros(n.saturating_sub(2), n);
171    for r in 0..n.saturating_sub(2) {
172        d[(r, r)] = 1.0;
173        d[(r, r + 1)] = -2.0;
174        d[(r, r + 2)] = 1.0;
175    }
176    let k_full = a.transpose() * &a + d.transpose() * &d * smoothing;
177
178    let free: Vec<usize> = (1..n - 1).collect();
179    let mut reduced = nalgebra::DMatrix::<f64>::zeros(free.len(), free.len());
180    for (ri, &i) in free.iter().enumerate() {
181        for (rj, &j) in free.iter().enumerate() {
182            reduced[(ri, rj)] = k_full[(i, j)];
183        }
184    }
185    let decomposition = reduced.lu();
186
187    let mut control = vec![Point::ORIGIN; n];
188    control[0] = points[0];
189    control[n - 1] = points[m - 1];
190    for axis in 0..3 {
191        let b_data =
192            nalgebra::DVector::from_iterator(m, points.iter().map(|p| [p.x, p.y, p.z][axis]));
193        let full_rhs = a.transpose() * &b_data;
194        let mut rhs = nalgebra::DVector::<f64>::zeros(free.len());
195        for (ri, &i) in free.iter().enumerate() {
196            rhs[ri] = full_rhs[i]
197                - k_full[(i, 0)] * [points[0].x, points[0].y, points[0].z][axis]
198                - k_full[(i, n - 1)] * [points[m - 1].x, points[m - 1].y, points[m - 1].z][axis];
199        }
200        let Some(solved) = decomposition.solve(&rhs) else {
201            ogeom_bail!(
202                Numeric,
203                "the faired system is singular; fewer controls or more points"
204            );
205        };
206        for (ri, &i) in free.iter().enumerate() {
207            match axis {
208                0 => control[i].x = solved[ri],
209                1 => control[i].y = solved[ri],
210                _ => control[i].z = solved[ri],
211            }
212        }
213    }
214
215    let curve = BSplineCurve::new(knots, control, tol)?;
216    let mut error = 0.0f64;
217    {
218        use crate::traits::Curve3d as _;
219        for (point, &u) in points.iter().zip(&parameters) {
220            error = error.max(curve.point_at(u, tol)?.distance(*point));
221        }
222    }
223    Ok(Fitted {
224        curve,
225        error,
226        met: true,
227    })
228}
229
230/// Fit a smoothly closed loop: as [`fit_points`], with the join C1.
231///
232/// The input must be a loop (the first point repeated at the end), and the
233/// ends are honoured exactly as always. Beyond that, the tangent leaving the
234/// join is constrained to equal the tangent arriving at it, eliminated
235/// exactly inside the least-squares solve rather than patched on after, so a
236/// surface built over the loop shows no crease at the seam.
237///
238/// The constraint spends shape freedom at the join: the last movable control
239/// point follows the first, so a loop fits with slightly more knots than the
240/// same data fitted open, and the tightest reachable error is a little
241/// higher. That is the price of the join being smooth, and it is why this is
242/// a separate entry rather than a change to [`fit_points`].
243///
244/// # Errors
245///
246/// As [`fit_points`], and additionally if the input is not a closed loop.
247pub fn fit_points_closed(
248    points: &[Point],
249    degree: usize,
250    tolerance: f64,
251    tol: Tolerances,
252) -> OgeomResult<Fitted<BSplineCurve>> {
253    let Some((first, last)) = points.first().zip(points.last()) else {
254        ogeom_bail!(Construction, "a closed fit needs points");
255    };
256    if !first.is_equal(*last, tol) {
257        ogeom_bail!(
258            Construction,
259            "a closed fit needs a loop: the first point repeated at the end"
260        );
261    }
262    let (knots, control, error, met) = fit::<3>(
263        &points.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
264        degree,
265        tolerance,
266        true,
267        tol,
268    )?;
269    let curve = BSplineCurve::new(
270        knots,
271        control
272            .into_iter()
273            .map(|c| Point::new(c[0], c[1], c[2]))
274            .collect(),
275        tol,
276    )?;
277    Ok(Fitted { curve, error, met })
278}
279
280/// Fit a spline through 2D points, refining until `tolerance` is met.
281///
282/// The planar twin of [`fit_points`], for pcurves: an intersection curve lives
283/// on both surfaces, and each face needs it in its own parameter space.
284///
285/// # Errors
286///
287/// As [`fit_points`].
288pub fn fit_points_2d(
289    points: &[Point2],
290    degree: usize,
291    tolerance: f64,
292    tol: Tolerances,
293) -> OgeomResult<Fitted<BSpline2d>> {
294    let (knots, control, error, met) = fit::<2>(
295        &points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>(),
296        degree,
297        tolerance,
298        false,
299        tol,
300    )?;
301    let curve = BSpline2d::new(
302        knots,
303        control
304            .into_iter()
305            .map(|c| Point2::new(c[0], c[1]))
306            .collect(),
307        tol,
308    )?;
309    Ok(Fitted { curve, error, met })
310}
311
312/// Fit one curve living in three spaces at once: a 3D curve and its two
313/// parameter-space images, as a single seven-dimensional fit.
314///
315/// One parameterization, one knot vector, one correction: the three results
316/// are same-parameter *by construction*, which separate fits cannot promise:
317/// each fit's parameter correction drifts its parameterization independently,
318/// and the drift is invisible to every per-fit error measure. The boolean
319/// found that: pcurves claiming 1e-7 evaluated millimetres from their own
320/// curve. The reported error bounds the worst deviation across all seven
321/// coordinates, so it bounds each space's deviation too.
322///
323/// # Errors
324///
325/// As [`fit_points`], and the three inputs must be equally long.
326#[allow(clippy::type_complexity)]
327pub fn fit_points_joint(
328    points: &[Point],
329    on_a: &[Point2],
330    on_b: &[Point2],
331    degree: usize,
332    tolerance: f64,
333    tol: Tolerances,
334) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
335    fit_points_joint_inner(points, on_a, on_b, degree, tolerance, false, tol)
336}
337
338/// As [`fit_points_joint`], with a closed loop's join made C1.
339///
340/// The seven-dimensional twin of [`fit_points_closed`]: when the trace is a
341/// loop (the first sample repeated at the end in every space), the join's
342/// tangent constraint is eliminated inside the shared solve, so the curve
343/// *and both pcurves* cross their seam smoothly, still same-parameter by
344/// construction. An input that is not closed in all seven coordinates fits
345/// as [`fit_points_joint`] would; the constraint simply never engages.
346///
347/// # Errors
348///
349/// As [`fit_points_joint`].
350#[allow(clippy::type_complexity)]
351pub fn fit_points_joint_closed(
352    points: &[Point],
353    on_a: &[Point2],
354    on_b: &[Point2],
355    degree: usize,
356    tolerance: f64,
357    tol: Tolerances,
358) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
359    fit_points_joint_inner(points, on_a, on_b, degree, tolerance, true, tol)
360}
361
362#[allow(clippy::type_complexity)]
363fn fit_points_joint_inner(
364    points: &[Point],
365    on_a: &[Point2],
366    on_b: &[Point2],
367    degree: usize,
368    tolerance: f64,
369    smooth_loop: bool,
370    tol: Tolerances,
371) -> OgeomResult<(Fitted<BSplineCurve>, BSpline2d, BSpline2d)> {
372    if points.len() != on_a.len() || points.len() != on_b.len() {
373        ogeom_bail!(
374            Construction,
375            "a joint fit needs the same trace seen in every space"
376        );
377    }
378    let joined: Vec<[f64; 7]> = points
379        .iter()
380        .zip(on_a)
381        .zip(on_b)
382        .map(|((p, a), b)| [p.x, p.y, p.z, a.x, a.y, b.x, b.y])
383        .collect();
384    // Centripetal first, as every free fit is, and by chord length where
385    // that misses its target: the two disagree only where the samples are
386    // spaced far from evenly, and there each is right for its own data
387    // (the centripetal guess for a trace with a kink in it, chord length
388    // for a smooth trace crowded at one end), so the closer of the two
389    // stands.
390    let first = fit_spaced::<7>(
391        &joined,
392        degree,
393        tolerance,
394        smooth_loop,
395        Spacing::Centripetal,
396        tol,
397    )?;
398    let (knots, control, error, met) = if first.3 {
399        first
400    } else {
401        match fit_spaced::<7>(
402            &joined,
403            degree,
404            tolerance,
405            smooth_loop,
406            Spacing::ChordLength,
407            tol,
408        ) {
409            Ok(second) if second.2 < first.2 => second,
410            _ => first,
411        }
412    };
413    let curve = BSplineCurve::new(
414        knots.clone(),
415        control
416            .iter()
417            .map(|c| Point::new(c[0], c[1], c[2]))
418            .collect(),
419        tol,
420    )?;
421    let pa = BSpline2d::new(
422        knots.clone(),
423        control.iter().map(|c| Point2::new(c[3], c[4])).collect(),
424        tol,
425    )?;
426    let pb = BSpline2d::new(
427        knots,
428        control.iter().map(|c| Point2::new(c[5], c[6])).collect(),
429        tol,
430    )?;
431    Ok((Fitted { curve, error, met }, pa, pb))
432}
433
434/// Fit a pcurve at *fixed* parameters: the source curve's own.
435///
436/// The fixed-parameter twin of [`fit_points_2d`], and the difference is the
437/// contract: parameter correction is what makes a free fit's residual honest,
438/// and it is exactly what a *same-parameter* fit must never do, because the
439/// parameters are not a guess to be improved; they are the 3D curve's own,
440/// and drifting them is how a pcurve ends up evaluating away from the curve
441/// it annotates. Here the parameters stay put, refinement adds knots where
442/// the error says, and the reported error is the true same-parameter
443/// deviation in the chart.
444///
445/// # Errors
446///
447/// As [`fit_points_2d`], and the parameters must be strictly increasing and
448/// as many as the points.
449pub fn fit_points_2d_at(
450    parameters: &[f64],
451    points: &[Point2],
452    degree: usize,
453    tolerance: f64,
454    tol: Tolerances,
455) -> OgeomResult<Fitted<BSpline2d>> {
456    fit_points_2d_at_inner(parameters, points, degree, tolerance, false, tol)
457}
458
459/// Fit space points at *fixed* parameters: the caller's `t` values are the
460/// curve's own, which is what keeps a replacement curve same-parameter with
461/// every chart already speaking the old one.
462///
463/// # Errors
464///
465/// As [`fit_points`], and the parameters must be strictly increasing and as
466/// many as the points.
467pub fn fit_points_at(
468    parameters: &[f64],
469    points: &[Point],
470    degree: usize,
471    tolerance: f64,
472    tol: Tolerances,
473) -> OgeomResult<Fitted<BSplineCurve>> {
474    if parameters.len() != points.len() {
475        ogeom_bail!(Construction, "one parameter per point, or the fit is a lie");
476    }
477    if parameters.windows(2).any(|w| w[1] <= w[0]) {
478        ogeom_bail!(Construction, "fixed parameters must strictly increase");
479    }
480    if !tolerance.is_finite() || tolerance <= 0.0 {
481        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
482    }
483    if degree == 0 {
484        ogeom_bail!(Construction, "a fit needs a degree of at least one");
485    }
486    let data: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, p.z]).collect();
487    if data.len() < 2 {
488        ogeom_bail!(Construction, "a fit needs at least two points");
489    }
490    let degree = degree.min(data.len() - 1);
491    let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
492    let mut knots = single_span(degree, a, b)?;
493
494    const ROUNDS: usize = 32;
495    let mut best: Option<(KnotVector, Vec<[f64; 3]>, f64)> = None;
496    for _ in 0..ROUNDS {
497        let control = match least_squares::<3>(&knots, &data, parameters, false) {
498            Ok(control) => control,
499            Err(e) => {
500                if best.is_some() {
501                    break;
502                }
503                return Err(e);
504            }
505        };
506        let errors = residuals::<3>(&knots, &control, &data, parameters);
507        let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
508        if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
509            best = Some((knots.clone(), control.clone(), worst));
510        }
511        if worst <= tolerance {
512            let curve = build_curve_3(knots, control, tol)?;
513            return Ok(Fitted {
514                curve,
515                error: worst,
516                met: true,
517            });
518        }
519        if knots.control_point_count() >= data.len() {
520            break;
521        }
522        let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
523            break;
524        };
525        knots = refined;
526    }
527    let Some((knots, control, worst)) = best else {
528        ogeom_bail!(Construction, "the fit found no usable rounds");
529    };
530    let curve = build_curve_3(knots, control, tol)?;
531    Ok(Fitted {
532        curve,
533        error: worst,
534        met: false,
535    })
536}
537
538fn build_curve_3(
539    knots: KnotVector,
540    control: Vec<[f64; 3]>,
541    tol: Tolerances,
542) -> OgeomResult<BSplineCurve> {
543    BSplineCurve::new(
544        knots,
545        control
546            .into_iter()
547            .map(|c| Point::new(c[0], c[1], c[2]))
548            .collect(),
549        tol,
550    )
551}
552
553/// As [`fit_points_2d_at`], with the loop's join made C1.
554///
555/// The chart image of a closed curve either returns to its first point or,
556/// crossing its surface's seam, to that point one period over; both are the
557/// same loop, and the join constraint holds either way because it speaks
558/// derivatives, not positions.
559///
560/// # Errors
561///
562/// As [`fit_points_2d_at`].
563pub fn fit_points_2d_at_closed(
564    parameters: &[f64],
565    points: &[Point2],
566    degree: usize,
567    tolerance: f64,
568    tol: Tolerances,
569) -> OgeomResult<Fitted<BSpline2d>> {
570    fit_points_2d_at_inner(parameters, points, degree, tolerance, true, tol)
571}
572
573fn fit_points_2d_at_inner(
574    parameters: &[f64],
575    points: &[Point2],
576    degree: usize,
577    tolerance: f64,
578    closed: bool,
579    tol: Tolerances,
580) -> OgeomResult<Fitted<BSpline2d>> {
581    if parameters.len() != points.len() {
582        ogeom_bail!(Construction, "one parameter per point, or the fit is a lie");
583    }
584    if parameters.windows(2).any(|w| w[1] <= w[0]) {
585        ogeom_bail!(Construction, "fixed parameters must strictly increase");
586    }
587    if !tolerance.is_finite() || tolerance <= 0.0 {
588        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
589    }
590    if degree == 0 {
591        ogeom_bail!(Construction, "a fit needs a degree of at least one");
592    }
593    let data: Vec<[f64; 2]> = points.iter().map(|p| [p.x, p.y]).collect();
594    if data.len() < 2 {
595        ogeom_bail!(Construction, "a fit needs at least two points");
596    }
597    let degree = degree.min(data.len() - 1);
598    let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
599    let mut knots = single_span(degree, a, b)?;
600
601    const ROUNDS: usize = 32;
602    let mut best: Option<(KnotVector, Vec<[f64; 2]>, f64)> = None;
603    for _ in 0..ROUNDS {
604        // A refinement can place a knot in a span the fixed parameters never
605        // visit: clustered samples leave the system singular. That kills
606        // the *round*, not the fit: the best earlier round still stands.
607        let control = match least_squares::<2>(&knots, &data, parameters, closed) {
608            Ok(control) => control,
609            Err(e) => {
610                if best.is_some() {
611                    break;
612                }
613                return Err(e);
614            }
615        };
616        let errors = residuals::<2>(&knots, &control, &data, parameters);
617        let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
618        if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
619            best = Some((knots.clone(), control.clone(), worst));
620        }
621        if worst <= tolerance {
622            let curve = BSpline2d::new(
623                knots,
624                control
625                    .into_iter()
626                    .map(|c| Point2::new(c[0], c[1]))
627                    .collect(),
628                tol,
629            )?;
630            return Ok(Fitted {
631                curve,
632                error: worst,
633                met: true,
634            });
635        }
636        if knots.control_point_count() >= data.len() {
637            break;
638        }
639        let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
640            break;
641        };
642        knots = refined;
643    }
644    let (knots, control, error) = best.ok_or_else(|| {
645        ogeom_core::ogeom_err!(Construction, "the fixed-parameter fit never solved")
646    })?;
647    let curve = BSpline2d::new(
648        knots,
649        control
650            .into_iter()
651            .map(|c| Point2::new(c[0], c[1]))
652            .collect(),
653        tol,
654    )?;
655    Ok(Fitted {
656        curve,
657        error,
658        met: false,
659    })
660}
661
662/// The dimension-generic core.
663///
664/// Least squares is solved coordinate by coordinate: the collocation matrix
665/// depends only on the parameters and the knots, so the expensive part is
666/// shared and each coordinate is one more right-hand side.
667#[allow(clippy::type_complexity)]
668/// How a free fit first assigns parameters to its samples, before the
669/// correction rounds move them to the feet.
670#[derive(Clone, Copy, Debug, PartialEq, Eq)]
671enum Spacing {
672    /// The square root of each chord: the general choice, which keeps a
673    /// corner from pulling the parameterization through it.
674    Centripetal,
675    /// Each chord as it is: exact for a straight run however unevenly it
676    /// was sampled, and right for anything traced smoothly. Marched
677    /// sections are sampled by a walk whose step halves to a micron at a
678    /// window's rim and grows back only twofold a point; the centripetal
679    /// guess sits so far from the feet there that two rounds of correction
680    /// never reach them, and a straight section came back twelve hundred
681    /// millimetres off its own line.
682    ChordLength,
683}
684
685fn fit<const D: usize>(
686    points: &[[f64; D]],
687    degree: usize,
688    tolerance: f64,
689    smooth_loop: bool,
690    tol: Tolerances,
691) -> OgeomResult<(KnotVector, Vec<[f64; D]>, f64, bool)> {
692    fit_spaced::<D>(
693        points,
694        degree,
695        tolerance,
696        smooth_loop,
697        Spacing::Centripetal,
698        tol,
699    )
700}
701
702fn fit_spaced<const D: usize>(
703    points: &[[f64; D]],
704    degree: usize,
705    tolerance: f64,
706    smooth_loop: bool,
707    spacing: Spacing,
708    tol: Tolerances,
709) -> OgeomResult<(KnotVector, Vec<[f64; D]>, f64, bool)> {
710    if !tolerance.is_finite() || tolerance <= 0.0 {
711        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
712    }
713    if degree == 0 {
714        ogeom_bail!(Construction, "a fit needs a degree of at least one");
715    }
716    let points = collapse::<D>(points, tol);
717    if points.len() < 2 {
718        ogeom_bail!(
719            Construction,
720            "a fit needs at least two distinct points, got {}",
721            points.len()
722        );
723    }
724    let degree = degree.min(points.len() - 1);
725    // A loop the caller asked to close smoothly: the first point repeated at
726    // the end, and the join constrained to matching *tangents*; a surface
727    // built over a C0 loop shows the crease. Opt-in, because the constraint
728    // spends shape freedom at the join that an open fit keeps.
729    let closed =
730        smooth_loop && distance::<D>(&points[0], &points[points.len() - 1]) <= tol.confusion();
731    let parameters = match spacing {
732        Spacing::Centripetal => centripetal::<D>(&points),
733        Spacing::ChordLength => chord_length::<D>(&points),
734    };
735
736    // Start with the fewest control points a clamped curve of this degree can
737    // have: one Bézier span. Refinement adds knots only where the error says.
738    let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
739    let mut knots = single_span(degree, a, b)?;
740
741    // Each round may split every offending span, so the count grows by at most
742    // a factor of two a round; a cap keeps a pathological input from spinning.
743    const ROUNDS: usize = 32;
744    let mut parameters = parameters;
745    let mut best: Option<(KnotVector, Vec<[f64; D]>, f64)> = None;
746    for _ in 0..ROUNDS {
747        // Parameter correction in an earlier round can slide the data out of
748        // a span that refinement checked *before* the slide, and the solve
749        // reports itself singular. The rounds before it stand: keep the best
750        // of them rather than promoting a bookkeeping casualty to an error.
751        let control = match least_squares::<D>(&knots, &points, &parameters, closed) {
752            Ok(control) => control,
753            Err(e) => {
754                if let Some((knots, control, worst)) = best {
755                    return Ok((knots, control, worst, false));
756                }
757                return Err(e);
758            }
759        };
760        // Parameter correction, and it is not a refinement; it is what makes
761        // the residual mean anything. The residual is measured at each point's
762        // assigned parameter, and the centripetal assignment is a guess: where
763        // it drifts from the curve's own flow, a *perfect* curve still shows an
764        // error at the assigned spot, the loop reads that as the curve's
765        // fault, and refinement adds knots forever against a floor it can
766        // never get under. Projecting each point onto the current curve
767        // (Newton on the foot of the perpendicular) removes the
768        // parameterization's share of the error and leaves the curve's.
769        for _ in 0..2 {
770            correct_parameters::<D>(&knots, &control, &points, &mut parameters, closed);
771        }
772        let mut errors = residuals::<D>(&knots, &control, &points, &parameters);
773        wandering::<D>(&knots, &control, &points, &parameters, &mut errors);
774        let worst = errors.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
775        if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
776            best = Some((knots.clone(), control.clone(), worst));
777        }
778        if worst <= tolerance {
779            return Ok((knots, control, worst, true));
780        }
781
782        // More control points than data points is interpolation wearing a
783        // different name, and past that adding knots buys nothing. A closed
784        // fit spent one control point on the C1 join, so its budget runs one
785        // further.
786        if knots.control_point_count() >= points.len() + usize::from(closed) {
787            break;
788        }
789        let Some(refined) = refined_where_bad(&knots, &errors, tolerance)? else {
790            break;
791        };
792        knots = refined;
793    }
794
795    #[allow(clippy::unwrap_used, reason = "at least one round always runs")]
796    let (knots, control, worst) = best.unwrap();
797    Ok((knots, control, worst, false))
798}
799
800/// Drop consecutive duplicates, which contribute a zero-length chord and make
801/// the parameterization stall.
802fn collapse<const D: usize>(points: &[[f64; D]], tol: Tolerances) -> Vec<[f64; D]> {
803    let mut out: Vec<[f64; D]> = Vec::with_capacity(points.len());
804    for p in points {
805        if out
806            .last()
807            .is_some_and(|q| distance::<D>(p, q) <= tol.confusion() * 0.01)
808        {
809            continue;
810        }
811        out.push(*p);
812    }
813    out
814}
815
816fn distance<const D: usize>(a: &[f64; D], b: &[f64; D]) -> f64 {
817    a.iter()
818        .zip(b)
819        .map(|(x, y)| (x - y) * (x - y))
820        .sum::<f64>()
821        .sqrt()
822}
823
824/// Fit a surface to *scattered* points: no grid required.
825///
826/// The points are parameterized by projection onto the cloud's own
827/// principal plane (the two dominant directions of its covariance), and
828/// fitted by tensor-product least squares with a bending penalty at
829/// `smoothing`, which is what keeps the system solvable where the scatter
830/// leaves basis functions unsupported. The reported error is the honest
831/// maximum distance from any input to the surface at its assigned
832/// parameters.
833///
834/// The cloud must be a *height field* over its principal plane: points
835/// that fold over (a closed shell, a cliff) project onto each other, and
836/// the fit answers with a large reported error rather than a lie.
837///
838/// # Errors
839///
840/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
841/// there are too few points for the control budget, the budget cannot
842/// carry the degree, or the weight is not finite and non-negative.
843pub fn fit_surface_scattered(
844    points: &[Point],
845    degree: usize,
846    controls: (usize, usize),
847    smoothing: f64,
848    tol: Tolerances,
849) -> OgeomResult<Fitted<crate::BSplineSurface>> {
850    use crate::traits::Surface as _;
851    if !smoothing.is_finite() || smoothing < 0.0 {
852        ogeom_bail!(
853            Construction,
854            "a smoothing weight of {smoothing} is not a weight"
855        );
856    }
857    let (nu, nv) = controls;
858    if nu < degree + 1 || nv < degree + 1 {
859        ogeom_bail!(
860            Construction,
861            "{nu}x{nv} control points cannot carry degree {degree}"
862        );
863    }
864    let m = points.len();
865    if m < 4 {
866        ogeom_bail!(Construction, "a scattered fit needs at least four points");
867    }
868
869    // The principal plane: centroid plus the covariance's two dominant
870    // directions.
871    let centroid = {
872        let mut sum = ogeom_math::Vector::ZERO;
873        for p in points {
874            sum += p.to_vector();
875        }
876        sum / precise(m)
877    };
878    let mut covariance = nalgebra::Matrix3::<f64>::zeros();
879    for p in points {
880        let d = p.to_vector() - centroid;
881        let v = nalgebra::Vector3::new(d.x, d.y, d.z);
882        covariance += v * v.transpose();
883    }
884    let eigen = nalgebra::SymmetricEigen::new(covariance);
885    let mut order: Vec<usize> = (0..3).collect();
886    order.sort_by(|a, b| {
887        eigen.eigenvalues[*b]
888            .partial_cmp(&eigen.eigenvalues[*a])
889            .unwrap_or(core::cmp::Ordering::Equal)
890    });
891    let axis = |i: usize| {
892        let c = eigen.eigenvectors.column(order[i]);
893        ogeom_math::Vector::new(c[0], c[1], c[2])
894    };
895    let (u_axis, v_axis) = (axis(0), axis(1));
896
897    // Parameters over the knot domains, from the projected extents.
898    let mut spans = Vec::with_capacity(m);
899    let (mut ulo, mut uhi) = (f64::INFINITY, f64::NEG_INFINITY);
900    let (mut vlo, mut vhi) = (f64::INFINITY, f64::NEG_INFINITY);
901    for p in points {
902        let d = p.to_vector() - centroid;
903        let (pu, pv) = (d.dot(u_axis), d.dot(v_axis));
904        ulo = ulo.min(pu);
905        uhi = uhi.max(pu);
906        vlo = vlo.min(pv);
907        vhi = vhi.max(pv);
908        spans.push((pu, pv));
909    }
910    if uhi - ulo <= tol.confusion() || vhi - vlo <= tol.confusion() {
911        ogeom_bail!(
912            Construction,
913            "the cloud is flat in a principal direction; fit a curve"
914        );
915    }
916    let u_knots = KnotVector::clamped_uniform(degree, nu)?;
917    let v_knots = KnotVector::clamped_uniform(degree, nv)?;
918    let (ka, kb) = (u_knots.domain_start(), u_knots.domain_end());
919    let (la, lb) = (v_knots.domain_start(), v_knots.domain_end());
920    let parameters: Vec<(f64, f64)> = spans
921        .iter()
922        .map(|(pu, pv)| {
923            (
924                ka + (kb - ka) * ((pu - ulo) / (uhi - ulo)),
925                la + (lb - la) * ((pv - vlo) / (vhi - vlo)),
926            )
927        })
928        .collect();
929
930    // Tensor design matrix and the bending penalty in both directions.
931    let n = nu * nv;
932    let mut a = nalgebra::DMatrix::<f64>::zeros(m, n);
933    for (k, &(pu, pv)) in parameters.iter().enumerate() {
934        let uspan = u_knots.span_unchecked(pu);
935        let vspan = v_knots.span_unchecked(pv);
936        let ub = u_knots.basis(uspan, pu);
937        let vb = v_knots.basis(vspan, pv);
938        let ufirst = uspan - degree;
939        let vfirst = vspan - degree;
940        for (j, bv) in vb.iter().enumerate() {
941            for (i, bu) in ub.iter().enumerate() {
942                a[(k, (ufirst + i) * nv + (vfirst + j))] = bu * bv;
943            }
944        }
945    }
946    let mut k_full = a.transpose() * &a;
947    let mut add_penalty = |along_u: bool| {
948        // Second differences along one grid direction, in the grid's own
949        // layout: flat index = i_u · nv + j_v.
950        let (count_a, count_b) = if along_u { (nu, nv) } else { (nv, nu) };
951        for jb in 0..count_b {
952            for ia in 0..count_a.saturating_sub(2) {
953                let base = |offset: usize| -> usize {
954                    if along_u {
955                        (ia + offset) * nv + jb
956                    } else {
957                        jb * nv + ia + offset
958                    }
959                };
960                let idx = [base(0), base(1), base(2)];
961                let w = [1.0, -2.0, 1.0];
962                for x in 0..3 {
963                    for y in 0..3 {
964                        k_full[(idx[x], idx[y])] += smoothing * w[x] * w[y];
965                    }
966                }
967            }
968        }
969    };
970    add_penalty(true);
971    add_penalty(false);
972
973    let decomposition = k_full.clone().lu();
974    let mut control = vec![Point::ORIGIN; n];
975    for axis_i in 0..3 {
976        let b = nalgebra::DVector::from_iterator(m, points.iter().map(|p| [p.x, p.y, p.z][axis_i]));
977        let rhs = a.transpose() * &b;
978        let Some(solved) = decomposition.solve(&rhs) else {
979            ogeom_bail!(
980                Numeric,
981                "the scattered system is singular; raise the smoothing or lower the controls"
982            );
983        };
984        for (slot, value) in solved.iter().enumerate() {
985            match axis_i {
986                0 => control[slot].x = *value,
987                1 => control[slot].y = *value,
988                _ => control[slot].z = *value,
989            }
990        }
991    }
992
993    let grid = ogeom_math::ControlGrid::new(control, nu, nv)?;
994    let surface = crate::BSplineSurface::new(u_knots, v_knots, &grid, tol)?;
995    let mut error = 0.0f64;
996    for (p, &(pu, pv)) in points.iter().zip(&parameters) {
997        error = error.max(surface.point_at(pu, pv, tol)?.distance(*p));
998    }
999    Ok(Fitted {
1000        curve: surface,
1001        error,
1002        met: true,
1003    })
1004}
1005
1006/// Fill the region bounded by four curves with a fitted patch: the
1007/// transfinite Coons blend of the boundaries, sampled and fitted, its error
1008/// reported.
1009///
1010/// The curves must close corner to corner in the order given (`bottom`
1011/// runs with `u`, `top` above it, `left` and `right` with `v`), each
1012/// traversed over its own domain. The patch *interpolates the Coons
1013/// surface's samples* to the stated tolerance; the Coons surface itself
1014/// interpolates the boundaries exactly, so the fit error is the whole
1015/// distance between the returned patch and the boundary it fills.
1016///
1017/// # Errors
1018///
1019/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1020/// corners do not meet within `tol`, plus whatever the fit refuses.
1021pub fn fill_boundary(
1022    bottom: &crate::Curve,
1023    top: &crate::Curve,
1024    left: &crate::Curve,
1025    right: &crate::Curve,
1026    samples: usize,
1027    tolerance: f64,
1028    tol: Tolerances,
1029) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1030    use crate::traits::Curve3d as _;
1031    let samples = samples.max(4);
1032    let at = |curve: &crate::Curve, t: f64| -> OgeomResult<Point> {
1033        let (lo, hi) = curve.domain();
1034        curve.point_at(lo + (hi - lo) * t, tol)
1035    };
1036    // The four corners, each named twice; they must agree.
1037    let c00 = at(bottom, 0.0)?;
1038    let c10 = at(bottom, 1.0)?;
1039    let c01 = at(top, 0.0)?;
1040    let c11 = at(top, 1.0)?;
1041    let slack = tol.confusion() * 1e3;
1042    for (name, a, b) in [
1043        ("bottom-left", c00, at(left, 0.0)?),
1044        ("top-left", c01, at(left, 1.0)?),
1045        ("bottom-right", c10, at(right, 0.0)?),
1046        ("top-right", c11, at(right, 1.0)?),
1047    ] {
1048        if a.distance(b) > slack {
1049            ogeom_bail!(
1050                Construction,
1051                "the {name} corner does not close: the boundaries miss by {}",
1052                a.distance(b)
1053            );
1054        }
1055    }
1056
1057    let mut rows: Vec<Vec<Point>> = Vec::with_capacity(samples);
1058    for j in 0..samples {
1059        let v = precise(j) / precise(samples - 1);
1060        let mut row = Vec::with_capacity(samples);
1061        for i in 0..samples {
1062            let u = precise(i) / precise(samples - 1);
1063            // The bilinearly blended Coons point: ruled in each direction,
1064            // the doubly-ruled corner sheet subtracted once.
1065            let cu0 = at(bottom, u)?;
1066            let cu1 = at(top, u)?;
1067            let d0v = at(left, v)?;
1068            let d1v = at(right, v)?;
1069            let ruled_u = cu0.to_vector() * (1.0 - v) + cu1.to_vector() * v;
1070            let ruled_v = d0v.to_vector() * (1.0 - u) + d1v.to_vector() * u;
1071            let corners = c00.to_vector() * ((1.0 - u) * (1.0 - v))
1072                + c10.to_vector() * (u * (1.0 - v))
1073                + c01.to_vector() * ((1.0 - u) * v)
1074                + c11.to_vector() * (u * v);
1075            row.push(Point::from_vector(ruled_u + ruled_v - corners));
1076        }
1077        rows.push(row);
1078    }
1079    fit_surface_grid(&rows, 3, tolerance, tol)
1080}
1081
1082/// Centripetal parameters over the points, on `[0, 1]`.
1083/// Fit a rectangular grid of points with a tensor-product B-spline surface.
1084///
1085/// The deferred grid fit, kept honest the way the curve fit is: rows first,
1086/// columns second, both passes at *fixed* parameters: a grid's
1087/// parameterization is shared property, and correcting it per row is how a
1088/// grid stops being one. Each pass adapts one shared knot vector against the
1089/// worst residual across its whole family, so every row rides the same
1090/// basis, which is what makes the second pass a fit over control points
1091/// rather than a guess. The reported error is measured at the end, surface
1092/// against every input point, and `met` does not round up.
1093///
1094/// `rows[j][i]` runs `i` along `u` and `j` along `v`.
1095///
1096/// # Errors
1097///
1098/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the grid is
1099/// not rectangular or is smaller than two by two, or the tolerance is not a
1100/// distance.
1101pub fn fit_surface_grid(
1102    rows: &[Vec<Point>],
1103    degree: usize,
1104    tolerance: f64,
1105    tol: Tolerances,
1106) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1107    fit_surface_grid_inner(rows, degree, tolerance, false, false, tol)
1108}
1109
1110/// As [`fit_surface_grid`], parameterized by chord length instead of the
1111/// centripetal assignment, the open counterpart of
1112/// [`fit_surface_grid_closed_v_chordal`], for a marched band that stops at
1113/// its run-out instead of closing on itself.
1114///
1115/// # Errors
1116///
1117/// As [`fit_surface_grid`].
1118pub fn fit_surface_grid_chordal(
1119    rows: &[Vec<Point>],
1120    degree: usize,
1121    tolerance: f64,
1122    tol: Tolerances,
1123) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1124    fit_surface_grid_inner(rows, degree, tolerance, false, true, tol)
1125}
1126
1127/// As [`fit_surface_grid`], with the `v` direction closed into a smooth loop.
1128///
1129/// The rows must form a loop (the first row repeated at the end), and the
1130/// join across it is made C1 the way [`fit_points_closed`] makes a curve's:
1131/// the constraint is eliminated inside the shared solve, so the two border
1132/// control rows are equal and the loop crosses its own seam smoothly.
1133///
1134/// # Errors
1135///
1136/// As [`fit_surface_grid`], and additionally if the rows are not a loop.
1137pub fn fit_surface_grid_closed_v(
1138    rows: &[Vec<Point>],
1139    degree: usize,
1140    tolerance: f64,
1141    tol: Tolerances,
1142) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1143    if rows.len() < 3 {
1144        ogeom_bail!(Construction, "a closed skin needs at least three rows");
1145    }
1146    let (first, last) = (&rows[0], &rows[rows.len() - 1]);
1147    if first.len() != last.len()
1148        || first
1149            .iter()
1150            .zip(last)
1151            .any(|(a, b)| a.distance(*b) > tol.confusion() * 100.0)
1152    {
1153        ogeom_bail!(
1154            Construction,
1155            "a closed skin needs a loop: the first row repeated at the end"
1156        );
1157    }
1158    fit_surface_grid_inner(rows, degree, tolerance, true, false, tol)
1159}
1160
1161/// As [`fit_surface_grid_closed_v`], parameterized by chord length instead
1162/// of the centripetal assignment.
1163///
1164/// The choice matters for dense data: a marched grid samples smooth curves
1165/// finely but not evenly, and chord length keeps the parameter's speed
1166/// uniform across the spacing jumps a march's closure leaves behind.
1167///
1168/// # Errors
1169///
1170/// As [`fit_surface_grid_closed_v`].
1171pub fn fit_surface_grid_closed_v_chordal(
1172    rows: &[Vec<Point>],
1173    degree: usize,
1174    tolerance: f64,
1175    tol: Tolerances,
1176) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1177    if rows.len() < 3 {
1178        ogeom_bail!(Construction, "a closed skin needs at least three rows");
1179    }
1180    let (first, last) = (&rows[0], &rows[rows.len() - 1]);
1181    if first.len() != last.len()
1182        || first
1183            .iter()
1184            .zip(last)
1185            .any(|(a, b)| a.distance(*b) > tol.confusion() * 100.0)
1186    {
1187        ogeom_bail!(
1188            Construction,
1189            "a closed skin needs a loop: the first row repeated at the end"
1190        );
1191    }
1192    fit_surface_grid_inner(rows, degree, tolerance, true, true, tol)
1193}
1194
1195fn fit_surface_grid_inner(
1196    rows: &[Vec<Point>],
1197    degree: usize,
1198    tolerance: f64,
1199    closed_v: bool,
1200    by_chord: bool,
1201    tol: Tolerances,
1202) -> OgeomResult<Fitted<crate::BSplineSurface>> {
1203    use crate::traits::Surface as _;
1204    if !tolerance.is_finite() || tolerance <= 0.0 {
1205        ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
1206    }
1207    let nv = rows.len();
1208    if nv < 2 {
1209        ogeom_bail!(Construction, "a surface fit needs at least two rows");
1210    }
1211    let nu = rows[0].len();
1212    if nu < 2 || rows.iter().any(|r| r.len() != nu) {
1213        ogeom_bail!(Construction, "a surface fit needs a rectangular grid");
1214    }
1215    let raw: Vec<Vec<[f64; 3]>> = rows
1216        .iter()
1217        .map(|r| r.iter().map(|p| [p.x, p.y, p.z]).collect())
1218        .collect();
1219
1220    // Averaged parameters: one shared assignment per direction. A family
1221    // with no extent (a row collapsed to a single point, the apex a skin
1222    // narrows to) has no parameterization opinion: its zero chords assign
1223    // [0, …, 0, 1], and averaging that in compresses everyone else's
1224    // parameters toward the start and sends the fitted border curves on an
1225    // oscillating sprint over the tail. It rides the others' parameters
1226    // instead; a constant row fits exactly at any assignment.
1227    let average = |families: &[Vec<[f64; 3]>]| -> Vec<f64> {
1228        let mut sums = vec![0.0; families[0].len()];
1229        let mut counted = 0.0_f64;
1230        for family in families {
1231            let extent: f64 = family
1232                .windows(2)
1233                .map(|pair| distance::<3>(&pair[0], &pair[1]))
1234                .sum();
1235            if extent <= 0.0 {
1236                continue;
1237            }
1238            counted += 1.0;
1239            let assigned = if by_chord {
1240                chordal::<3>(family)
1241            } else {
1242                centripetal::<3>(family)
1243            };
1244            for (s, p) in sums.iter_mut().zip(assigned) {
1245                *s += p;
1246            }
1247        }
1248        if counted == 0.0 {
1249            // Every family degenerate: uniform is the only honest assignment.
1250            let n = sums.len();
1251            return (0..n)
1252                .map(|i| {
1253                    #[allow(clippy::cast_precision_loss)]
1254                    let t = i as f64 / (n - 1).max(1) as f64;
1255                    t
1256                })
1257                .collect();
1258        }
1259        sums.iter().map(|s| s / counted).collect()
1260    };
1261    let u_params = average(&raw);
1262    let columns: Vec<Vec<[f64; 3]>> = (0..nu)
1263        .map(|i| raw.iter().map(|r| r[i]).collect())
1264        .collect();
1265    let v_params = average(&columns);
1266
1267    // Pass one: every row on one shared knot vector.
1268    let (u_knots, row_controls) = fit_family::<3>(&raw, &u_params, degree, tolerance * 0.5, false)?;
1269    // Pass two: the columns of control points, against the v parameters.
1270    let k = u_knots.control_point_count();
1271    let control_columns: Vec<Vec<[f64; 3]>> = (0..k)
1272        .map(|i| row_controls.iter().map(|r| r[i]).collect())
1273        .collect();
1274    let (v_knots, column_controls) = fit_family::<3>(
1275        &control_columns,
1276        &v_params,
1277        degree,
1278        tolerance * 0.5,
1279        closed_v,
1280    )?;
1281    let l = v_knots.control_point_count();
1282
1283    // Assemble: `column_controls[i][j]` is the control at u-index i,
1284    // v-index j; the grid is row-major in u.
1285    let mut net: Vec<Point> = Vec::with_capacity(k * l);
1286    for column in &column_controls {
1287        for c in column {
1288            net.push(Point::new(c[0], c[1], c[2]));
1289        }
1290    }
1291    let grid = ogeom_math::ControlGrid::new(net, k, l)?;
1292    let surface = crate::BSplineSurface::new(u_knots, v_knots, &grid, tol)?;
1293
1294    // The honest error: the surface against every input point, at the
1295    // grid's own parameters.
1296    let mut worst = 0.0_f64;
1297    for (j, row) in rows.iter().enumerate() {
1298        for (i, p) in row.iter().enumerate() {
1299            let at = surface.point_at(u_params[i], v_params[j], tol)?;
1300            worst = worst.max(at.distance(*p));
1301        }
1302    }
1303    Ok(Fitted {
1304        curve: surface,
1305        error: worst,
1306        met: worst <= tolerance,
1307    })
1308}
1309
1310/// Fit a family of point rows sharing parameters onto one knot vector,
1311/// refined against the worst residual across the whole family, parameters
1312/// held fixed.
1313/// The best round a family fit reached: knots, one control row per member,
1314/// and the worst residual.
1315type FamilyRound<const D: usize> = (KnotVector, Vec<Vec<[f64; D]>>, f64);
1316
1317fn fit_family<const D: usize>(
1318    family: &[Vec<[f64; D]>],
1319    parameters: &[f64],
1320    degree: usize,
1321    tolerance: f64,
1322    closed: bool,
1323) -> OgeomResult<(KnotVector, Vec<Vec<[f64; D]>>)> {
1324    let degree = degree.min(parameters.len() - 1).max(1);
1325    let (a, b) = (parameters[0], parameters[parameters.len() - 1]);
1326    let mut knots = single_span(degree, a, b)?;
1327    const ROUNDS: usize = 24;
1328    let mut best: Option<FamilyRound<D>> = None;
1329    for _ in 0..ROUNDS {
1330        let mut controls = Vec::with_capacity(family.len());
1331        let mut merged: Vec<(f64, f64)> = parameters.iter().map(|u| (*u, 0.0)).collect();
1332        let mut solvable = true;
1333        for row in family {
1334            match least_squares::<D>(&knots, row, parameters, closed) {
1335                Ok(control) => {
1336                    for (slot, entry) in residuals::<D>(&knots, &control, row, parameters)
1337                        .iter()
1338                        .zip(merged.iter_mut())
1339                    {
1340                        entry.1 = entry.1.max(slot.1);
1341                    }
1342                    controls.push(control);
1343                }
1344                Err(_) => {
1345                    solvable = false;
1346                    break;
1347                }
1348            }
1349        }
1350        if !solvable {
1351            break;
1352        }
1353        let worst = merged.iter().fold(0.0_f64, |acc, e| acc.max(e.1));
1354        if best.as_ref().is_none_or(|(_, _, held)| worst < *held) {
1355            best = Some((knots.clone(), controls, worst));
1356        }
1357        if worst <= tolerance
1358            || knots.control_point_count() >= parameters.len() + usize::from(closed)
1359        {
1360            break;
1361        }
1362        let Some(refined) = refined_where_bad(&knots, &merged, tolerance)? else {
1363            break;
1364        };
1365        knots = refined;
1366    }
1367    let Some((knots, controls, _)) = best else {
1368        ogeom_bail!(NotDone, "the family fit solved no round at all");
1369    };
1370    Ok((knots, controls))
1371}
1372
1373/// Chord-length parameters, normalized to `[0, 1]`.
1374///
1375/// For densely sampled points of a smooth curve, chord length approximates
1376/// arc length whatever the sampling density does, so the parameter-to-point
1377/// map keeps a uniform speed across spacing jumps, exactly where the
1378/// centripetal assignment would fold a spacing jump into a speed kink.
1379fn chordal<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1380    let mut out = Vec::with_capacity(points.len());
1381    out.push(0.0);
1382    let mut total = 0.0;
1383    for pair in points.windows(2) {
1384        total += distance::<D>(&pair[0], &pair[1]);
1385        out.push(total);
1386    }
1387    if total > 0.0 {
1388        for u in &mut out {
1389            *u /= total;
1390        }
1391    }
1392    if let Some(last) = out.last_mut() {
1393        *last = 1.0;
1394    }
1395    out
1396}
1397
1398/// Parameters proportional to the running chord length, ending exactly at
1399/// one.
1400fn chord_length<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1401    let mut out = Vec::with_capacity(points.len());
1402    out.push(0.0);
1403    let mut total = 0.0;
1404    for pair in points.windows(2) {
1405        total += distance::<D>(&pair[0], &pair[1]);
1406        out.push(total);
1407    }
1408    if total > 0.0 {
1409        for u in &mut out {
1410            *u /= total;
1411        }
1412    }
1413    if let Some(last) = out.last_mut() {
1414        *last = 1.0;
1415    }
1416    out
1417}
1418
1419fn centripetal<const D: usize>(points: &[[f64; D]]) -> Vec<f64> {
1420    let mut out = Vec::with_capacity(points.len());
1421    out.push(0.0);
1422    let mut total = 0.0;
1423    for pair in points.windows(2) {
1424        total += distance::<D>(&pair[0], &pair[1]).sqrt();
1425        out.push(total);
1426    }
1427    if total > 0.0 {
1428        for u in &mut out {
1429            *u /= total;
1430        }
1431    }
1432    // Exactness at the far end matters: the last parameter must be the domain
1433    // end, not a rounding neighbour of it.
1434    if let Some(last) = out.last_mut() {
1435        *last = 1.0;
1436    }
1437    out
1438}
1439
1440/// A clamped knot vector with a single span.
1441fn single_span(degree: usize, a: f64, b: f64) -> OgeomResult<KnotVector> {
1442    let mut knots = Vec::with_capacity(2 * (degree + 1));
1443    knots.extend(core::iter::repeat_n(a, degree + 1));
1444    knots.extend(core::iter::repeat_n(b, degree + 1));
1445    KnotVector::new(knots, degree)
1446}
1447
1448/// Least-squares control points for a fixed knot vector.
1449///
1450/// The ends are pinned to the first and last data points (they are where the
1451/// curve joins its neighbours), and the interior is solved. With no interior
1452/// there is nothing to solve and the pinned Bézier is the answer.
1453fn least_squares<const D: usize>(
1454    knots: &KnotVector,
1455    points: &[[f64; D]],
1456    parameters: &[f64],
1457    closed: bool,
1458) -> OgeomResult<Vec<[f64; D]>> {
1459    let n = knots.control_point_count();
1460    let m = points.len();
1461    let degree = knots.degree();
1462
1463    let mut control = vec![[0.0; D]; n];
1464    control[0] = points[0];
1465    control[n - 1] = points[m - 1];
1466    if n <= 2 {
1467        return Ok(control);
1468    }
1469
1470    // C1 across a closed loop's join: the clamped end derivatives are
1471    // degree/(t_{p+1}-t_1) * (P_1 - P_0) and
1472    // degree/(t_{n+p-1}-t_{n-1}) * (P_{n-1} - P_{n-2}). Setting them equal
1473    // makes P_{n-2} = P_{n-1} - r (P_1 - P_0): one unknown eliminated, the
1474    // constraint exact in the solve rather than patched on after. Both ends
1475    // are pinned, so this holds whether they coincide or sit one period
1476    // apart, as a chart image crossing its surface's seam does.
1477    let t = knots.knots();
1478    let ratio = if closed && n >= 4 {
1479        let d_start = t[degree + 1] - t[1];
1480        let d_end = t[n + degree - 1] - t[n - 1];
1481        (d_start > 0.0 && d_end > 0.0).then(|| d_end / d_start)
1482    } else {
1483        None
1484    };
1485    let eliminated = ratio.map(|_| n - 2);
1486    let unknown_count = match eliminated {
1487        Some(_) => n - 3,
1488        None => n - 2,
1489    };
1490    if unknown_count == 0 {
1491        if let (Some(r), Some(e)) = (ratio, eliminated) {
1492            // Only P_1 = P_{n-2} remains, and the constraint alone fixes it:
1493            // P_{n-2} = P_{n-1} - r (P_1 - P_0) with P_1 = P_{n-2} gives the
1494            // point dividing between the pinned ends.
1495            for d in 0..D {
1496                control[e][d] = r.mul_add(points[0][d], points[m - 1][d]) / (1.0 + r);
1497            }
1498            let _ = r;
1499        }
1500        return Ok(control);
1501    }
1502
1503    // The collocation rows, with the pinned ends moved to the right-hand side.
1504    let mut normal = nalgebra::DMatrix::<f64>::zeros(unknown_count, unknown_count);
1505    let mut rhs = vec![nalgebra::DVector::<f64>::zeros(unknown_count); D];
1506
1507    let mut rows: Vec<(usize, Vec<(usize, f64)>)> = Vec::with_capacity(m);
1508    for (k, &u) in parameters.iter().enumerate() {
1509        let span = knots.span_unchecked(u);
1510        let basis = knots.basis(span, u);
1511        let first = span - degree;
1512        let mut row: Vec<(usize, f64)> = basis
1513            .iter()
1514            .enumerate()
1515            .map(|(j, b)| (first + j, *b))
1516            .collect();
1517        // Substitute the eliminated column: b_e P_e with
1518        // P_e = P_{n-1} + r P_0 - r P_1 becomes known shares on the pinned
1519        // ends and a coefficient of -r b_e on P_1.
1520        if let (Some(r), Some(e)) = (ratio, eliminated)
1521            && let Some(position) = row.iter().position(|(i, _)| *i == e)
1522        {
1523            let (_, b_e) = row.remove(position);
1524            row.push((usize::MAX, b_e * r));
1525            row.push((n - 1, b_e));
1526            row.push((1, -r * b_e));
1527        }
1528        rows.push((k, row));
1529    }
1530
1531    for (k, row) in &rows {
1532        // The residual this row wants to explain, after the pinned ends. The
1533        // sentinel usize::MAX marks the eliminated column's share of P_0.
1534        let mut target = points[*k];
1535        for (index, b) in row {
1536            if *index == 0 || *index == usize::MAX {
1537                for d in 0..D {
1538                    target[d] -= b * points[0][d];
1539                }
1540            } else if *index == n - 1 {
1541                for d in 0..D {
1542                    target[d] -= b * points[m - 1][d];
1543                }
1544            }
1545        }
1546        let is_known = |i: usize| i == 0 || i == n - 1 || i == usize::MAX;
1547        for (i, bi) in row {
1548            if is_known(*i) {
1549                continue;
1550            }
1551            for (j, bj) in row {
1552                if is_known(*j) {
1553                    continue;
1554                }
1555                normal[(i - 1, j - 1)] += bi * bj;
1556            }
1557            for d in 0..D {
1558                rhs[d][i - 1] += bi * target[d];
1559            }
1560        }
1561    }
1562
1563    // The normal matrix can be singular when a span has no parameter in it:
1564    // a knot was placed where there is no data to say where the curve goes.
1565    let Some(inverted) = normal.clone().try_inverse() else {
1566        ogeom_bail!(
1567            NotDone,
1568            "the fitting system is singular: a knot span contains no data"
1569        );
1570    };
1571    for d in 0..D {
1572        let solved = &inverted * &rhs[d];
1573        for i in 0..unknown_count {
1574            control[i + 1][d] = solved[i];
1575        }
1576    }
1577    if let (Some(r), Some(e)) = (ratio, eliminated) {
1578        for d in 0..D {
1579            control[e][d] = points[m - 1][d] + r * (points[0][d] - control[1][d]);
1580        }
1581    }
1582    Ok(control)
1583}
1584
1585/// Move each parameter to the foot of the perpendicular from its point.
1586///
1587/// One Newton step per call on `g(u) = (C(u) - Q) · C'(u) = 0`. The ends stay
1588/// pinned: they are where the curve joins its neighbours, and letting them
1589/// slide would trade end accuracy for interior accuracy silently.
1590fn correct_parameters<const D: usize>(
1591    knots: &KnotVector,
1592    control: &[[f64; D]],
1593    points: &[[f64; D]],
1594    parameters: &mut [f64],
1595    looped: bool,
1596) {
1597    let (lo, hi) = knots.domain();
1598    let last = parameters.len() - 1;
1599    // On a loop, a trust region of a few sample spacings. Newton on a curve
1600    // that is still poor (the early rounds of a fit) can project a point
1601    // to the far side of the domain, and a loop's join makes that a cliff:
1602    // both ends of the domain are the same place in space, one wild foot
1603    // lands at the wrong end, and the monotonicity repair below drags every
1604    // parameter after it onto it. An open curve has no such identification,
1605    // and clamping its corrections only slows the endgame.
1606    #[allow(clippy::cast_precision_loss)]
1607    let max_step = if looped {
1608        (hi - lo) * 8.0 / parameters.len() as f64
1609    } else {
1610        hi - lo
1611    };
1612    for (k, u) in parameters.iter_mut().enumerate() {
1613        if k == 0 || k == last {
1614            continue;
1615        }
1616        let (at, d1, d2) = evaluate::<D>(knots, control, *u);
1617        let gap: [f64; D] = core::array::from_fn(|d| at[d] - points[k][d]);
1618        let dot = |a: &[f64; D], b: &[f64; D]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
1619        let numerator = dot(&gap, &d1);
1620        let denominator = dot(&d1, &d1) + dot(&gap, &d2);
1621        if denominator.abs() <= f64::MIN_POSITIVE {
1622            continue;
1623        }
1624        let stepped = *u - (numerator / denominator).clamp(-max_step, max_step);
1625        if stepped.is_finite() {
1626            *u = stepped.clamp(lo, hi);
1627        }
1628    }
1629    // Projection can reorder neighbours near a tight turn; the fit assumes the
1630    // parameters walk forward with the points.
1631    for k in 1..parameters.len() {
1632        if parameters[k] < parameters[k - 1] {
1633            parameters[k] = parameters[k - 1];
1634        }
1635    }
1636}
1637
1638/// A curve point and its first two derivatives, from raw knots and control.
1639fn evaluate<const D: usize>(
1640    knots: &KnotVector,
1641    control: &[[f64; D]],
1642    u: f64,
1643) -> ([f64; D], [f64; D], [f64; D]) {
1644    let degree = knots.degree();
1645    let span = knots.span_unchecked(u);
1646    let table = knots.basis_derivatives(span, u, 2);
1647    let first = span - degree;
1648    let mut out = [[0.0; D]; 3];
1649    for (order, row) in table.iter().enumerate().take(3) {
1650        for (j, b) in row.iter().enumerate() {
1651            for d in 0..D {
1652                out[order][d] += b * control[first + j][d];
1653            }
1654        }
1655    }
1656    (out[0], out[1], out[2])
1657}
1658
1659/// The distance from each point to the curve at its parameter.
1660fn residuals<const D: usize>(
1661    knots: &KnotVector,
1662    control: &[[f64; D]],
1663    points: &[[f64; D]],
1664    parameters: &[f64],
1665) -> Vec<(f64, f64)> {
1666    let degree = knots.degree();
1667    parameters
1668        .iter()
1669        .zip(points)
1670        .map(|(&u, p)| {
1671            let span = knots.span_unchecked(u);
1672            let basis = knots.basis(span, u);
1673            let first = span - degree;
1674            let mut at = [0.0; D];
1675            for (j, b) in basis.iter().enumerate() {
1676                for d in 0..D {
1677                    at[d] += b * control[first + j][d];
1678                }
1679            }
1680            (u, distance::<D>(&at, p))
1681        })
1682        .collect()
1683}
1684
1685/// How far the curve strays between samples, charged to the samples.
1686///
1687/// Correction lets every point find its own foot, so a curve that loops
1688/// away between two samples and comes back for each still meets every
1689/// point: a trace of fifty millimetres read within tolerance as a curve of
1690/// three metres. Midway between two samples' parameters the curve lies
1691/// within half their chord of the chord's middle, or the excess is the
1692/// fit's error at both samples, where refinement can act on it.
1693fn wandering<const D: usize>(
1694    knots: &KnotVector,
1695    control: &[[f64; D]],
1696    points: &[[f64; D]],
1697    parameters: &[f64],
1698    errors: &mut [(f64, f64)],
1699) {
1700    for k in 0..parameters.len().saturating_sub(1) {
1701        let middle = f64::midpoint(parameters[k], parameters[k + 1]);
1702        let (at, _, _) = evaluate::<D>(knots, control, middle);
1703        let chord: [f64; D] =
1704            core::array::from_fn(|d| f64::midpoint(points[k][d], points[k + 1][d]));
1705        let excess = distance::<D>(&at, &chord) - distance::<D>(&points[k], &points[k + 1]) / 2.0;
1706        if excess > 0.0 {
1707            errors[k].1 = errors[k].1.max(excess);
1708            errors[k + 1].1 = errors[k + 1].1.max(excess);
1709        }
1710    }
1711}
1712
1713/// The knot vector with every offending span split.
1714///
1715/// Split at the *median parameter* inside the span, not its geometric middle.
1716/// The middle is where a textbook puts it and it stalls in practice: when the
1717/// data crowds into one half of a bad span, a middle knot leaves the other
1718/// half empty, an empty half makes the least-squares system singular, and the
1719/// span can never be refined again; the fit then converges to just above the
1720/// target and sticks there. The median always leaves data on both sides.
1721///
1722/// `None` when no span can be split further: every bad span holds fewer than
1723/// two parameters, and a knot needs data on both sides to be supported.
1724fn refined_where_bad(
1725    knots: &KnotVector,
1726    errors: &[(f64, f64)],
1727    tolerance: f64,
1728) -> OgeomResult<Option<KnotVector>> {
1729    let distinct = knots.distinct();
1730    let mut refined = knots.clone();
1731    let mut changed = false;
1732    for window in distinct.windows(2) {
1733        let (lo, hi) = (window[0].0, window[1].0);
1734        let inside: Vec<f64> = errors
1735            .iter()
1736            .filter(|(u, _)| *u >= lo && *u < hi)
1737            .map(|(u, _)| *u)
1738            .collect();
1739        let bad = errors
1740            .iter()
1741            .any(|(u, e)| *u >= lo && *u < hi && *e > tolerance);
1742        if !bad || inside.len() < 2 {
1743            continue;
1744        }
1745        // The knot between the two middle parameters, kept strictly interior.
1746        let at = f64::midpoint(inside[inside.len() / 2 - 1], inside[inside.len() / 2])
1747            .clamp(lo + (hi - lo) * 1e-6, hi - (hi - lo) * 1e-6);
1748        // Both sides must keep data, or the new span is unsupported.
1749        let left = inside.iter().any(|u| *u < at);
1750        let right = inside.iter().any(|u| *u >= at);
1751        if left && right {
1752            refined = refined.with_knot_inserted(at, 1)?;
1753            changed = true;
1754        }
1755    }
1756    Ok(if changed { Some(refined) } else { None })
1757}
1758
1759#[cfg(test)]
1760#[allow(clippy::unwrap_used)]
1761mod grid_tests {
1762    use super::*;
1763    use crate::traits::Surface as _;
1764    use ogeom_core::Tolerances;
1765
1766    const T: Tolerances = Tolerances::millimetres();
1767
1768    #[test]
1769    fn a_torus_patch_grid_fits_to_tolerance_on_and_off_the_grid() {
1770        let torus = ogeom_math::Torus::new(ogeom_math::Frame::WORLD, 2.0, 0.5, T).unwrap();
1771        let surface = crate::TorusSurface::new(torus);
1772        let (nu, nv) = (25, 17);
1773        let span_u = 1.2_f64;
1774        let span_v = 0.9_f64;
1775        let sample = |fu: f64, fv: f64| surface.point_at(span_u * fu, span_v * fv, T).unwrap();
1776        let mut rows = Vec::new();
1777        for j in 0..nv {
1778            let mut row = Vec::new();
1779            for i in 0..nu {
1780                row.push(sample(
1781                    f64::from(i) / f64::from(nu - 1),
1782                    f64::from(j) / f64::from(nv - 1),
1783                ));
1784            }
1785            rows.push(row);
1786        }
1787        let fitted = fit_surface_grid(&rows, 3, 1e-4, T).unwrap();
1788        assert!(fitted.met, "error {} above the target", fitted.error);
1789
1790        // Off the grid too: the fit describes the surface, not just its
1791        // samples. The fitted chart and the torus's differ, so compare by
1792        // distance to the true surface rather than at matched parameters.
1793        let (ud, vd) = fitted.curve.domain();
1794        for i in 0..8 {
1795            for j in 0..8 {
1796                let u = ud.0 + (ud.1 - ud.0) * (0.07 + 0.9 * f64::from(i) / 7.0);
1797                let v = vd.0 + (vd.1 - vd.0) * (0.07 + 0.9 * f64::from(j) / 7.0);
1798                let p = fitted.curve.point_at(u, v, T).unwrap();
1799                let d = torus.distance_to(p);
1800                assert!(d < 5e-4, "off-grid deviation {d} at ({u}, {v})");
1801            }
1802        }
1803    }
1804
1805    #[test]
1806    fn a_grid_the_basis_can_represent_fits_to_rounding() {
1807        // Points from a bilinear patch: degree one in both directions.
1808        let corner = |x: f64, y: f64| Point::new(x, y, 0.3 * x - 0.2 * y);
1809        let mut rows = Vec::new();
1810        for j in 0..6 {
1811            let mut row = Vec::new();
1812            for i in 0..6 {
1813                row.push(corner(f64::from(i), 2.0 * f64::from(j)));
1814            }
1815            rows.push(row);
1816        }
1817        let fitted = fit_surface_grid(&rows, 1, 1e-9, T).unwrap();
1818        assert!(fitted.met, "error {} above rounding", fitted.error);
1819    }
1820
1821    #[test]
1822    fn a_ragged_grid_is_refused() {
1823        let rows = vec![
1824            vec![Point::ORIGIN, Point::new(1.0, 0.0, 0.0)],
1825            vec![Point::new(0.0, 1.0, 0.0)],
1826        ];
1827        assert!(fit_surface_grid(&rows, 2, 1e-6, T).is_err());
1828    }
1829}
1830
1831#[cfg(test)]
1832#[allow(clippy::unwrap_used)]
1833mod tests {
1834
1835    #[test]
1836    fn a_v_closed_grid_fits_a_ring_with_a_smooth_join() {
1837        use crate::traits::Surface as _;
1838        // A torus sampled as a loop of rows: each row a tube circle, the
1839        // rows running the major circle round and back to the start.
1840        let tau = core::f64::consts::TAU;
1841        let (major, minor) = (5.0_f64, 1.5_f64);
1842        let rows: Vec<Vec<Point>> = (0..=24)
1843            .map(|j| {
1844                let a = tau * f64::from(j) / 24.0;
1845                (0..=16)
1846                    .map(|i| {
1847                        let b = tau * f64::from(i) / 16.0;
1848                        let r = minor.mul_add(b.cos(), major);
1849                        Point::new(r * a.cos(), r * a.sin(), minor * b.sin())
1850                    })
1851                    .collect()
1852            })
1853            .collect();
1854        let fitted = fit_surface_grid_closed_v(&rows, 3, 5e-3, T).unwrap();
1855        assert!(fitted.met, "the ring fit should meet its tolerance");
1856        let surface = fitted.curve;
1857
1858        // Exact closure: the two border control rows are equal, so the loop
1859        // crosses its seam with nothing to weld.
1860        let grid = surface.grid();
1861        let (k, l) = (grid.u_count(), grid.v_count());
1862        for i in 0..k {
1863            let first = grid.points()[i * l].point();
1864            let last = grid.points()[i * l + (l - 1)].point();
1865            assert!(
1866                first.distance(last) < 1e-12,
1867                "border control rows differ at u-index {i}"
1868            );
1869        }
1870
1871        // And smoothly: the v-derivative agrees across the join.
1872        let (u_dom, v_dom) = surface.domain();
1873        for i in 0..5 {
1874            let u = u_dom.0 + (u_dom.1 - u_dom.0) * f64::from(i) / 4.0;
1875            let (_, dv0) = surface.d1_at(u, v_dom.0, T).unwrap();
1876            let (_, dv1) = surface.d1_at(u, v_dom.1, T).unwrap();
1877            let gap = (dv0 / dv0.magnitude() - dv1 / dv1.magnitude()).magnitude();
1878            assert!(gap < 1e-6, "the join kinks at u = {u}: {gap}");
1879        }
1880    }
1881
1882    #[test]
1883    fn a_grid_that_is_not_a_loop_is_refused_by_the_closed_entry() {
1884        let rows: Vec<Vec<Point>> = (0..4)
1885            .map(|j| {
1886                (0..4)
1887                    .map(|i| Point::new(f64::from(i), f64::from(j), 0.0))
1888                    .collect()
1889            })
1890            .collect();
1891        assert!(fit_surface_grid_closed_v(&rows, 3, 1e-3, T).is_err());
1892    }
1893
1894    use super::*;
1895    use crate::traits::{Curve2d as _, Curve3d as _};
1896    use core::f64::consts::TAU;
1897
1898    /// A cubic through two samples at its ends that bulges ten units off
1899    /// the chord between them meets both samples exactly, and strays by
1900    /// its bulge less half the chord: charged to both.
1901    #[test]
1902    fn a_curve_straying_between_its_samples_is_charged_for_it() {
1903        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
1904        let control = [[0.0, 0.0], [0.0, 10.0], [1.0, 10.0], [1.0, 0.0]];
1905        let points = [[0.0, 0.0], [1.0, 0.0]];
1906        let parameters = [0.0, 1.0];
1907        let mut errors = residuals::<2>(&knots, &control, &points, &parameters);
1908        assert!(errors.iter().all(|e| e.1 < 1e-12));
1909        wandering::<2>(&knots, &control, &points, &parameters, &mut errors);
1910        for e in &errors {
1911            assert!((e.1 - 7.0).abs() < 1e-12, "{e:?}");
1912        }
1913    }
1914
1915    #[test]
1916    fn scattered_points_fit_without_a_grid() {
1917        // A paraboloid sampled at pseudo-random spots (a deterministic
1918        // congruential walk, no randomness in the test) fits within a
1919        // stated error.
1920        let mut state = 12345u64;
1921        let mut next = || {
1922            state = state
1923                .wrapping_mul(6364136223846793005)
1924                .wrapping_add(1442695040888963407);
1925            {
1926                #[allow(clippy::cast_precision_loss, reason = "31 bits fit exactly")]
1927                let r = (state >> 33) as f64;
1928                r / f64::from(u32::MAX) * 2.0 - 1.0
1929            }
1930        };
1931        let points: Vec<Point> = (0..200)
1932            .map(|_| {
1933                let (x, y) = (next() * 5.0, next() * 5.0);
1934                Point::new(x, y, 0.2 * (x * x + y * y))
1935            })
1936            .collect();
1937        let fitted = fit_surface_scattered(&points, 3, (8, 8), 1e-3, T).unwrap();
1938        assert!(
1939            fitted.error < 0.05,
1940            "the paraboloid fits to {}",
1941            fitted.error
1942        );
1943    }
1944
1945    #[test]
1946    fn fairing_trades_closeness_for_straightness_and_says_the_price() {
1947        // Points on a line, kicked alternately off it: the pure fit chases
1948        // the noise, the faired one lays a batten through it.
1949        let points: Vec<Point> = (0..15)
1950            .map(|i| {
1951                let t = f64::from(i);
1952                let kick = if i % 2 == 0 { 0.1 } else { -0.1 };
1953                Point::new(t, t + kick, 0.0)
1954            })
1955            .collect();
1956        let chased = fit_points_faired(&points, 3, 10, 0.0, T).unwrap();
1957        let faired = fit_points_faired(&points, 3, 10, 50.0, T).unwrap();
1958
1959        // Bending energy of the control polygon: fairing must lower it.
1960        let bend = |c: &BSplineCurve| -> f64 {
1961            let pts = c.control_points();
1962            (1..pts.len() - 1)
1963                .map(|i| {
1964                    let p0 = pts[i - 1].point();
1965                    let p1 = pts[i].point();
1966                    let p2 = pts[i + 1].point();
1967                    ((p2 - p1) - (p1 - p0)).magnitude().powi(2)
1968                })
1969                .sum()
1970        };
1971        assert!(
1972            bend(&faired.curve) < bend(&chased.curve) / 4.0,
1973            "fairing must straighten: {} vs {}",
1974            bend(&faired.curve),
1975            bend(&chased.curve)
1976        );
1977        // The price is stated: the faired error is larger (it includes the
1978        // tangential slip a strong weight causes), while the *geometric*
1979        // deviation from the underlying line stays inside the noise band.
1980        assert!(faired.error >= chased.error);
1981        use crate::traits::Curve3d as _;
1982        let (lo, hi) = faired.curve.domain();
1983        for i in 0..=32 {
1984            let u = lo + (hi - lo) * f64::from(i) / 32.0;
1985            let p = faired.curve.point_at(u, T).unwrap();
1986            let off_line = (p.y - p.x).abs() / core::f64::consts::SQRT_2;
1987            assert!(
1988                off_line < 0.12,
1989                "the batten stays in the noise band: {off_line}"
1990            );
1991        }
1992        // Ends interpolate exactly.
1993        assert!(faired.curve.point_at(lo, T).unwrap().distance(points[0]) < 1e-9);
1994        assert!(faired.curve.point_at(hi, T).unwrap().distance(points[14]) < 1e-9);
1995    }
1996
1997    const T: Tolerances = Tolerances::millimetres();
1998
1999    /// Distance from a point to a curve: scan, then ternary-refine.
2000    fn nearest(curve: &BSplineCurve, p: Point) -> f64 {
2001        let at = |u: f64| curve.point_at(u, T).map_or(f64::MAX, |q| p.distance(q));
2002        let mut best = (0.0, f64::MAX);
2003        for i in 0..=4000 {
2004            #[allow(clippy::cast_precision_loss)]
2005            let u = i as f64 / 4000.0;
2006            let d = at(u);
2007            if d < best.1 {
2008                best = (u, d);
2009            }
2010        }
2011        let (mut lo, mut hi) = ((best.0 - 5e-4).max(0.0), (best.0 + 5e-4).min(1.0));
2012        for _ in 0..100 {
2013            let one = lo + (hi - lo) / 3.0;
2014            let two = hi - (hi - lo) / 3.0;
2015            if at(one) < at(two) {
2016                hi = two;
2017            } else {
2018                lo = one;
2019            }
2020        }
2021        at(f64::midpoint(lo, hi)).min(best.1)
2022    }
2023
2024    /// Points along a circle, the standard curve a spline cannot represent
2025    /// exactly and can approach as closely as asked.
2026    fn circle_points(n: usize, radius: f64) -> Vec<Point> {
2027        (0..=n)
2028            .map(|i| {
2029                #[allow(clippy::cast_precision_loss)]
2030                let a = TAU * i as f64 / n as f64;
2031                Point::new(radius * a.cos(), radius * a.sin(), 0.0)
2032            })
2033            .collect()
2034    }
2035
2036    #[test]
2037    fn a_fit_meets_the_tolerance_it_was_asked_for_and_says_what_it_reached() {
2038        let points = circle_points(200, 5.0);
2039        for tolerance in [1e-2, 1e-4, 1e-6] {
2040            let fitted = fit_points(&points, 3, tolerance, T).unwrap();
2041            assert!(
2042                fitted.met,
2043                "target {tolerance:e} not met, got {:e}",
2044                fitted.error
2045            );
2046            assert!(
2047                fitted.error <= tolerance,
2048                "reported {:e} over target {tolerance:e}",
2049                fitted.error
2050            );
2051            // And the report is honest: measure independently, point to curve.
2052            // A bare scan reports its own step size: 2000 samples over a
2053            // circumference of thirty is an 8e-3 grid, and comparing that to a
2054            // 1e-6 fit measures the scan, so the scan brackets and a ternary
2055            // search finishes.
2056            let mut worst = 0.0_f64;
2057            for p in &points {
2058                worst = worst.max(nearest(&fitted.curve, *p));
2059            }
2060            assert!(
2061                worst <= tolerance * 1.5,
2062                "independent measurement found {worst:e} against {tolerance:e}"
2063            );
2064        }
2065    }
2066
2067    #[test]
2068    fn a_tighter_tolerance_never_uses_fewer_control_points() {
2069        let points = circle_points(300, 3.0);
2070        let coarse = fit_points(&points, 3, 1e-2, T).unwrap();
2071        let fine = fit_points(&points, 3, 1e-6, T).unwrap();
2072        assert!(
2073            fine.curve.control_points().len() > coarse.curve.control_points().len(),
2074            "{} then {}",
2075            coarse.curve.control_points().len(),
2076            fine.curve.control_points().len()
2077        );
2078        // And the coarse one is genuinely coarse: far fewer control points
2079        // than input points, or the fit is interpolation in disguise.
2080        assert!(coarse.curve.control_points().len() < 30);
2081    }
2082
2083    #[test]
2084    fn knots_go_where_the_error_is() {
2085        // A straight run with one tight corner. Uniform refinement would
2086        // spread knots evenly; adaptive refinement must put them in the
2087        // corner.
2088        let mut points = Vec::new();
2089        for i in 0..=100 {
2090            points.push(Point::new(f64::from(i) * 0.1, 0.0, 0.0));
2091        }
2092        for i in 1..=50 {
2093            let a = f64::from(i) / 50.0 * core::f64::consts::FRAC_PI_2;
2094            points.push(Point::new(10.0 + a.sin() * 0.5, (1.0 - a.cos()) * 0.5, 0.0));
2095        }
2096        for i in 1..=100 {
2097            points.push(Point::new(10.5, 0.5 + f64::from(i) * 0.1, 0.0));
2098        }
2099
2100        let fitted = fit_points(&points, 3, 1e-4, T).unwrap();
2101        assert!(fitted.met);
2102
2103        // Knot parameters cluster around the corner, which sits at roughly
2104        // half way through the arc length.
2105        let distinct = fitted.curve.knots().distinct();
2106        let interior: Vec<f64> = distinct[1..distinct.len() - 1]
2107            .iter()
2108            .map(|(u, _)| *u)
2109            .collect();
2110        let near_corner = interior
2111            .iter()
2112            .filter(|u| (0.40..0.60).contains(*u))
2113            .count();
2114        assert!(
2115            near_corner * 2 > interior.len(),
2116            "only {near_corner} of {} interior knots are near the corner",
2117            interior.len()
2118        );
2119    }
2120
2121    #[test]
2122    fn the_ends_are_honoured_exactly_and_a_closed_loop_stays_closed() {
2123        let points = circle_points(64, 2.0);
2124        let fitted = fit_points(&points, 3, 1e-3, T).unwrap();
2125        let (a, b) = fitted.curve.knots().domain();
2126        let start = fitted.curve.point_at(a, T).unwrap();
2127        let end = fitted.curve.point_at(b, T).unwrap();
2128        assert!(start.is_equal(points[0], T), "the start drifted");
2129        assert!(end.is_equal(*points.last().unwrap(), T), "the end drifted");
2130        assert!(start.is_equal(end, T), "the loop opened");
2131    }
2132
2133    #[test]
2134    fn a_smooth_loop_closes_with_matching_tangents_at_the_join() {
2135        use crate::traits::Curve3d as _;
2136        let points = circle_points(128, 3.0);
2137        let fitted = fit_points_closed(&points, 3, 1e-4, T).unwrap();
2138        assert!(fitted.met, "target not met, reached {:e}", fitted.error);
2139
2140        let curve: crate::curve::Curve = fitted.curve.clone().into();
2141        let (a, b) = fitted.curve.knots().domain();
2142        let start = curve.point_at(a, T).unwrap();
2143        let end = curve.point_at(b, T).unwrap();
2144        assert!(start.is_equal(end, T), "the loop opened");
2145
2146        // The join is C1: the derivative leaving the seam equals the one
2147        // arriving, exactly; the constraint is solved, not approximated.
2148        let out = curve.d1_at(a, T).unwrap();
2149        let back = curve.d1_at(b, T).unwrap();
2150        assert!(
2151            (out - back).magnitude() <= 1e-9 * out.magnitude(),
2152            "the join creases: {out:?} vs {back:?}"
2153        );
2154
2155        // And the open fit of the same data does *not* promise this, which is
2156        // why the closed fit is its own entry.
2157        let open = fit_points(&points, 3, 1e-4, T).unwrap();
2158        let ocurve: crate::curve::Curve = open.curve.into();
2159        let (oa, ob) = (a, b);
2160        let _ = (ocurve.d1_at(oa, T).unwrap(), ocurve.d1_at(ob, T).unwrap());
2161    }
2162
2163    #[test]
2164    fn a_joint_closed_fit_is_smooth_in_all_seven_coordinates() {
2165        use crate::traits::{Curve2d as _, Curve3d as _};
2166        let n = 96;
2167        let mut points = Vec::new();
2168        let mut on_a = Vec::new();
2169        let mut on_b = Vec::new();
2170        for i in 0..=n {
2171            #[allow(clippy::cast_precision_loss)]
2172            let t = core::f64::consts::TAU * i as f64 / n as f64;
2173            points.push(Point::new(4.0 * t.cos(), 4.0 * t.sin(), 1.0));
2174            // Two different smooth closed chart images of the same loop.
2175            on_a.push(Point2::new(t.cos(), t.sin()));
2176            on_b.push(Point2::new(2.0 * t.sin(), t.cos() - 3.0));
2177        }
2178        let (space, pa, pb) = fit_points_joint_closed(&points, &on_a, &on_b, 3, 1e-4, T).unwrap();
2179        assert!(space.met, "reached {:e}", space.error);
2180
2181        let curve: crate::curve::Curve = space.curve.into();
2182        let (lo, hi) = curve.domain();
2183        let out3 = curve.d1_at(lo, T).unwrap();
2184        let back3 = curve.d1_at(hi, T).unwrap();
2185        assert!((out3 - back3).magnitude() <= 1e-9 * out3.magnitude());
2186        for plane in [&pa, &pb] {
2187            let planar: crate::curve2d::PlanarCurve = plane.clone().into();
2188            let out2 = planar.d1_at(lo, T).unwrap();
2189            let back2 = planar.d1_at(hi, T).unwrap();
2190            assert!(
2191                (out2 - back2).magnitude() <= 1e-9 * out2.magnitude(),
2192                "a pcurve creases at its seam"
2193            );
2194        }
2195    }
2196
2197    #[test]
2198    fn a_fit_that_is_not_a_loop_is_refused_by_the_closed_entry() {
2199        let mut points = circle_points(32, 1.0);
2200        points.pop();
2201        assert!(fit_points_closed(&points, 3, 1e-3, T).is_err());
2202    }
2203
2204    #[test]
2205    fn an_impossible_target_is_reported_not_rounded_up_to_success() {
2206        // Five points cannot pin a curve to a picometre unless the curve
2207        // interpolates them, and past that adding knots buys nothing. The fit
2208        // must say it fell short and how far.
2209        let points = vec![
2210            Point::new(0.0, 0.0, 0.0),
2211            Point::new(1.0, 1.0, 0.0),
2212            Point::new(2.0, -1.0, 0.0),
2213            Point::new(3.0, 1.0, 0.0),
2214            Point::new(4.0, 0.0, 0.0),
2215        ];
2216        let fitted = fit_points(&points, 3, 1e-15, T).unwrap();
2217        // With as many control points as points it may interpolate and land
2218        // at rounding; either way the flags must be consistent.
2219        assert_eq!(fitted.met, fitted.error <= 1e-15);
2220    }
2221
2222    #[test]
2223    fn the_2d_fit_is_the_same_machinery() {
2224        let points: Vec<Point2> = (0..=100)
2225            .map(|i| {
2226                #[allow(clippy::cast_precision_loss)]
2227                let a = TAU * f64::from(i) / 100.0;
2228                Point2::new(3.0 * a.cos(), 3.0 * a.sin())
2229            })
2230            .collect();
2231        let fitted = fit_points_2d(&points, 3, 1e-4, T).unwrap();
2232        assert!(fitted.met);
2233        assert!(fitted.error <= 1e-4);
2234        // Spot-check a few points independently, with refinement past the
2235        // scan's own resolution.
2236        for p in points.iter().step_by(17) {
2237            let scan = |u: f64| {
2238                fitted
2239                    .curve
2240                    .point_at(u, T)
2241                    .map_or(f64::MAX, |q| p.distance(q))
2242            };
2243            let mut best = (0.0, f64::MAX);
2244            for i in 0..=2000 {
2245                #[allow(clippy::cast_precision_loss)]
2246                let u = i as f64 / 2000.0;
2247                let d = scan(u);
2248                if d < best.1 {
2249                    best = (u, d);
2250                }
2251            }
2252            let (mut lo, mut hi) = ((best.0 - 1e-3).max(0.0), (best.0 + 1e-3).min(1.0));
2253            for _ in 0..100 {
2254                let one = lo + (hi - lo) / 3.0;
2255                let two = hi - (hi - lo) / 3.0;
2256                if scan(one) < scan(two) {
2257                    hi = two;
2258                } else {
2259                    lo = one;
2260                }
2261            }
2262            let found = scan(f64::midpoint(lo, hi)).min(best.1);
2263            assert!(found < 2e-4, "a 2d point is {found:e} off the fit");
2264        }
2265    }
2266
2267    #[test]
2268    fn inputs_that_describe_nothing_are_refused() {
2269        let p = Point::ORIGIN;
2270        assert!(fit_points(&[], 3, 1e-3, T).is_err());
2271        assert!(fit_points(&[p], 3, 1e-3, T).is_err());
2272        assert!(
2273            fit_points(&[p, p, p], 3, 1e-3, T).is_err(),
2274            "all duplicates"
2275        );
2276        let two = [p, Point::new(1.0, 0.0, 0.0)];
2277        assert!(fit_points(&two, 3, 0.0, T).is_err());
2278        assert!(fit_points(&two, 3, -1.0, T).is_err());
2279        assert!(fit_points(&two, 3, f64::NAN, T).is_err());
2280        assert!(fit_points(&two, 0, 1e-3, T).is_err());
2281        // Two points always fit: the segment between them.
2282        assert!(fit_points(&two, 3, 1e-9, T).unwrap().met);
2283    }
2284
2285    /// A joint fit reproduces a straight run however unevenly it was walked.
2286    ///
2287    /// A marched section's walk starts on a window's rim with a step that
2288    /// halved to a micron and grows back twofold a point, so most of its
2289    /// samples crowd one end. Parameterised centripetally, a cubic through
2290    /// them could not be the line they lie on, and the correction rounds
2291    /// never reached the feet: a straight section came back twelve hundred
2292    /// millimetres off its own line. Chord length makes the line exact.
2293    #[test]
2294    fn a_joint_fit_holds_a_straight_run_sampled_geometrically() {
2295        let mut points = Vec::new();
2296        let mut on_a = Vec::new();
2297        let mut on_b = Vec::new();
2298        let mut s = 0.0_f64;
2299        let mut step = 1e-5;
2300        while s < 16.0 {
2301            points.push(Point::new(-6.5, 9.0 - s, 8.0));
2302            on_a.push(Point2::new(-8.0 + s, -1.0));
2303            on_b.push(Point2::new(-0.2, 0.7 - s / 16.0));
2304            s += step;
2305            step = (step * 2.0).min(1.0);
2306        }
2307        points.push(Point::new(-6.5, -7.0, 8.0));
2308        on_a.push(Point2::new(8.0, -1.0));
2309        on_b.push(Point2::new(-0.2, -0.3));
2310        let (space, pa, pb) = fit_points_joint(&points, &on_a, &on_b, 3, 1e-6, T).unwrap();
2311        assert!(space.error < 1e-6, "the run is a line: {}", space.error);
2312        let (lo, hi) = space.curve.domain();
2313        for i in 0..=200 {
2314            let t = lo + (hi - lo) * f64::from(i) / 200.0;
2315            let p = space.curve.point_at(t, T).unwrap();
2316            assert!(
2317                (p.x + 6.5).abs() < 1e-6 && (p.z - 8.0).abs() < 1e-6,
2318                "off the line at {p:?}"
2319            );
2320            let a = pa.point_at(t, T).unwrap();
2321            assert!((a.y + 1.0).abs() < 1e-6 && (a.x - (-8.0 + (9.0 - p.y))).abs() < 1e-6);
2322            let b = pb.point_at(t, T).unwrap();
2323            assert!((b.x + 0.2).abs() < 1e-6);
2324        }
2325    }
2326}