Skip to main content

brepkit_math/nurbs/
fitting.rs

1//! NURBS curve fitting: interpolation and approximation from data points.
2
3#![allow(
4    clippy::many_single_char_names,
5    clippy::similar_names,
6    clippy::suboptimal_flops,
7    clippy::needless_range_loop,
8    clippy::cast_precision_loss,
9    clippy::option_if_let_else,
10    clippy::let_and_return,
11    clippy::doc_markdown,
12    clippy::manual_slice_fill,
13    clippy::missing_const_for_fn
14)]
15
16use crate::MathError;
17use crate::nurbs::basis::basis_funs;
18use crate::nurbs::curve::NurbsCurve;
19use crate::vec::Point3;
20
21/// Interpolate a NURBS curve through a set of data points.
22///
23/// Uses chord-length parameterization and a cubic (degree 3) B-spline.
24/// The resulting curve passes through every input point exactly.
25///
26/// # Parameters
27///
28/// - `points` — the data points to interpolate (at least 2)
29/// - `degree` — polynomial degree (typically 3 for cubic)
30///
31/// # Algorithm
32///
33/// 1. Compute parameters via chord-length parameterization
34/// 2. Build a clamped knot vector
35/// 3. Set up and solve the linear system `N * P = Q` where `N` is the
36///    basis function matrix and `Q` is the data points
37///
38/// # Errors
39///
40/// Returns an error if fewer than 2 points are provided or the degree
41/// is too high for the number of points.
42pub fn interpolate(points: &[Point3], degree: usize) -> Result<NurbsCurve, MathError> {
43    let n = points.len();
44    if n < 2 {
45        return Err(MathError::EmptyInput);
46    }
47
48    let p = degree.min(n - 1);
49
50    let params = chord_length_params(points);
51    let knots = build_interpolation_knots(&params, p, n);
52    let control_points = solve_interpolation(points, &params, &knots, p)?;
53
54    let weights = vec![1.0; n];
55    NurbsCurve::new(p, knots, control_points, weights)
56}
57
58/// Approximate a set of points with a NURBS curve of specified number
59/// of control points.
60///
61/// Uses least-squares fitting: the resulting curve minimizes the sum of
62/// squared distances to the data points. The curve generally does NOT
63/// pass through the points unless `num_control_points == points.len()`.
64///
65/// # Errors
66///
67/// Returns an error if parameters are invalid.
68pub fn approximate(
69    points: &[Point3],
70    degree: usize,
71    num_control_points: usize,
72) -> Result<NurbsCurve, MathError> {
73    let n = points.len();
74    if n < 2 {
75        return Err(MathError::EmptyInput);
76    }
77    if num_control_points < degree + 1 {
78        return Err(MathError::InvalidKnotVector {
79            expected: degree + 1,
80            got: num_control_points,
81        });
82    }
83    if num_control_points > n {
84        return Err(MathError::InvalidKnotVector {
85            expected: n,
86            got: num_control_points,
87        });
88    }
89
90    if num_control_points == n {
91        return interpolate(points, degree);
92    }
93
94    let p = degree.min(num_control_points - 1);
95    let m = num_control_points;
96
97    let params = chord_length_params(points);
98    let knots = build_approximation_knots(&params, p, m, n);
99
100    // Solve least-squares: N^T * N * P = N^T * Q
101    // First and last control points are fixed to the first and last data points.
102    let control_points = solve_approximation(points, &params, &knots, p, m)?;
103
104    let weights = vec![1.0; m];
105    NurbsCurve::new(p, knots, control_points, weights)
106}
107
108// ── Parameterization ───────────────────────────────────────────────
109
110/// Compute chord-length parameters for data points.
111///
112/// Returns parameters in [0, 1] where each parameter is proportional
113/// to the accumulated chord length.
114pub(crate) fn chord_length_params(points: &[Point3]) -> Vec<f64> {
115    let n = points.len();
116    if n <= 1 {
117        return vec![0.0; n];
118    }
119
120    let mut dists = Vec::with_capacity(n);
121    dists.push(0.0);
122    for i in 1..n {
123        let d = (points[i] - points[i - 1]).length();
124        dists.push(dists[i - 1] + d);
125    }
126
127    let total = dists[n - 1];
128    if total < 1e-15 {
129        // All points are coincident — uniform params.
130        #[allow(clippy::cast_precision_loss)]
131        return (0..n).map(|i| (i as f64) / ((n - 1) as f64)).collect();
132    }
133
134    dists.iter().map(|d| d / total).collect()
135}
136
137// ── Knot vector construction ───────────────────────────────────────
138
139/// Build a clamped knot vector for interpolation.
140///
141/// For n control points and degree p:
142/// - First p+1 knots = 0.0
143/// - Middle knots are averages of consecutive parameter values
144/// - Last p+1 knots = 1.0
145#[allow(clippy::cast_precision_loss)]
146fn build_interpolation_knots(params: &[f64], p: usize, n: usize) -> Vec<f64> {
147    let num_knots = n + p + 1;
148    let mut knots = Vec::with_capacity(num_knots);
149
150    // Clamped start.
151    knots.extend(std::iter::repeat_n(0.0, p + 1));
152
153    // Interior knots: averaging (NURBS Book eq 9.69).
154    for j in 1..n - p {
155        #[allow(clippy::cast_precision_loss)]
156        let avg = params[j..j + p].iter().sum::<f64>() / (p as f64);
157        knots.push(avg);
158    }
159
160    // Clamped end.
161    knots.extend(std::iter::repeat_n(1.0, p + 1));
162
163    knots
164}
165
166/// Build a knot vector for least-squares approximation.
167#[allow(
168    clippy::many_single_char_names,
169    clippy::cast_precision_loss,
170    clippy::cast_possible_truncation,
171    clippy::cast_sign_loss
172)]
173pub(crate) fn build_approximation_knots(params: &[f64], p: usize, m: usize, n: usize) -> Vec<f64> {
174    let num_knots = m + p + 1;
175    let mut knots = Vec::with_capacity(num_knots);
176
177    knots.extend(std::iter::repeat_n(0.0, p + 1));
178
179    // Interior knots (NURBS Book eq 9.68).
180    let num_interior = m - p - 1;
181    if num_interior > 0 {
182        #[allow(clippy::cast_precision_loss)]
183        let d = (n as f64) / ((num_interior + 1) as f64);
184        for j in 1..=num_interior {
185            #[allow(
186                clippy::cast_precision_loss,
187                clippy::cast_possible_truncation,
188                clippy::cast_sign_loss
189            )]
190            let i = (j as f64 * d) as usize;
191            let alpha = (j as f64).mul_add(d, -(i as f64));
192            let knot =
193                (1.0 - alpha).mul_add(params[i.min(n - 1)], alpha * params[(i + 1).min(n - 1)]);
194            knots.push(knot);
195        }
196    }
197
198    knots.extend(std::iter::repeat_n(1.0, p + 1));
199
200    knots
201}
202
203// ── Linear system solvers ──────────────────────────────────────────
204
205/// Solve the interpolation linear system for control points.
206///
207/// Sets up `N[i][j] = B_{j,p}(t_i)` and solves `N * P = Q`.
208/// Uses simple Gaussian elimination (sufficient for typical point counts).
209#[allow(clippy::cast_precision_loss, clippy::needless_range_loop)]
210fn solve_interpolation(
211    points: &[Point3],
212    params: &[f64],
213    knots: &[f64],
214    degree: usize,
215) -> Result<Vec<Point3>, MathError> {
216    let n = points.len();
217
218    let mut matrix = vec![vec![0.0; n]; n];
219    for (i, &t) in params.iter().enumerate() {
220        let span = find_span(t, degree, knots, n);
221        let basis = basis_funs(span, t, degree, knots);
222        for (k, &b) in basis.iter().enumerate() {
223            let col = span - degree + k;
224            if col < n {
225                matrix[i][col] = b;
226            }
227        }
228    }
229
230    let mut rhs_x: Vec<f64> = points.iter().map(|p| p.x()).collect();
231    let mut rhs_y: Vec<f64> = points.iter().map(|p| p.y()).collect();
232    let mut rhs_z: Vec<f64> = points.iter().map(|p| p.z()).collect();
233
234    gauss_solve(&mut matrix, &mut rhs_x)?;
235    // Re-build matrix (it was modified in-place).
236    let mut matrix2 = vec![vec![0.0; n]; n];
237    for (i, &t) in params.iter().enumerate() {
238        let span = find_span(t, degree, knots, n);
239        let basis = basis_funs(span, t, degree, knots);
240        for (k, &b) in basis.iter().enumerate() {
241            let col = span - degree + k;
242            if col < n {
243                matrix2[i][col] = b;
244            }
245        }
246    }
247    gauss_solve(&mut matrix2, &mut rhs_y)?;
248
249    let mut matrix3 = vec![vec![0.0; n]; n];
250    for (i, &t) in params.iter().enumerate() {
251        let span = find_span(t, degree, knots, n);
252        let basis = basis_funs(span, t, degree, knots);
253        for (k, &b) in basis.iter().enumerate() {
254            let col = span - degree + k;
255            if col < n {
256                matrix3[i][col] = b;
257            }
258        }
259    }
260    gauss_solve(&mut matrix3, &mut rhs_z)?;
261
262    Ok(rhs_x
263        .iter()
264        .zip(rhs_y.iter())
265        .zip(rhs_z.iter())
266        .map(|((&x, &y), &z)| Point3::new(x, y, z))
267        .collect())
268}
269
270/// Solve least-squares approximation for control points.
271#[allow(
272    clippy::cast_precision_loss,
273    clippy::many_single_char_names,
274    clippy::needless_range_loop
275)]
276fn solve_approximation(
277    points: &[Point3],
278    params: &[f64],
279    knots: &[f64],
280    degree: usize,
281    m: usize,
282) -> Result<Vec<Point3>, MathError> {
283    let n = points.len();
284
285    let mut mat_n = vec![vec![0.0; m]; n];
286    for (i, &t) in params.iter().enumerate() {
287        let span = find_span(t, degree, knots, m);
288        let basis = basis_funs(span, t, degree, knots);
289        for (k, &b) in basis.iter().enumerate() {
290            let col = span - degree + k;
291            if col < m {
292                mat_n[i][col] = b;
293            }
294        }
295    }
296
297    // N^T * N (m×m) and N^T * Q (m×3).
298    let mut ntn = vec![vec![0.0; m]; m];
299    let mut ntq_x = vec![0.0; m];
300    let mut ntq_y = vec![0.0; m];
301    let mut ntq_z = vec![0.0; m];
302
303    for i in 0..m {
304        for j in 0..m {
305            for k in 0..n {
306                ntn[i][j] += mat_n[k][i] * mat_n[k][j];
307            }
308        }
309        for k in 0..n {
310            ntq_x[i] += mat_n[k][i] * points[k].x();
311            ntq_y[i] += mat_n[k][i] * points[k].y();
312            ntq_z[i] += mat_n[k][i] * points[k].z();
313        }
314    }
315
316    // Fix first and last control points: zero the first/last rows and cols,
317    // set diagonal to 1 so the endpoints are interpolated exactly.
318    for j in 0..m {
319        ntn[0][j] = 0.0;
320        ntn[m - 1][j] = 0.0;
321        ntn[j][0] = 0.0;
322        ntn[j][m - 1] = 0.0;
323    }
324    ntn[0][0] = 1.0;
325    ntn[m - 1][m - 1] = 1.0;
326    ntq_x[0] = points[0].x();
327    ntq_y[0] = points[0].y();
328    ntq_z[0] = points[0].z();
329    ntq_x[m - 1] = points[n - 1].x();
330    ntq_y[m - 1] = points[n - 1].y();
331    ntq_z[m - 1] = points[n - 1].z();
332
333    gauss_solve(&mut ntn.clone(), &mut ntq_x)?;
334    gauss_solve(&mut ntn.clone(), &mut ntq_y)?;
335    gauss_solve(&mut ntn, &mut ntq_z)?;
336
337    Ok(ntq_x
338        .iter()
339        .zip(ntq_y.iter())
340        .zip(ntq_z.iter())
341        .map(|((&x, &y), &z)| Point3::new(x, y, z))
342        .collect())
343}
344
345// ── Helpers ────────────────────────────────────────────────────────
346
347/// Find the knot span index for parameter t.
348pub(crate) fn find_span(t: f64, degree: usize, knots: &[f64], n: usize) -> usize {
349    if t >= knots[n] {
350        return n - 1;
351    }
352    if t <= knots[degree] {
353        return degree;
354    }
355
356    let mut low = degree;
357    let mut high = n;
358    let mut mid = low.midpoint(high);
359
360    while t < knots[mid] || t >= knots[mid + 1] {
361        if t < knots[mid] {
362            high = mid;
363        } else {
364            low = mid;
365        }
366        mid = low.midpoint(high);
367    }
368
369    mid
370}
371
372/// Solve a linear system `Ax = b` using Gaussian elimination with partial pivoting.
373#[allow(clippy::needless_range_loop)]
374fn gauss_solve(a: &mut [Vec<f64>], b: &mut [f64]) -> Result<(), MathError> {
375    let n = b.len();
376
377    // Forward elimination.
378    for k in 0..n {
379        let mut max_val = a[k][k].abs();
380        let mut max_row = k;
381        for i in (k + 1)..n {
382            if a[i][k].abs() > max_val {
383                max_val = a[i][k].abs();
384                max_row = i;
385            }
386        }
387
388        if max_val < 1e-15 {
389            return Err(MathError::SingularMatrix);
390        }
391
392        if max_row != k {
393            a.swap(k, max_row);
394            b.swap(k, max_row);
395        }
396
397        for i in (k + 1)..n {
398            let factor = a[i][k] / a[k][k];
399            for j in (k + 1)..n {
400                a[i][j] -= factor * a[k][j];
401            }
402            a[i][k] = 0.0;
403            b[i] -= factor * b[k];
404        }
405    }
406
407    // Back substitution.
408    for i in (0..n).rev() {
409        let mut sum = b[i];
410        for j in (i + 1)..n {
411            sum -= a[i][j] * b[j];
412        }
413        b[i] = sum / a[i][i];
414    }
415
416    Ok(())
417}
418
419// ── LSPIA step-size computation ───────────────────────────────────────
420
421/// Estimate the largest eigenvalue of `N^T N` via power iteration, then
422/// compute a conservative LSPIA step size `mu = 1 / lambda_max`.
423///
424/// `N` is the (n_data x m_cps) basis-function matrix stored implicitly
425/// via the sparse `basis_data` representation.
426fn compute_lspia_step_size(basis_data: &[(usize, Vec<f64>)], degree: usize, m: usize) -> f64 {
427    // Power iteration: approximate lambda_max of N^T N.
428    let mut v = vec![1.0f64; m];
429    let norm = (m as f64).sqrt();
430    for val in &mut v {
431        *val /= norm;
432    }
433
434    for _ in 0..20 {
435        // w = N^T N v, computed as N^T (N v) to avoid forming N^T N.
436        let nv: Vec<f64> = basis_data
437            .iter()
438            .map(|(span, n_vals)| {
439                let mut sum = 0.0f64;
440                for (k, &bk) in n_vals.iter().enumerate() {
441                    let j = span - degree + k;
442                    if j < m {
443                        sum += bk * v[j];
444                    }
445                }
446                sum
447            })
448            .collect();
449        let mut w = vec![0.0f64; m];
450        for (i, (span, n_vals)) in basis_data.iter().enumerate() {
451            for (k, &bk) in n_vals.iter().enumerate() {
452                let j = span - degree + k;
453                if j < m {
454                    w[j] += bk * nv[i];
455                }
456            }
457        }
458
459        let mag = w.iter().map(|x| x * x).sum::<f64>().sqrt();
460        if mag < 1e-30 {
461            return 1.0;
462        }
463        for val in &mut v {
464            *val = 0.0;
465        }
466        for (j, &wj) in w.iter().enumerate() {
467            v[j] = wj / mag;
468        }
469    }
470
471    // Compute Rayleigh quotient: lambda = v^T (N^T N v) / (v^T v).
472    let nv: Vec<f64> = basis_data
473        .iter()
474        .map(|(span, n_vals)| {
475            let mut sum = 0.0f64;
476            for (k, &bk) in n_vals.iter().enumerate() {
477                let j = span - degree + k;
478                if j < m {
479                    sum += bk * v[j];
480                }
481            }
482            sum
483        })
484        .collect();
485    let lambda_max = nv.iter().map(|x| x * x).sum::<f64>();
486
487    if lambda_max < 1e-30 {
488        1.0
489    } else {
490        // Conservative: mu = 1 / lambda_max ensures convergence.
491        1.0 / lambda_max
492    }
493}
494
495/// Weighted variant of LSPIA step-size computation.
496///
497/// Computes `mu = 1 / lambda_max` for the weighted normal equations
498/// `N^T W N` where `W = diag(point_weights)`.
499fn compute_lspia_step_size_weighted(
500    basis_data: &[(usize, Vec<f64>)],
501    point_weights: &[f64],
502    degree: usize,
503    m: usize,
504) -> f64 {
505    let mut v = vec![1.0f64; m];
506    let norm = (m as f64).sqrt();
507    for val in &mut v {
508        *val /= norm;
509    }
510
511    for _ in 0..20 {
512        let nv: Vec<f64> = basis_data
513            .iter()
514            .map(|(span, n_vals)| {
515                let mut sum = 0.0f64;
516                for (k, &bk) in n_vals.iter().enumerate() {
517                    let j = span - degree + k;
518                    if j < m {
519                        sum += bk * v[j];
520                    }
521                }
522                sum
523            })
524            .collect();
525        let mut w = vec![0.0f64; m];
526        for (i, (span, n_vals)) in basis_data.iter().enumerate() {
527            let pw = point_weights[i];
528            for (k, &bk) in n_vals.iter().enumerate() {
529                let j = span - degree + k;
530                if j < m {
531                    w[j] += pw * bk * nv[i];
532                }
533            }
534        }
535
536        let mag = w.iter().map(|x| x * x).sum::<f64>().sqrt();
537        if mag < 1e-30 {
538            return 1.0;
539        }
540        for val in &mut v {
541            *val = 0.0;
542        }
543        for (j, &wj) in w.iter().enumerate() {
544            v[j] = wj / mag;
545        }
546    }
547
548    let nv: Vec<f64> = basis_data
549        .iter()
550        .map(|(span, n_vals)| {
551            let mut sum = 0.0f64;
552            for (k, &bk) in n_vals.iter().enumerate() {
553                let j = span - degree + k;
554                if j < m {
555                    sum += bk * v[j];
556                }
557            }
558            sum
559        })
560        .collect();
561    let lambda_max: f64 = nv
562        .iter()
563        .zip(point_weights.iter())
564        .map(|(&x, &pw)| pw * x * x)
565        .sum();
566
567    if lambda_max < 1e-30 {
568        1.0
569    } else {
570        1.0 / lambda_max
571    }
572}
573
574// ── LSPIA (Locally Supported Progressive-Iterative Approximation) ─────
575
576/// Approximate a NURBS curve through data points using Progressive-Iterative Approximation.
577///
578/// LSPIA iteratively adjusts control points to minimize the least-squares error,
579/// achieving O(n) per iteration vs O(n^3) for direct Gaussian elimination.
580/// Converges when the maximum point deviation falls below `tolerance`.
581///
582/// # Parameters
583///
584/// - `points` -- data points to approximate
585/// - `degree` -- polynomial degree (typically 3)
586/// - `num_control_points` -- number of control points (must be <= `points.len()`)
587/// - `tolerance` -- convergence threshold for max point deviation
588/// - `max_iterations` -- maximum number of PIA iterations
589///
590/// # Algorithm
591///
592/// 1. Compute parameters via chord-length parameterization
593/// 2. Build a clamped knot vector for the desired number of CPs
594/// 3. Initialize CPs by sampling the parameter-position mapping
595/// 4. Iterate: compute errors at data points, distribute corrections
596///    to CPs weighted by basis function values
597///
598/// # Errors
599///
600/// Returns [`MathError::EmptyInput`] if fewer than 2 points.
601/// If the iteration has not converged after `max_iterations`, the best-effort
602/// curve (lowest observed error) is returned as `Ok`; no error is raised.
603/// In debug builds (`#[cfg(debug_assertions)]`) a `log::warn!` is emitted;
604/// release builds are silent.
605#[allow(clippy::cast_precision_loss)]
606pub fn approximate_lspia(
607    points: &[Point3],
608    degree: usize,
609    num_control_points: usize,
610    tolerance: f64,
611    max_iterations: usize,
612) -> Result<NurbsCurve, MathError> {
613    let n = points.len();
614    if n < 2 {
615        return Err(MathError::EmptyInput);
616    }
617
618    let p = degree.min(n - 1);
619    let m = num_control_points.min(n).max(p + 1);
620
621    let params = chord_length_params(points);
622    let knots = build_approximation_knots(&params, p, m, n);
623
624    // Initialize control points by sampling closest data points.
625    let mut control_points = Vec::with_capacity(m);
626    for i in 0..m {
627        let t = if m > 1 {
628            i as f64 / (m - 1) as f64
629        } else {
630            0.0
631        };
632        let mut best_idx = 0;
633        let mut best_dist = f64::INFINITY;
634        for (j, &param) in params.iter().enumerate() {
635            let d = (param - t).abs();
636            if d < best_dist {
637                best_dist = d;
638                best_idx = j;
639            }
640        }
641        control_points.push(points[best_idx]);
642    }
643
644    let weights = vec![1.0; m];
645
646    let mut basis_data: Vec<(usize, Vec<f64>)> = Vec::with_capacity(n);
647    for &u in &params {
648        let span = find_span(u, p, &knots, m);
649        let n_vals = basis_funs(span, u, p, &knots);
650        basis_data.push((span, n_vals));
651    }
652
653    // mu = 2 / (lambda_min + lambda_max) where lambda are eigenvalues of N^T N.
654    // We approximate lambda_max via the power method and use a conservative mu.
655    let mu = compute_lspia_step_size(&basis_data, p, m);
656
657    for iter in 0..max_iterations {
658        let curve = NurbsCurve::new(p, knots.clone(), control_points.clone(), weights.clone())?;
659
660        let mut max_err = 0.0f64;
661        let mut deltas = vec![(0.0f64, 0.0f64, 0.0f64); m];
662
663        for (i, &u) in params.iter().enumerate() {
664            let q = curve.evaluate(u);
665            let err_x = points[i].x() - q.x();
666            let err_y = points[i].y() - q.y();
667            let err_z = points[i].z() - q.z();
668            let err_mag = (err_x * err_x + err_y * err_y + err_z * err_z).sqrt();
669            max_err = max_err.max(err_mag);
670
671            let (span, n_vals) = &basis_data[i];
672            for (k, &nv) in n_vals.iter().enumerate() {
673                let j = span - p + k;
674                if j < m {
675                    deltas[j].0 += nv * err_x;
676                    deltas[j].1 += nv * err_y;
677                    deltas[j].2 += nv * err_z;
678                }
679            }
680        }
681
682        if max_err < tolerance {
683            return NurbsCurve::new(p, knots, control_points, weights);
684        }
685
686        // Update control points: P_j += mu * delta_j
687        for j in 0..m {
688            control_points[j] = Point3::new(
689                mu.mul_add(deltas[j].0, control_points[j].x()),
690                mu.mul_add(deltas[j].1, control_points[j].y()),
691                mu.mul_add(deltas[j].2, control_points[j].z()),
692            );
693        }
694
695        // Return best result if this is the last iteration.
696        if iter == max_iterations - 1 {
697            // LSPIA did not converge to the requested tolerance.  Warn in debug
698            // builds so callers can detect poorly fitted curves.
699            #[cfg(debug_assertions)]
700            if max_err > tolerance {
701                log::warn!(
702                    "approximate_lspia: did not converge after {max_iterations} iterations \
703                     (max_err={max_err:.2e}, tolerance={tolerance:.2e}, \
704                     num_cps={m}, degree={p})"
705                );
706            }
707            return NurbsCurve::new(p, knots, control_points, weights);
708        }
709    }
710
711    // Unreachable if max_iterations > 0, but handle edge case.
712    NurbsCurve::new(p, knots, control_points, weights)
713}
714
715/// Weighted LSPIA approximation with per-point weights.
716///
717/// Points with higher weights have more influence on the fit. This is useful
718/// for emphasizing certain regions of the curve or for progressive refinement.
719///
720/// # Errors
721///
722/// Returns [`MathError::EmptyInput`] if fewer than 2 points.
723/// Returns [`MathError::InvalidWeights`] if `point_weights.len()` does not match
724/// `points.len()`.
725#[allow(clippy::cast_precision_loss, clippy::too_many_arguments, dead_code)]
726pub(crate) fn approximate_lspia_weighted(
727    points: &[Point3],
728    point_weights: &[f64],
729    degree: usize,
730    num_control_points: usize,
731    tolerance: f64,
732    max_iterations: usize,
733) -> Result<NurbsCurve, MathError> {
734    let n = points.len();
735    if n < 2 {
736        return Err(MathError::EmptyInput);
737    }
738    if points.len() != point_weights.len() {
739        return Err(MathError::InvalidWeights {
740            expected: points.len(),
741            got: point_weights.len(),
742        });
743    }
744
745    let p = degree.min(n - 1);
746    let m = num_control_points.min(n).max(p + 1);
747
748    let params = chord_length_params(points);
749    let knots = build_approximation_knots(&params, p, m, n);
750
751    // Initialize control points by sampling closest data points.
752    let mut control_points = Vec::with_capacity(m);
753    for i in 0..m {
754        let t = if m > 1 {
755            i as f64 / (m - 1) as f64
756        } else {
757            0.0
758        };
759        let mut best_idx = 0;
760        let mut best_dist = f64::INFINITY;
761        for (j, &param) in params.iter().enumerate() {
762            let d = (param - t).abs();
763            if d < best_dist {
764                best_dist = d;
765                best_idx = j;
766            }
767        }
768        control_points.push(points[best_idx]);
769    }
770
771    let weights = vec![1.0; m];
772
773    let mut basis_data: Vec<(usize, Vec<f64>)> = Vec::with_capacity(n);
774    for &u in &params {
775        let span = find_span(u, p, &knots, m);
776        let n_vals = basis_funs(span, u, p, &knots);
777        basis_data.push((span, n_vals));
778    }
779
780    let mu = compute_lspia_step_size_weighted(&basis_data, point_weights, p, m);
781
782    for iter in 0..max_iterations {
783        let curve = NurbsCurve::new(p, knots.clone(), control_points.clone(), weights.clone())?;
784
785        let mut max_err = 0.0f64;
786        let mut deltas = vec![(0.0f64, 0.0f64, 0.0f64); m];
787
788        for (i, &u) in params.iter().enumerate() {
789            let q = curve.evaluate(u);
790            let pw = point_weights[i];
791            let err_x = points[i].x() - q.x();
792            let err_y = points[i].y() - q.y();
793            let err_z = points[i].z() - q.z();
794            let err_mag = (err_x * err_x + err_y * err_y + err_z * err_z).sqrt();
795            max_err = max_err.max(err_mag);
796
797            let (span, n_vals) = &basis_data[i];
798            for (k, &nv) in n_vals.iter().enumerate() {
799                let j = span - p + k;
800                if j < m {
801                    deltas[j].0 += pw * nv * err_x;
802                    deltas[j].1 += pw * nv * err_y;
803                    deltas[j].2 += pw * nv * err_z;
804                }
805            }
806        }
807
808        if max_err < tolerance {
809            return NurbsCurve::new(p, knots, control_points, weights);
810        }
811
812        // Update control points: P_j += mu * delta_j
813        for j in 0..m {
814            control_points[j] = Point3::new(
815                mu.mul_add(deltas[j].0, control_points[j].x()),
816                mu.mul_add(deltas[j].1, control_points[j].y()),
817                mu.mul_add(deltas[j].2, control_points[j].z()),
818            );
819        }
820
821        if iter == max_iterations - 1 {
822            return NurbsCurve::new(p, knots, control_points, weights);
823        }
824    }
825
826    NurbsCurve::new(p, knots, control_points, weights)
827}
828
829#[cfg(test)]
830mod tests {
831    #![allow(clippy::unwrap_used, clippy::cast_lossless, clippy::suboptimal_flops)]
832
833    use crate::tolerance::Tolerance;
834    use crate::vec::Point3;
835
836    use super::*;
837
838    #[test]
839    fn interpolate_two_points_is_line() {
840        let pts = vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)];
841        let curve = interpolate(&pts, 1).unwrap();
842
843        let tol = Tolerance::new();
844        let mid = curve.evaluate(0.5);
845        assert!(tol.approx_eq(mid.x(), 0.5));
846        assert!(tol.approx_eq(mid.y(), 0.0));
847    }
848
849    #[test]
850    fn interpolate_passes_through_points() {
851        let pts = vec![
852            Point3::new(0.0, 0.0, 0.0),
853            Point3::new(1.0, 1.0, 0.0),
854            Point3::new(2.0, 0.0, 0.0),
855            Point3::new(3.0, 1.0, 0.0),
856        ];
857        let curve = interpolate(&pts, 3).unwrap();
858
859        let tol = Tolerance::new();
860        let p0 = curve.evaluate(0.0);
861        let p1 = curve.evaluate(1.0);
862        assert!(tol.approx_eq(p0.x(), 0.0), "start x: {}", p0.x());
863        assert!(tol.approx_eq(p0.y(), 0.0), "start y: {}", p0.y());
864        assert!(tol.approx_eq(p1.x(), 3.0), "end x: {}", p1.x());
865        assert!(tol.approx_eq(p1.y(), 1.0), "end y: {}", p1.y());
866    }
867
868    #[test]
869    fn interpolate_3d_points() {
870        let pts = vec![
871            Point3::new(0.0, 0.0, 0.0),
872            Point3::new(1.0, 1.0, 1.0),
873            Point3::new(2.0, 0.0, 2.0),
874        ];
875        let curve = interpolate(&pts, 2).unwrap();
876
877        let tol = Tolerance::new();
878        let p0 = curve.evaluate(0.0);
879        let p1 = curve.evaluate(1.0);
880        assert!(tol.approx_eq(p0.x(), 0.0));
881        assert!(tol.approx_eq(p1.x(), 2.0));
882        assert!(tol.approx_eq(p1.z(), 2.0));
883    }
884
885    #[test]
886    fn interpolate_single_point_error() {
887        let pts = vec![Point3::new(0.0, 0.0, 0.0)];
888        assert!(interpolate(&pts, 3).is_err());
889    }
890
891    #[test]
892    fn approximate_fewer_control_points() {
893        let pts = vec![
894            Point3::new(0.0, 0.0, 0.0),
895            Point3::new(0.5, 0.8, 0.0),
896            Point3::new(1.0, 1.0, 0.0),
897            Point3::new(1.5, 0.8, 0.0),
898            Point3::new(2.0, 0.0, 0.0),
899        ];
900        let curve = approximate(&pts, 3, 4).unwrap();
901
902        // Endpoints should be exact.
903        let tol = Tolerance::new();
904        let p0 = curve.evaluate(0.0);
905        let p1 = curve.evaluate(1.0);
906        assert!(tol.approx_eq(p0.x(), 0.0), "start x: {}", p0.x());
907        assert!(tol.approx_eq(p1.x(), 2.0), "end x: {}", p1.x());
908    }
909
910    #[test]
911    fn approximate_equals_interpolate_when_same_count() {
912        let pts = vec![
913            Point3::new(0.0, 0.0, 0.0),
914            Point3::new(1.0, 1.0, 0.0),
915            Point3::new(2.0, 0.0, 0.0),
916        ];
917
918        let interp = interpolate(&pts, 2).unwrap();
919        let approx = approximate(&pts, 2, 3).unwrap();
920
921        let tol = Tolerance::new();
922        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
923            let pi = interp.evaluate(t);
924            let pa = approx.evaluate(t);
925            assert!(
926                tol.approx_eq(pi.x(), pa.x()) && tol.approx_eq(pi.y(), pa.y()),
927                "at t={t}: interp=({}, {}) approx=({}, {})",
928                pi.x(),
929                pi.y(),
930                pa.x(),
931                pa.y()
932            );
933        }
934    }
935
936    // ── LSPIA tests ────────────────────────────────────────────────────
937
938    #[test]
939    #[allow(clippy::cast_precision_loss)]
940    fn lspia_fits_line() {
941        let points: Vec<Point3> = (0..10)
942            .map(|i| {
943                let t = i as f64 / 9.0;
944                Point3::new(t, 2.0f64.mul_add(t, 1.0), 0.0)
945            })
946            .collect();
947        let curve = approximate_lspia(&points, 3, 6, 1e-6, 100).unwrap();
948
949        // Check endpoints.
950        let p0 = curve.evaluate(0.0);
951        let p1 = curve.evaluate(1.0);
952        assert!(
953            (p0.x() - 0.0).abs() < 0.01,
954            "start x: expected ~0.0, got {}",
955            p0.x()
956        );
957        assert!(
958            (p1.x() - 1.0).abs() < 0.01,
959            "end x: expected ~1.0, got {}",
960            p1.x()
961        );
962    }
963
964    #[test]
965    #[allow(clippy::cast_precision_loss)]
966    fn lspia_fits_circle() {
967        let n = 50;
968        let points: Vec<Point3> = (0..n)
969            .map(|i| {
970                let t = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
971                Point3::new(t.cos(), t.sin(), 0.0)
972            })
973            .collect();
974        let curve = approximate_lspia(&points, 3, 15, 1e-4, 200).unwrap();
975
976        for i in 0..10 {
977            let t = i as f64 / 9.0;
978            let p = curve.evaluate(t);
979            let r = (p.x() * p.x() + p.y() * p.y()).sqrt();
980            assert!((r - 1.0).abs() < 0.15, "radius at t={t} is {r}");
981        }
982    }
983
984    #[test]
985    #[allow(clippy::cast_precision_loss)]
986    fn lspia_fewer_cps_than_points() {
987        let points: Vec<Point3> = (0..100)
988            .map(|i| {
989                let t = i as f64 / 99.0;
990                Point3::new(t, (t * 6.0).sin(), 0.0)
991            })
992            .collect();
993        let curve = approximate_lspia(&points, 3, 20, 1e-3, 100).unwrap();
994        let p = curve.evaluate(0.5);
995        assert!(
996            (p.x() - 0.5).abs() < 0.1,
997            "midpoint x: expected ~0.5, got {}",
998            p.x()
999        );
1000    }
1001
1002    #[test]
1003    fn lspia_empty_input_returns_error() {
1004        let result = approximate_lspia(&[], 3, 5, 1e-6, 100);
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    #[allow(clippy::cast_precision_loss)]
1010    fn lspia_weighted_emphasizes_region() {
1011        let points: Vec<Point3> = (0..20)
1012            .map(|i| {
1013                let t = i as f64 / 19.0;
1014                Point3::new(t, t * t, 0.0)
1015            })
1016            .collect();
1017        let uniform_weights = vec![1.0; 20];
1018        let curve = approximate_lspia_weighted(&points, &uniform_weights, 3, 8, 1e-5, 100).unwrap();
1019
1020        let p = curve.evaluate(0.5);
1021        assert!(
1022            (p.x() - 0.5).abs() < 0.15,
1023            "midpoint x: expected ~0.5, got {}",
1024            p.x()
1025        );
1026    }
1027
1028    #[test]
1029    fn lspia_weighted_mismatched_lengths_returns_error() {
1030        let points = vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 0.0)];
1031        let weights = vec![1.0; 5];
1032        let result = approximate_lspia_weighted(&points, &weights, 1, 2, 1e-6, 10);
1033        assert!(result.is_err());
1034    }
1035}