Skip to main content

axiolid_nurbs/
approximate.rs

1//! Least-squares curve and surface approximation (#150, ledger B11).
2//!
3//! # Approximation, not interpolation
4//!
5//! `fit.rs` interpolates: the curve passes through every input point.
6//! This module approximates: the curve or surface minimises the sum of
7//! squared deviations from scattered points, using fewer control points
8//! than data points. The two are different operations with different
9//! uses -- a caller fairing noisy survey points wants the first kind of
10//! fit, not the second.
11//!
12//! # Method
13//!
14//! Parameters are assigned per point (uniform, chord-length or
15//! centripetal, Piegl and Tiller section 9.2), the knot vector is placed
16//! by the averaging formula for approximation (Piegl and Tiller eq.
17//! 9.68, distinct from the interpolation averaging in `fit.rs` because
18//! here the control count is smaller than the data count), and the
19//! resulting Vandermonde-like system is solved by `axiolid-numeric`'s
20//! column-pivoted QR rather than by normal equations: normal equations
21//! square the condition number, which is already poor for high degree
22//! or clustered parameters.
23//!
24//! Endpoint interpolation, when requested, is an equality constraint
25//! (`P_0 = Q_0`, `P_n = Q_m`) solved by `constrained_least_squares`,
26//! not a free consequence of the clamped basis: a plain least-squares
27//! solve does not generally reproduce the first and last points exactly.
28//!
29//! Every reported deviation is the actual Euclidean distance between an
30//! input point and the fitted curve or surface evaluated at that
31//! point's own parameter -- computed from the solved control points,
32//! never estimated from the residual norm the solver reports (which is
33//! in the `f64` sense, not the geometric one, once a smoothing term is
34//! mixed into the same system).
35
36use axiolid_contracts::GeomError;
37use axiolid_core::{Point3, Scalar};
38use axiolid_curve::{BSplineCurve3, KnotSpec};
39use axiolid_numeric::{constrained_least_squares, least_squares, Matrix, NumericError};
40use axiolid_surface::BSplineSurface;
41
42use crate::fit::{basis_at, chord_parameters, collapse, span_of};
43
44/// Result of a geometry solve: `GeomError` on refusal.
45pub type FitResult<T> = Result<T, GeomError>;
46
47/// How a point sequence is mapped onto its curve parameter.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Parameterisation {
50    /// Equal parameter spacing, independent of point spacing.
51    Uniform,
52    /// Spacing proportional to the distance between consecutive points
53    /// (Piegl and Tiller eq. 9.4).
54    ChordLength,
55    /// Spacing proportional to the square root of the distance (Piegl
56    /// and Tiller eq. 9.6): tends to track curvature changes better than
57    /// chord length when the input has sharp corners.
58    Centripetal,
59}
60
61/// Options for `fit_curve3`.
62#[derive(Debug, Clone, PartialEq)]
63pub struct CurveFitOptions {
64    /// Degree of the fitted curve. Must be at least 1.
65    pub degree: u16,
66    /// Number of control points to solve for. Must be strictly between
67    /// `degree` and the number of input points (inclusive of equality,
68    /// which degenerates to interpolation).
69    pub control_point_count: usize,
70    /// Parameter assignment.
71    pub parameterisation: Parameterisation,
72    /// Constrain the first and last control points to equal the first
73    /// and last input points exactly, by equality-constrained least
74    /// squares rather than as an incidental consequence of the basis.
75    pub interpolate_endpoints: bool,
76    /// Weight of an optional second-difference smoothing term on the
77    /// control polygon (a discrete fairness energy). Zero disables it.
78    /// Must be finite and non-negative.
79    pub smoothing: Scalar,
80    /// Newton parameter-correction passes (Hoschek and Lasser, section
81    /// 9.3) run after the initial solve: each pass projects every point
82    /// onto the current curve and refits with the corrected parameters.
83    /// Zero skips correction and uses the assigned parameters as-is.
84    pub parameter_correction_iterations: usize,
85}
86
87impl Default for CurveFitOptions {
88    fn default() -> Self {
89        Self {
90            degree: 3,
91            control_point_count: 0,
92            parameterisation: Parameterisation::ChordLength,
93            interpolate_endpoints: false,
94            smoothing: 0.0,
95            parameter_correction_iterations: 0,
96        }
97    }
98}
99
100/// A fitted curve together with its certified deviation from the input.
101#[derive(Debug, Clone, PartialEq)]
102pub struct CurveFit3 {
103    /// The fitted curve.
104    pub curve: BSplineCurve3,
105    /// Largest Euclidean distance from an input point to the curve at
106    /// that point's own parameter.
107    pub max_deviation: Scalar,
108    /// Root-mean-square of those distances.
109    pub rms_deviation: Scalar,
110}
111
112/// Options for `fit_curve3_to_tolerance`.
113#[derive(Debug, Clone, PartialEq)]
114pub struct CurveFitTolerance {
115    /// Degree of the fitted curve.
116    pub degree: u16,
117    /// Parameter assignment.
118    pub parameterisation: Parameterisation,
119    /// Constrain the endpoints, as in `CurveFitOptions`.
120    pub interpolate_endpoints: bool,
121    /// Maximum deviation (see `CurveFit3::max_deviation`) the caller
122    /// will accept.
123    pub max_deviation: Scalar,
124    /// Largest control-point count the search may try before refusing.
125    pub max_control_points: usize,
126}
127
128fn validate_finite(points: &[Point3]) -> FitResult<()> {
129    if !points.iter().all(|p| p.is_finite()) {
130        return Err(GeomError::InvalidInput(
131            "fitting points must be finite".to_owned(),
132        ));
133    }
134    Ok(())
135}
136
137fn validate_degree(degree: u16) -> FitResult<usize> {
138    if degree == 0 {
139        return Err(GeomError::InvalidInput(
140            "fitting degree must be at least 1".to_owned(),
141        ));
142    }
143    Ok(usize::from(degree))
144}
145
146/// Parameters in `[0, 1]` for `points` under `mode`.
147///
148/// Uniform spacing never inspects point positions, so it cannot detect
149/// coincident points; both chord-length and centripetal spacing refuse
150/// coincident consecutive points, since the step they divide by is zero.
151pub(crate) fn parameters_for(points: &[Point3], mode: Parameterisation) -> FitResult<Vec<Scalar>> {
152    match mode {
153        Parameterisation::Uniform => {
154            let last = points.len() - 1;
155            Ok((0..points.len())
156                .map(|k| k as Scalar / last as Scalar)
157                .collect())
158        }
159        Parameterisation::ChordLength => chord_parameters(points),
160        Parameterisation::Centripetal => centripetal_parameters(points),
161    }
162}
163
164fn centripetal_parameters(points: &[Point3]) -> FitResult<Vec<Scalar>> {
165    let mut distances = Vec::with_capacity(points.len());
166    distances.push(0.0);
167    let mut total = 0.0;
168    for pair in points.windows(2) {
169        let step = (pair[1] - pair[0]).length();
170        if step <= 0.0 {
171            return Err(GeomError::Degenerate(
172                "consecutive fitting points coincide, so centripetal spacing is undefined"
173                    .to_owned(),
174            ));
175        }
176        total += step.sqrt();
177        distances.push(total);
178    }
179    Ok(distances.into_iter().map(|d| d / total).collect())
180}
181
182/// The clamped, expanded approximation knot vector (Piegl and Tiller eq.
183/// 9.68): `control_count - degree - 1` interior knots placed by
184/// averaging `degree` consecutive parameters, scaled by the ratio of
185/// data points to control points.
186pub(crate) fn approximation_knots(
187    parameters: &[Scalar],
188    degree: usize,
189    control_count: usize,
190) -> Vec<Scalar> {
191    let m = parameters.len() - 1;
192    let n = control_count - 1;
193    let d = (m + 1) as Scalar / (n - degree + 1) as Scalar;
194    let mut knots = vec![0.0; degree + 1];
195    for j in 1..=(n - degree) {
196        let temp = j as Scalar * d;
197        let i = temp.floor() as usize;
198        let alpha = temp - i as Scalar;
199        let value = if i == 0 {
200            parameters[0]
201        } else {
202            (1.0 - alpha) * parameters[i - 1] + alpha * parameters[i]
203        };
204        knots.push(value);
205    }
206    knots.extend(core::iter::repeat_n(1.0, degree + 1));
207    knots
208}
209
210/// The design matrix: row `k` holds the `degree + 1` non-zero basis
211/// values at `parameters[k]`, placed at their control-point columns.
212fn design_matrix(
213    parameters: &[Scalar],
214    expanded_knots: &[Scalar],
215    degree: usize,
216    control_count: usize,
217) -> Matrix {
218    let n = control_count - 1;
219    let mut dense = vec![0.0; parameters.len() * control_count];
220    for (row, &t) in parameters.iter().enumerate() {
221        let span = span_of(expanded_knots, n, degree, t);
222        let basis = basis_at(span, t, degree, expanded_knots);
223        for (offset, value) in basis.iter().enumerate() {
224            dense[row * control_count + span - degree + offset] = *value;
225        }
226    }
227    Matrix::from_fn(parameters.len(), control_count, |i, j| {
228        dense[i * control_count + j]
229    })
230}
231
232/// Evaluate a clamped B-spline curve described by its expanded knot
233/// vector and control points at `t`.
234fn evaluate(
235    control_points: &[Point3],
236    expanded_knots: &[Scalar],
237    degree: usize,
238    t: Scalar,
239) -> Point3 {
240    let n = control_points.len() - 1;
241    let span = span_of(expanded_knots, n, degree, t);
242    let basis = basis_at(span, t, degree, expanded_knots);
243    let mut acc = Point3::new(0.0, 0.0, 0.0);
244    for (offset, value) in basis.iter().enumerate() {
245        let cp = control_points[span - degree + offset];
246        acc = Point3::new(
247            acc.x + value * cp.x,
248            acc.y + value * cp.y,
249            acc.z + value * cp.z,
250        );
251    }
252    acc
253}
254
255/// First derivative of a clamped B-spline curve at `t`, by finite
256/// difference of neighbouring control points (Piegl and Tiller eq. 3.3),
257/// used only to take a Newton step in parameter correction.
258fn evaluate_derivative(
259    control_points: &[Point3],
260    expanded_knots: &[Scalar],
261    degree: usize,
262    t: Scalar,
263) -> axiolid_core::Vec3 {
264    if degree == 0 {
265        return axiolid_core::Vec3::new(0.0, 0.0, 0.0);
266    }
267    let n = control_points.len() - 1;
268    let span = span_of(expanded_knots, n, degree, t);
269    let basis = basis_at(span, t, degree - 1, expanded_knots);
270    let mut acc = axiolid_core::Vec3::new(0.0, 0.0, 0.0);
271    let p = degree as Scalar;
272    for (offset, value) in basis.iter().enumerate() {
273        let i = span - degree + offset;
274        let denom = expanded_knots[i + degree + 1] - expanded_knots[i + 1];
275        if denom == 0.0 {
276            continue;
277        }
278        let coeff = p / denom * value;
279        acc += (control_points[i + 1] - control_points[i]) * coeff;
280    }
281    acc
282}
283
284fn from_numeric(err: NumericError) -> GeomError {
285    match err {
286        NumericError::RankDeficient {
287            name,
288            rank,
289            required,
290        } => GeomError::Degenerate(format!(
291            "{name} has numerical rank {rank}, needs {required} for a unique least-squares fit"
292        )),
293        other => GeomError::Degenerate(other.to_string()),
294    }
295}
296
297/// Solve the least-squares (optionally endpoint-constrained, optionally
298/// smoothed) system for one coordinate.
299#[allow(clippy::too_many_arguments)]
300fn solve_coordinate(
301    design: &Matrix,
302    smoothing_rows: Option<&Matrix>,
303    values: &[Scalar],
304    control_count: usize,
305    interpolate_endpoints: bool,
306    first: Scalar,
307    last: Scalar,
308) -> FitResult<Vec<Scalar>> {
309    let (a, b): (Matrix, Vec<Scalar>) = if let Some(extra) = smoothing_rows {
310        let rows = design.rows() + extra.rows();
311        let a = Matrix::from_fn(rows, control_count, |i, j| {
312            if i < design.rows() {
313                design[(i, j)]
314            } else {
315                extra[(i - design.rows(), j)]
316            }
317        });
318        let mut b = values.to_vec();
319        b.resize(rows, 0.0);
320        (a, b)
321    } else {
322        (design.clone(), values.to_vec())
323    };
324
325    if interpolate_endpoints {
326        let mut c = Matrix::zeros(2, control_count);
327        c[(0, 0)] = 1.0;
328        c[(1, control_count - 1)] = 1.0;
329        let d = [first, last];
330        let solution = constrained_least_squares(&a, &b, &c, &d).map_err(from_numeric)?;
331        Ok(solution.x)
332    } else {
333        let solution = least_squares(&a, &b).map_err(from_numeric)?;
334        Ok(solution.x)
335    }
336}
337
338/// Second-difference smoothing rows, scaled by `sqrt(weight)`: row `k`
339/// is `weight^(1/2) * (P_{k-1} - 2 P_k + P_{k+1})`, driven to zero.
340fn smoothing_rows(control_count: usize, weight: Scalar) -> Option<Matrix> {
341    if weight <= 0.0 || control_count < 3 {
342        return None;
343    }
344    let scale = weight.sqrt();
345    let rows = control_count - 2;
346    Some(Matrix::from_fn(rows, control_count, |i, j| {
347        if j == i {
348            scale
349        } else if j == i + 1 {
350            -2.0 * scale
351        } else if j == i + 2 {
352            scale
353        } else {
354            0.0
355        }
356    }))
357}
358
359fn assemble_curve(
360    control_points: Vec<Point3>,
361    expanded_knots: &[Scalar],
362    degree: usize,
363) -> FitResult<BSplineCurve3> {
364    let (knots, multiplicities) = collapse(expanded_knots);
365    Ok(BSplineCurve3 {
366        degree: u16::try_from(degree)
367            .map_err(|_| GeomError::InvalidInput("degree overflows".to_owned()))?,
368        control_points,
369        knots,
370        multiplicities,
371        weights: None,
372        knot_spec: KnotSpec::Unspecified,
373        closed: false,
374        self_intersect: None,
375    })
376}
377
378fn deviation(
379    points: &[Point3],
380    curve: &[Point3],
381    knots: &[Scalar],
382    degree: usize,
383    t: &[Scalar],
384) -> (Scalar, Scalar) {
385    let mut max = 0.0;
386    let mut sum_sq = 0.0;
387    for (point, &tk) in points.iter().zip(t) {
388        let fitted = evaluate(curve, knots, degree, tk);
389        let d = (fitted - *point).length();
390        max = Scalar::max(max, d);
391        sum_sq += d * d;
392    }
393    let rms = (sum_sq / points.len() as Scalar).sqrt();
394    (max, rms)
395}
396
397/// Fit a B-spline curve of `options.degree` through `points` by least
398/// squares, with `options.control_point_count` unknowns.
399///
400/// # Errors
401///
402/// Refuses non-finite points, fewer than two points, an invalid degree,
403/// a control-point count outside `degree + 1 ..= points.len()`,
404/// coincident consecutive points under chord-length or centripetal
405/// parameterisation, a non-finite or negative smoothing weight, and a
406/// rank-deficient least-squares system (reported with the numeric rank
407/// and the rank required).
408pub fn fit_curve3(points: &[Point3], options: &CurveFitOptions) -> FitResult<CurveFit3> {
409    if points.len() < 2 {
410        return Err(GeomError::InvalidInput(
411            "fitting needs at least two points".to_owned(),
412        ));
413    }
414    validate_finite(points)?;
415    let degree = validate_degree(options.degree)?;
416    let control_count = options.control_point_count;
417    if control_count < degree + 1 || control_count > points.len() {
418        return Err(GeomError::InvalidInput(format!(
419            "control point count {control_count} must be between {} and {} for degree {degree} \
420             and {} points",
421            degree + 1,
422            points.len(),
423            points.len()
424        )));
425    }
426    if !options.smoothing.is_finite() || options.smoothing < 0.0 {
427        return Err(GeomError::InvalidInput(
428            "smoothing weight must be finite and non-negative".to_owned(),
429        ));
430    }
431
432    let mut parameters = parameters_for(points, options.parameterisation)?;
433    let mut expanded = approximation_knots(&parameters, degree, control_count);
434    let mut design = design_matrix(&parameters, &expanded, degree, control_count);
435    let extra = smoothing_rows(control_count, options.smoothing);
436
437    let xs: Vec<Scalar> = points.iter().map(|p| p.x).collect();
438    let ys: Vec<Scalar> = points.iter().map(|p| p.y).collect();
439    let zs: Vec<Scalar> = points.iter().map(|p| p.z).collect();
440
441    let mut control_points = solve_xyz(
442        &design,
443        extra.as_ref(),
444        &xs,
445        &ys,
446        &zs,
447        control_count,
448        options.interpolate_endpoints,
449        points[0],
450        points[points.len() - 1],
451    )?;
452
453    for _ in 0..options.parameter_correction_iterations {
454        parameters = correct_parameters(points, &control_points, &expanded, degree, &parameters);
455        expanded = approximation_knots(&parameters, degree, control_count);
456        design = design_matrix(&parameters, &expanded, degree, control_count);
457        let extra = smoothing_rows(control_count, options.smoothing);
458        control_points = solve_xyz(
459            &design,
460            extra.as_ref(),
461            &xs,
462            &ys,
463            &zs,
464            control_count,
465            options.interpolate_endpoints,
466            points[0],
467            points[points.len() - 1],
468        )?;
469    }
470
471    let (max_deviation, rms_deviation) =
472        deviation(points, &control_points, &expanded, degree, &parameters);
473    let curve = assemble_curve(control_points, &expanded, degree)?;
474    Ok(CurveFit3 {
475        curve,
476        max_deviation,
477        rms_deviation,
478    })
479}
480
481#[allow(clippy::too_many_arguments)]
482fn solve_xyz(
483    design: &Matrix,
484    extra: Option<&Matrix>,
485    xs: &[Scalar],
486    ys: &[Scalar],
487    zs: &[Scalar],
488    control_count: usize,
489    interpolate_endpoints: bool,
490    first: Point3,
491    last: Point3,
492) -> FitResult<Vec<Point3>> {
493    let x = solve_coordinate(
494        design,
495        extra,
496        xs,
497        control_count,
498        interpolate_endpoints,
499        first.x,
500        last.x,
501    )?;
502    let y = solve_coordinate(
503        design,
504        extra,
505        ys,
506        control_count,
507        interpolate_endpoints,
508        first.y,
509        last.y,
510    )?;
511    let z = solve_coordinate(
512        design,
513        extra,
514        zs,
515        control_count,
516        interpolate_endpoints,
517        first.z,
518        last.z,
519    )?;
520    Ok((0..control_count)
521        .map(|i| Point3::new(x[i], y[i], z[i]))
522        .collect())
523}
524
525/// One Newton step of point inversion per point (Hoschek and Lasser),
526/// clamped to stay inside `[0, 1]`: the new parameter for point `q` is
527/// `t - (C(t) - q) . C'(t) / |C'(t)|^2`.
528fn correct_parameters(
529    points: &[Point3],
530    control_points: &[Point3],
531    expanded_knots: &[Scalar],
532    degree: usize,
533    parameters: &[Scalar],
534) -> Vec<Scalar> {
535    let last = parameters.len() - 1;
536    let mut out = Vec::with_capacity(points.len());
537    for (k, (point, &t)) in points.iter().zip(parameters).enumerate() {
538        if k == 0 || k == last {
539            out.push(t);
540            continue;
541        }
542        let c = evaluate(control_points, expanded_knots, degree, t);
543        let d = evaluate_derivative(control_points, expanded_knots, degree, t);
544        let speed_sq = d.x * d.x + d.y * d.y + d.z * d.z;
545        let corrected = if speed_sq > 0.0 {
546            let diff = c - *point;
547            let num = diff.x * d.x + diff.y * d.y + diff.z * d.z;
548            (t - num / speed_sq).clamp(0.0, 1.0)
549        } else {
550            t
551        };
552        out.push(corrected);
553    }
554    // Keep correction monotone: a parameter overtaking its neighbour
555    // would break span search. Falling back to the previous value is
556    // conservative rather than silently reordering the sequence.
557    for i in 1..out.len() {
558        if out[i] <= out[i - 1] {
559            out[i] = parameters[i];
560        }
561    }
562    out
563}
564
565/// Fit a curve whose maximum deviation does not exceed `tolerance.max_deviation`,
566/// adding control points (starting from `degree + 1`) until it does.
567///
568/// # Errors
569///
570/// As `fit_curve3`, plus `GeomError::BudgetExceeded` when
571/// `tolerance.max_control_points` is reached without meeting the
572/// tolerance: the caller must widen the budget rather than receive a fit
573/// that silently misses the requested accuracy.
574pub fn fit_curve3_to_tolerance(
575    points: &[Point3],
576    tolerance: &CurveFitTolerance,
577) -> FitResult<CurveFit3> {
578    if !tolerance.max_deviation.is_finite() || tolerance.max_deviation < 0.0 {
579        return Err(GeomError::InvalidInput(
580            "tolerance must be finite and non-negative".to_owned(),
581        ));
582    }
583    let degree = validate_degree(tolerance.degree)?;
584    let mut control_count = degree + 1;
585    loop {
586        if control_count > tolerance.max_control_points || control_count > points.len() {
587            return Err(GeomError::BudgetExceeded {
588                resource: "control points",
589            });
590        }
591        let options = CurveFitOptions {
592            degree: tolerance.degree,
593            control_point_count: control_count,
594            parameterisation: tolerance.parameterisation,
595            interpolate_endpoints: tolerance.interpolate_endpoints,
596            smoothing: 0.0,
597            parameter_correction_iterations: 0,
598        };
599        let fit = fit_curve3(points, &options)?;
600        if fit.max_deviation <= tolerance.max_deviation {
601            return Ok(fit);
602        }
603        // The next iteration's budget check (above) catches running out of
604        // either the caller's ceiling or the data itself, so there is
605        // nothing further to check here before trying one more point.
606        control_count += 1;
607    }
608}
609
610/// Options for `fit_surface_grid`.
611#[derive(Debug, Clone, PartialEq)]
612pub struct SurfaceFitOptions {
613    /// Degree along the first (row, `u`) axis.
614    pub u_degree: u16,
615    /// Degree along the second (column, `v`) axis.
616    pub v_degree: u16,
617    /// Control points along `u`.
618    pub u_control_count: usize,
619    /// Control points along `v`.
620    pub v_control_count: usize,
621    /// Parameter assignment, applied independently along each axis.
622    pub parameterisation: Parameterisation,
623}
624
625/// A fitted surface together with its certified deviation from the grid.
626#[derive(Debug, Clone, PartialEq)]
627pub struct SurfaceFit {
628    /// The fitted surface.
629    pub surface: BSplineSurface,
630    /// Largest Euclidean distance from a grid point to the surface at
631    /// that point's own `(u, v)` parameter.
632    pub max_deviation: Scalar,
633    /// Root-mean-square of those distances.
634    pub rms_deviation: Scalar,
635}
636
637/// Fit a tensor-product B-spline surface to a rectangular grid of
638/// points, `points[i][j]` at row `i` (the `u` direction) and column `j`
639/// (the `v` direction), every row the same length.
640///
641/// The fit is separable (Piegl and Tiller section 9.4): each row is
642/// least-squares fit along `u` sharing one design matrix and one global
643/// `u` knot vector (parameters averaged across rows), producing an
644/// intermediate `u_control_count x cols` net; each column of that net is
645/// then least-squares fit along `v` the same way. The result is not the
646/// global two-dimensional optimum a single joint solve would give, but
647/// it is deterministic, reuses the already-certified curve fit, and
648/// matches how `axiolid-nurbs` already builds surfaces from curve data
649/// (`loft_surface`).
650///
651/// # Errors
652///
653/// Refuses fewer than two rows or columns, ragged rows, non-finite
654/// points, an invalid degree, a control count outside
655/// `degree + 1 ..= point count` on either axis, coincident consecutive
656/// rows or columns under chord-length or centripetal parameterisation,
657/// and a rank-deficient system on either axis.
658pub fn fit_surface_grid(
659    points: &[Vec<Point3>],
660    options: &SurfaceFitOptions,
661) -> FitResult<SurfaceFit> {
662    let rows = points.len();
663    if rows < 2 {
664        return Err(GeomError::InvalidInput(
665            "surface fitting needs at least two rows".to_owned(),
666        ));
667    }
668    let cols = points[0].len();
669    if cols < 2 {
670        return Err(GeomError::InvalidInput(
671            "surface fitting needs at least two columns".to_owned(),
672        ));
673    }
674    for (i, row) in points.iter().enumerate() {
675        if row.len() != cols {
676            return Err(GeomError::InvalidInput(format!(
677                "row {i} has {} points but row 0 has {cols}; the grid must be rectangular",
678                row.len()
679            )));
680        }
681    }
682    let u_degree = validate_degree(options.u_degree)?;
683    let v_degree = validate_degree(options.v_degree)?;
684    if options.u_control_count < u_degree + 1 || options.u_control_count > rows {
685        return Err(GeomError::InvalidInput(format!(
686            "u control point count {} must be between {} and {rows}",
687            options.u_control_count,
688            u_degree + 1
689        )));
690    }
691    if options.v_control_count < v_degree + 1 || options.v_control_count > cols {
692        return Err(GeomError::InvalidInput(format!(
693            "v control point count {} must be between {} and {cols}",
694            options.v_control_count,
695            v_degree + 1
696        )));
697    }
698
699    // Average u-parameters across rows (one chord-length/centripetal/
700    // uniform assignment per row, then averaged column-wise), as Piegl
701    // and Tiller eq. 9.71-9.72 do for interpolation; the same averaging
702    // keeps every row sharing one design matrix for approximation.
703    let mut u_params = vec![0.0; rows];
704    for row in points {
705        let per_row = parameters_for(row, options.parameterisation)?;
706        for (acc, value) in u_params.iter_mut().zip(&per_row) {
707            *acc += value / cols as Scalar;
708        }
709    }
710    u_params[0] = 0.0;
711    *u_params.last_mut().expect("rows checked non-empty") = 1.0;
712
713    let mut v_params = vec![0.0; cols];
714    for j in 0..cols {
715        let column: Vec<Point3> = points.iter().map(|row| row[j]).collect();
716        let per_col = parameters_for(&column, options.parameterisation)?;
717        for (acc, value) in v_params.iter_mut().zip(&per_col) {
718            *acc += value / rows as Scalar;
719        }
720    }
721    v_params[0] = 0.0;
722    *v_params.last_mut().expect("columns checked non-empty") = 1.0;
723
724    let u_knots = approximation_knots(&u_params, u_degree, options.u_control_count);
725    let v_knots = approximation_knots(&v_params, v_degree, options.v_control_count);
726
727    // Fit each row along u: one design matrix shared by every row and
728    // every coordinate.
729    let u_design = design_matrix(&u_params, &u_knots, u_degree, options.u_control_count);
730    let mut intermediate: Vec<Vec<Point3>> =
731        vec![Vec::with_capacity(cols); options.u_control_count];
732    for j in 0..cols {
733        let column: Vec<Point3> = points.iter().map(|row| row[j]).collect();
734        let xs: Vec<Scalar> = column.iter().map(|p| p.x).collect();
735        let ys: Vec<Scalar> = column.iter().map(|p| p.y).collect();
736        let zs: Vec<Scalar> = column.iter().map(|p| p.z).collect();
737        let solved = solve_xyz(
738            &u_design,
739            None,
740            &xs,
741            &ys,
742            &zs,
743            options.u_control_count,
744            false,
745            column[0],
746            column[rows - 1],
747        )?;
748        for (row, point) in intermediate.iter_mut().zip(solved) {
749            row.push(point);
750        }
751    }
752
753    // Fit each row of the intermediate net along v.
754    let v_design = design_matrix(&v_params, &v_knots, v_degree, options.v_control_count);
755    let mut net: Vec<Vec<Point3>> = Vec::with_capacity(options.u_control_count);
756    for row in &intermediate {
757        let xs: Vec<Scalar> = row.iter().map(|p| p.x).collect();
758        let ys: Vec<Scalar> = row.iter().map(|p| p.y).collect();
759        let zs: Vec<Scalar> = row.iter().map(|p| p.z).collect();
760        let solved = solve_xyz(
761            &v_design,
762            None,
763            &xs,
764            &ys,
765            &zs,
766            options.v_control_count,
767            false,
768            row[0],
769            row[cols - 1],
770        )?;
771        net.push(solved);
772    }
773
774    // Deviation: evaluate the tensor-product surface at each grid
775    // point's own (u, v) parameter.
776    let mut max_deviation: Scalar = 0.0;
777    let mut sum_sq = 0.0;
778    for (i, row) in points.iter().enumerate() {
779        for (j, point) in row.iter().enumerate() {
780            let fitted = evaluate_surface(
781                &net,
782                &u_knots,
783                u_degree,
784                &v_knots,
785                v_degree,
786                u_params[i],
787                v_params[j],
788            );
789            let d = (fitted - *point).length();
790            max_deviation = Scalar::max(max_deviation, d);
791            sum_sq += d * d;
792        }
793    }
794    let rms_deviation = (sum_sq / (rows * cols) as Scalar).sqrt();
795
796    let (u_knots_distinct, u_multiplicities) = collapse(&u_knots);
797    let (v_knots_distinct, v_multiplicities) = collapse(&v_knots);
798    let surface = BSplineSurface {
799        u_degree: options.u_degree,
800        v_degree: options.v_degree,
801        control_points: net,
802        u_knots: u_knots_distinct,
803        u_multiplicities,
804        v_knots: v_knots_distinct,
805        v_multiplicities,
806        weights: None,
807        u_closed: false,
808        v_closed: false,
809        knot_spec: KnotSpec::Unspecified,
810        self_intersect: None,
811    };
812    Ok(SurfaceFit {
813        surface,
814        max_deviation,
815        rms_deviation,
816    })
817}
818
819/// Evaluate a tensor-product B-spline surface at `(u, v)` from its
820/// expanded knot vectors and control net.
821fn evaluate_surface(
822    net: &[Vec<Point3>],
823    u_knots: &[Scalar],
824    u_degree: usize,
825    v_knots: &[Scalar],
826    v_degree: usize,
827    u: Scalar,
828    v: Scalar,
829) -> Point3 {
830    let rows = net.len();
831    let cols = net[0].len();
832    let u_span = span_of(u_knots, rows - 1, u_degree, u);
833    let u_basis = basis_at(u_span, u, u_degree, u_knots);
834    let v_span = span_of(v_knots, cols - 1, v_degree, v);
835    let v_basis = basis_at(v_span, v, v_degree, v_knots);
836    let mut acc = Point3::new(0.0, 0.0, 0.0);
837    for (ui, &uw) in u_basis.iter().enumerate() {
838        let row = u_span - u_degree + ui;
839        for (vi, &vw) in v_basis.iter().enumerate() {
840            let col = v_span - v_degree + vi;
841            let weight = uw * vw;
842            let cp = net[row][col];
843            acc = Point3::new(
844                acc.x + weight * cp.x,
845                acc.y + weight * cp.y,
846                acc.z + weight * cp.z,
847            );
848        }
849    }
850    acc
851}