Skip to main content

brepkit_math/nurbs/
surface_fitting.rs

1//! NURBS surface fitting from a grid of data points via least-squares
2//! progressive-iterative approximation (LSPIA).
3
4#![allow(
5    clippy::many_single_char_names,
6    clippy::similar_names,
7    clippy::suboptimal_flops,
8    clippy::needless_range_loop,
9    clippy::cast_precision_loss,
10    clippy::doc_markdown
11)]
12
13use crate::MathError;
14use crate::nurbs::basis::basis_funs;
15use crate::nurbs::fitting;
16use crate::nurbs::fitting::interpolate;
17use crate::nurbs::surface::NurbsSurface;
18use crate::vec::Point3;
19
20/// Interpolate a NURBS surface through a grid of data points.
21///
22/// The input is a row-major grid where `points[i][j]` is the point
23/// at row `i`, column `j`. All rows must have the same number of
24/// columns. The resulting surface passes through every input point.
25///
26/// # Parameters
27///
28/// - `points` — 2D grid of data points (rows × cols)
29/// - `degree_u` — polynomial degree in the u (row) direction
30/// - `degree_v` — polynomial degree in the v (column) direction
31///
32/// # Algorithm
33///
34/// Uses the tensor-product approach:
35/// 1. For each row, fit a NURBS curve through the columns
36/// 2. Extract control points from those curves (they form a grid)
37/// 3. For each column of control points, fit another NURBS curve
38/// 4. The final control point grid and combined knot vectors form
39///    the interpolated surface
40///
41/// # Errors
42///
43/// Returns an error if the grid is too small or rows have inconsistent
44/// lengths.
45pub fn interpolate_surface(
46    points: &[Vec<Point3>],
47    degree_u: usize,
48    degree_v: usize,
49) -> Result<NurbsSurface, MathError> {
50    let num_rows = points.len();
51    if num_rows < 2 {
52        return Err(MathError::EmptyInput);
53    }
54
55    let num_cols = points[0].len();
56    if num_cols < 2 {
57        return Err(MathError::EmptyInput);
58    }
59
60    for row in points {
61        if row.len() != num_cols {
62            return Err(MathError::InvalidControlPointGrid {
63                expected_rows: num_rows,
64                expected_cols: num_cols,
65            });
66        }
67    }
68
69    // Step 1: Fit curves through each row (u-direction).
70    let mut row_curves = Vec::with_capacity(num_rows);
71    for row in points {
72        let curve = interpolate(row, degree_v)?;
73        row_curves.push(curve);
74    }
75
76    // All row curves should have the same number of control points
77    // (= num_cols, since we're interpolating exactly).
78    let n_cp_v = row_curves[0].control_points().len();
79
80    // Step 2: Extract column-wise control points from row curves.
81    // cp_grid[col][row] = row_curves[row].control_point[col]
82    let mut col_points: Vec<Vec<Point3>> = Vec::with_capacity(n_cp_v);
83    for j in 0..n_cp_v {
84        let col: Vec<Point3> = row_curves.iter().map(|c| c.control_points()[j]).collect();
85        col_points.push(col);
86    }
87
88    // Step 3: Fit curves through each column (v-direction).
89    let mut col_curves = Vec::with_capacity(n_cp_v);
90    for col in &col_points {
91        let curve = interpolate(col, degree_u)?;
92        col_curves.push(curve);
93    }
94
95    // Step 4: Extract the final control point grid and knot vectors.
96    let n_cp_u = col_curves[0].control_points().len();
97    let knots_u = col_curves[0].knots().to_vec();
98    let knots_v = row_curves[0].knots().to_vec();
99
100    let mut control_points: Vec<Vec<Point3>> = Vec::with_capacity(n_cp_u);
101    for i in 0..n_cp_u {
102        let row: Vec<Point3> = col_curves.iter().map(|c| c.control_points()[i]).collect();
103        control_points.push(row);
104    }
105
106    let weights: Vec<Vec<f64>> = control_points
107        .iter()
108        .map(|row| vec![1.0; row.len()])
109        .collect();
110
111    NurbsSurface::new(
112        degree_u,
113        degree_v,
114        knots_u,
115        knots_v,
116        control_points,
117        weights,
118    )
119}
120
121/// Approximate a NURBS surface through a grid of data points using LSPIA.
122///
123/// Uses tensor-product basis `N_i(u) * N_j(v)` for iterative refinement.
124/// This is more efficient than direct least-squares for large grids, achieving
125/// O(rows * cols) per iteration.
126///
127/// # Parameters
128///
129/// - `points` -- 2D grid of data points (rows x cols)
130/// - `degree_u`, `degree_v` -- polynomial degrees in each direction
131/// - `num_cps_u`, `num_cps_v` -- number of control points in each direction
132/// - `tolerance` -- convergence threshold for max point deviation
133/// - `max_iterations` -- maximum number of PIA iterations
134///
135/// # Errors
136///
137/// Returns [`MathError::EmptyInput`] if the grid is empty.
138/// Returns [`MathError::InvalidControlPointGrid`] if rows have inconsistent lengths.
139#[allow(
140    clippy::too_many_arguments,
141    clippy::too_many_lines,
142    clippy::cast_precision_loss,
143    clippy::needless_range_loop
144)]
145pub fn approximate_surface_lspia(
146    points: &[Vec<Point3>],
147    degree_u: usize,
148    degree_v: usize,
149    num_cps_u: usize,
150    num_cps_v: usize,
151    tolerance: f64,
152    max_iterations: usize,
153) -> Result<NurbsSurface, MathError> {
154    if points.is_empty() || points[0].is_empty() {
155        return Err(MathError::EmptyInput);
156    }
157    let rows = points.len();
158    let cols = points[0].len();
159    for row in points {
160        if row.len() != cols {
161            return Err(MathError::InvalidControlPointGrid {
162                expected_rows: rows,
163                expected_cols: cols,
164            });
165        }
166    }
167
168    let pu = degree_u.min(rows - 1);
169    let pv = degree_v.min(cols - 1);
170    let mu = num_cps_u.min(rows).max(pu + 1);
171    let mv = num_cps_v.min(cols).max(pv + 1);
172
173    let params_u = compute_grid_params_u(points, rows, cols);
174    let params_v = compute_grid_params_v(points, rows, cols);
175
176    let knots_u = fitting::build_approximation_knots(&params_u, pu, mu, rows);
177    let knots_v = fitting::build_approximation_knots(&params_v, pv, mv, cols);
178
179    // Initialize control points grid (mu x mv).
180    let mut cps: Vec<Vec<Point3>> = Vec::with_capacity(mu);
181    for i in 0..mu {
182        let mut row = Vec::with_capacity(mv);
183        for j in 0..mv {
184            let ui = if mu > 1 { i * (rows - 1) / (mu - 1) } else { 0 };
185            let vj = if mv > 1 { j * (cols - 1) / (mv - 1) } else { 0 };
186            row.push(points[ui.min(rows - 1)][vj.min(cols - 1)]);
187        }
188        cps.push(row);
189    }
190
191    let weights = vec![vec![1.0; mv]; mu];
192
193    let basis_u_data: Vec<(usize, Vec<f64>)> = params_u
194        .iter()
195        .map(|&u| {
196            let span = fitting::find_span(u, pu, &knots_u, mu);
197            let n = basis_funs(span, u, pu, &knots_u);
198            (span, n)
199        })
200        .collect();
201    let basis_v_data: Vec<(usize, Vec<f64>)> = params_v
202        .iter()
203        .map(|&v| {
204            let span = fitting::find_span(v, pv, &knots_v, mv);
205            let n = basis_funs(span, v, pv, &knots_v);
206            (span, n)
207        })
208        .collect();
209
210    // Compute step size for LSPIA convergence.
211    // Estimate lambda_max per direction via max column sum of squared basis values.
212    let mu_u = col_sum_lambda(&basis_u_data, pu, mu);
213    let mu_v = col_sum_lambda(&basis_v_data, pv, mv);
214    let lambda_max = mu_u * mu_v;
215    let step: f64 = if lambda_max < 1e-30 {
216        1.0
217    } else {
218        1.0 / lambda_max
219    };
220
221    for iter in 0..max_iterations {
222        let surface = NurbsSurface::new(
223            pu,
224            pv,
225            knots_u.clone(),
226            knots_v.clone(),
227            cps.clone(),
228            weights.clone(),
229        )?;
230
231        let mut max_err = 0.0f64;
232        let mut deltas = vec![vec![(0.0f64, 0.0f64, 0.0f64); mv]; mu];
233
234        for (i, (su, nu)) in basis_u_data.iter().enumerate() {
235            for (j, (sv, nv)) in basis_v_data.iter().enumerate() {
236                let q = surface.evaluate(params_u[i], params_v[j]);
237                let err_x = points[i][j].x() - q.x();
238                let err_y = points[i][j].y() - q.y();
239                let err_z = points[i][j].z() - q.z();
240                let err_mag = (err_x * err_x + err_y * err_y + err_z * err_z).sqrt();
241                max_err = max_err.max(err_mag);
242
243                for (ku, &bu) in nu.iter().enumerate() {
244                    for (kv, &bv) in nv.iter().enumerate() {
245                        let ci = su - pu + ku;
246                        let cj = sv - pv + kv;
247                        if ci < mu && cj < mv {
248                            let w = bu * bv;
249                            deltas[ci][cj].0 += w * err_x;
250                            deltas[ci][cj].1 += w * err_y;
251                            deltas[ci][cj].2 += w * err_z;
252                        }
253                    }
254                }
255            }
256        }
257
258        if max_err < tolerance {
259            return NurbsSurface::new(pu, pv, knots_u, knots_v, cps, weights);
260        }
261
262        // Update control points: CP += step * delta.
263        for i in 0..mu {
264            for j in 0..mv {
265                cps[i][j] = Point3::new(
266                    step.mul_add(deltas[i][j].0, cps[i][j].x()),
267                    step.mul_add(deltas[i][j].1, cps[i][j].y()),
268                    step.mul_add(deltas[i][j].2, cps[i][j].z()),
269                );
270            }
271        }
272
273        if iter == max_iterations - 1 {
274            return NurbsSurface::new(pu, pv, knots_u, knots_v, cps, weights);
275        }
276    }
277
278    NurbsSurface::new(pu, pv, knots_u, knots_v, cps, weights)
279}
280
281/// Compute average chord-length parameters in the u direction (across rows).
282#[allow(clippy::cast_precision_loss)]
283fn compute_grid_params_u(points: &[Vec<Point3>], rows: usize, cols: usize) -> Vec<f64> {
284    let mut params = vec![0.0f64; rows];
285    for j in 0..cols {
286        let col: Vec<Point3> = (0..rows).map(|i| points[i][j]).collect();
287        let col_params = fitting::chord_length_params(&col);
288        for (i, &p) in col_params.iter().enumerate() {
289            params[i] += p;
290        }
291    }
292    let cols_f = cols as f64;
293    for p in &mut params {
294        *p /= cols_f;
295    }
296    params
297}
298
299/// Compute average chord-length parameters in the v direction (across columns).
300#[allow(clippy::cast_precision_loss)]
301fn compute_grid_params_v(points: &[Vec<Point3>], rows: usize, cols: usize) -> Vec<f64> {
302    let mut params = vec![0.0f64; cols];
303    for i in 0..rows {
304        let row_params = fitting::chord_length_params(&points[i]);
305        for (j, &p) in row_params.iter().enumerate() {
306            params[j] += p;
307        }
308    }
309    let rows_f = rows as f64;
310    for p in &mut params {
311        *p /= rows_f;
312    }
313    params
314}
315
316/// Estimate the spectral radius of `N^T N` via max column sum of squared basis values.
317fn col_sum_lambda(basis_data: &[(usize, Vec<f64>)], p: usize, m: usize) -> f64 {
318    let mut col_sums = vec![0.0f64; m];
319    for (span, n_vals) in basis_data {
320        for (k, &nv) in n_vals.iter().enumerate() {
321            let j = span - p + k;
322            if j < m {
323                col_sums[j] += nv * nv;
324            }
325        }
326    }
327    col_sums.iter().copied().fold(0.0f64, f64::max).max(1e-30)
328}
329
330#[cfg(test)]
331mod tests {
332    #![allow(clippy::unwrap_used, clippy::cast_lossless, clippy::suboptimal_flops)]
333
334    use crate::tolerance::Tolerance;
335    use crate::vec::Point3;
336
337    use super::*;
338
339    #[test]
340    fn interpolate_flat_grid() {
341        // A 3×3 flat grid on the XY plane.
342        let points = vec![
343            vec![
344                Point3::new(0.0, 0.0, 0.0),
345                Point3::new(1.0, 0.0, 0.0),
346                Point3::new(2.0, 0.0, 0.0),
347            ],
348            vec![
349                Point3::new(0.0, 1.0, 0.0),
350                Point3::new(1.0, 1.0, 0.0),
351                Point3::new(2.0, 1.0, 0.0),
352            ],
353            vec![
354                Point3::new(0.0, 2.0, 0.0),
355                Point3::new(1.0, 2.0, 0.0),
356                Point3::new(2.0, 2.0, 0.0),
357            ],
358        ];
359
360        let surface = interpolate_surface(&points, 2, 2).unwrap();
361
362        let tol = Tolerance::new();
363
364        // Check corners.
365        let p00 = surface.evaluate(0.0, 0.0);
366        assert!(tol.approx_eq(p00.x(), 0.0), "corner (0,0) x: {}", p00.x());
367        assert!(tol.approx_eq(p00.y(), 0.0), "corner (0,0) y: {}", p00.y());
368        assert!(tol.approx_eq(p00.z(), 0.0), "corner (0,0) z: {}", p00.z());
369
370        let p11 = surface.evaluate(1.0, 1.0);
371        assert!(tol.approx_eq(p11.x(), 2.0), "corner (1,1) x: {}", p11.x());
372        assert!(tol.approx_eq(p11.y(), 2.0), "corner (1,1) y: {}", p11.y());
373
374        // All z should be 0 (flat grid).
375        let p_mid = surface.evaluate(0.5, 0.5);
376        assert!(
377            p_mid.z().abs() < 0.01,
378            "flat grid mid z should be ~0, got {}",
379            p_mid.z()
380        );
381    }
382
383    #[test]
384    fn interpolate_curved_grid() {
385        // A 3×3 grid with z-height forming a paraboloid.
386        let points = vec![
387            vec![
388                Point3::new(0.0, 0.0, 0.0),
389                Point3::new(1.0, 0.0, 1.0),
390                Point3::new(2.0, 0.0, 4.0),
391            ],
392            vec![
393                Point3::new(0.0, 1.0, 1.0),
394                Point3::new(1.0, 1.0, 2.0),
395                Point3::new(2.0, 1.0, 5.0),
396            ],
397            vec![
398                Point3::new(0.0, 2.0, 4.0),
399                Point3::new(1.0, 2.0, 5.0),
400                Point3::new(2.0, 2.0, 8.0),
401            ],
402        ];
403
404        let surface = interpolate_surface(&points, 2, 2).unwrap();
405
406        let tol = Tolerance::new();
407        // Check corners pass through.
408        let p00 = surface.evaluate(0.0, 0.0);
409        assert!(tol.approx_eq(p00.z(), 0.0), "corner z: {}", p00.z());
410
411        let p11 = surface.evaluate(1.0, 1.0);
412        assert!(tol.approx_eq(p11.z(), 8.0), "far corner z: {}", p11.z());
413    }
414
415    #[test]
416    fn interpolate_surface_too_small() {
417        let points = vec![vec![Point3::new(0.0, 0.0, 0.0)]];
418        assert!(interpolate_surface(&points, 1, 1).is_err());
419    }
420
421    #[test]
422    fn interpolate_surface_inconsistent_rows() {
423        let points = vec![
424            vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)],
425            vec![Point3::new(0.0, 1.0, 0.0)], // different length
426        ];
427        assert!(interpolate_surface(&points, 1, 1).is_err());
428    }
429
430    // ── LSPIA surface tests ────────────────────────────────────────────
431
432    #[test]
433    #[allow(clippy::cast_precision_loss)]
434    fn lspia_surface_fits_plane() {
435        let grid: Vec<Vec<Point3>> = (0..10)
436            .map(|i| {
437                (0..10)
438                    .map(|j| {
439                        let u = i as f64 / 9.0;
440                        let v = j as f64 / 9.0;
441                        Point3::new(u, v, 0.5)
442                    })
443                    .collect()
444            })
445            .collect();
446        let surface = approximate_surface_lspia(&grid, 3, 3, 6, 6, 1e-6, 50).unwrap();
447        let p = surface.evaluate(0.5, 0.5);
448        assert!(
449            (p.z() - 0.5).abs() < 0.01,
450            "plane z at center: expected ~0.5, got {}",
451            p.z()
452        );
453    }
454
455    #[test]
456    fn lspia_surface_empty_returns_error() {
457        let grid: Vec<Vec<Point3>> = Vec::new();
458        assert!(approximate_surface_lspia(&grid, 3, 3, 4, 4, 1e-6, 50).is_err());
459    }
460
461    #[test]
462    fn lspia_surface_inconsistent_rows_returns_error() {
463        let grid = vec![
464            vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)],
465            vec![Point3::new(0.0, 1.0, 0.0)],
466        ];
467        assert!(approximate_surface_lspia(&grid, 1, 1, 2, 2, 1e-6, 50).is_err());
468    }
469
470    #[test]
471    #[allow(clippy::cast_precision_loss)]
472    fn lspia_surface_fits_paraboloid() {
473        let grid: Vec<Vec<Point3>> = (0..8)
474            .map(|i| {
475                (0..8)
476                    .map(|j| {
477                        let u = i as f64 / 7.0;
478                        let v = j as f64 / 7.0;
479                        Point3::new(u, v, u * u + v * v)
480                    })
481                    .collect()
482            })
483            .collect();
484        let surface = approximate_surface_lspia(&grid, 3, 3, 6, 6, 1e-4, 100).unwrap();
485        let p = surface.evaluate(0.5, 0.5);
486        // Expected z = 0.25 + 0.25 = 0.5
487        assert!(
488            (p.z() - 0.5).abs() < 0.15,
489            "paraboloid z at center: expected ~0.5, got {}",
490            p.z()
491        );
492    }
493}