axiolid_nurbs/
fit.rs

1//! Curve interpolation and lofting (#33).
2//!
3//! # Interpolation, not approximation
4//!
5//! `interpolate_curve3` produces a curve that passes THROUGH its input points,
6//! not near them. That distinction is the contract: a caller handing over
7//! survey points or a section outline needs those points on the curve, and an
8//! approximating fit that misses them by a tolerance is a different operation
9//! with different uses.
10//!
11//! The test that matters therefore evaluates the result at each computed
12//! parameter and requires the input point back to near machine precision.
13//!
14//! # Continuity is documented, not assumed
15//!
16//! A cubic interpolant through n points is C2 across interior joins: the
17//! natural consequence of a single global system with continuity built into
18//! the basis. A piecewise fit stitched segment by segment would only be C0,
19//! which is why this solves globally rather than locally.
20//!
21//! # Lofting
22//!
23//! `loft_surface` runs a surface through an ordered set of section curves. The
24//! sections become rows of the control net, so the surface interpolates each
25//! section exactly. Sections must agree in degree and control-point count:
26//! reconciling mismatched sections means knot merging and degree elevation,
27//! and doing it implicitly would hide a shape change inside a construction.
28
29use axiolid_contracts::{GeomError, GeomResult};
30use axiolid_core::{Point3, Scalar};
31use axiolid_curve::{BSplineCurve3, KnotSpec};
32use axiolid_surface::BSplineSurface;
33
34/// Interpolate a cubic B-spline through `points` in order.
35///
36/// The curve passes through every input point. Parameters are assigned by
37/// chord length, which keeps the parameterisation proportional to distance --
38/// uniform spacing on unevenly spaced points produces visible overshoot.
39///
40/// Fewer than two points cannot define a curve, and repeated coincident points
41/// make chord length degenerate, so both are refused.
42pub fn interpolate_curve3(points: &[Point3]) -> GeomResult<BSplineCurve3> {
43    if points.len() < 2 {
44        return Err(GeomError::InvalidInput(
45            "interpolation needs at least two points".to_owned(),
46        ));
47    }
48    if !points.iter().all(|p| p.is_finite()) {
49        return Err(GeomError::InvalidInput(
50            "interpolation points must be finite".to_owned(),
51        ));
52    }
53    let parameters = chord_parameters(points)?;
54    interpolate_with(points, &parameters)
55}
56
57/// Chord-length parameters normalised to `[0, 1]`.
58fn chord_parameters(points: &[Point3]) -> GeomResult<Vec<Scalar>> {
59    let mut distances = Vec::with_capacity(points.len());
60    distances.push(0.0);
61    let mut total = 0.0;
62    for pair in points.windows(2) {
63        let step = (pair[1] - pair[0]).length();
64        if step <= 0.0 {
65            return Err(GeomError::Degenerate(
66                "consecutive interpolation points coincide, so chord length is undefined"
67                    .to_owned(),
68            ));
69        }
70        total += step;
71        distances.push(total);
72    }
73    Ok(distances.into_iter().map(|d| d / total).collect())
74}
75
76/// Cubic degree, or lower when there are too few points to support it.
77///
78/// Three points cannot define a cubic, so the degree drops rather than the
79/// call failing: an interpolant through two or three points is still a useful
80/// answer, and padding with invented points would fabricate shape.
81fn degree_for(count: usize) -> u16 {
82    match count {
83        0 | 1 => 0,
84        2 => 1,
85        3 => 2,
86        _ => 3,
87    }
88}
89
90/// Expanded (repeated) knot vector for interpolation, averaging interior knots.
91///
92/// Averaging is what keeps the system well-conditioned: it guarantees every
93/// basis function has a parameter inside its support, so the matrix is
94/// non-singular.
95fn averaged_knots(parameters: &[Scalar], degree: usize) -> Vec<Scalar> {
96    let n = parameters.len() - 1;
97    let mut knots = vec![0.0; degree + 1];
98    for j in 1..=n.saturating_sub(degree) {
99        let sum: Scalar = parameters[j..j + degree].iter().sum();
100        knots.push(sum / degree as Scalar);
101    }
102    knots.extend(core::iter::repeat_n(1.0, degree + 1));
103    knots
104}
105
106/// Knot span containing `t`, clamped to the last non-empty span.
107fn span_of(knots: &[Scalar], n: usize, degree: usize, t: Scalar) -> usize {
108    if t >= knots[n + 1] {
109        return n;
110    }
111    let (mut lo, mut hi) = (degree, n + 1);
112    let mut mid = lo.midpoint(hi);
113    while t < knots[mid] || t >= knots[mid + 1] {
114        if t < knots[mid] {
115            hi = mid;
116        } else {
117            lo = mid;
118        }
119        mid = lo.midpoint(hi);
120    }
121    mid
122}
123
124/// Non-zero basis functions at `t`, by the Cox-de Boor recurrence.
125fn basis_at(span: usize, t: Scalar, degree: usize, knots: &[Scalar]) -> Vec<Scalar> {
126    let mut basis = vec![0.0; degree + 1];
127    let mut left = vec![0.0; degree + 1];
128    let mut right = vec![0.0; degree + 1];
129    basis[0] = 1.0;
130    for j in 1..=degree {
131        left[j] = t - knots[span + 1 - j];
132        right[j] = knots[span + j] - t;
133        let mut saved = 0.0;
134        for r in 0..j {
135            let temp = basis[r] / (right[r + 1] + left[j - r]);
136            basis[r] = saved + right[r + 1] * temp;
137            saved = left[j - r] * temp;
138        }
139        basis[j] = saved;
140    }
141    basis
142}
143
144/// Solve `matrix * x = rhs` by Gaussian elimination with partial pivoting.
145///
146/// Pivoting is not optional here: the interpolation matrix is banded but its
147/// diagonal is not guaranteed dominant for arbitrary point spacing, and
148/// without pivoting a small pivot amplifies rounding into visible wobble.
149/// A singular matrix is reported rather than producing infinities.
150fn solve(mut matrix: Vec<Vec<Scalar>>, mut rhs: Vec<[Scalar; 3]>) -> GeomResult<Vec<[Scalar; 3]>> {
151    let n = matrix.len();
152    for column in 0..n {
153        let pivot = (column..n)
154            .max_by(|&a, &b| matrix[a][column].abs().total_cmp(&matrix[b][column].abs()))
155            .expect("range is non-empty");
156        if matrix[pivot][column].abs() < 1e-12 {
157            return Err(GeomError::Degenerate(
158                "interpolation system is singular for these points".to_owned(),
159            ));
160        }
161        matrix.swap(column, pivot);
162        rhs.swap(column, pivot);
163
164        for row in (column + 1)..n {
165            let factor = matrix[row][column] / matrix[column][column];
166            if factor == 0.0 {
167                continue;
168            }
169            for k in column..n {
170                matrix[row][k] -= factor * matrix[column][k];
171            }
172            for axis in 0..3 {
173                rhs[row][axis] -= factor * rhs[column][axis];
174            }
175        }
176    }
177
178    let mut solution = vec![[0.0; 3]; n];
179    for row in (0..n).rev() {
180        let mut accumulated = rhs[row];
181        for (k, solved) in solution.iter().enumerate().take(n).skip(row + 1) {
182            for (axis, value) in accumulated.iter_mut().enumerate() {
183                *value -= matrix[row][k] * solved[axis];
184            }
185        }
186        let pivot = matrix[row][row];
187        for (axis, value) in solution[row].iter_mut().enumerate() {
188            *value = accumulated[axis] / pivot;
189        }
190    }
191    Ok(solution)
192}
193
194/// Assemble and solve the global interpolation system.
195fn interpolate_with(points: &[Point3], parameters: &[Scalar]) -> GeomResult<BSplineCurve3> {
196    let count = points.len();
197    let degree = usize::from(degree_for(count));
198    let expanded = averaged_knots(parameters, degree);
199    let n = count - 1;
200
201    // One row per point: the basis functions at its parameter must combine
202    // the unknown control points into exactly that point.
203    let mut matrix = vec![vec![0.0; count]; count];
204    let mut rhs = vec![[0.0; 3]; count];
205    for (row, (&t, point)) in parameters.iter().zip(points).enumerate() {
206        let span = span_of(&expanded, n, degree, t);
207        let basis = basis_at(span, t, degree, &expanded);
208        for (offset, value) in basis.iter().enumerate() {
209            matrix[row][span - degree + offset] = *value;
210        }
211        rhs[row] = [point.x, point.y, point.z];
212    }
213
214    let solved = solve(matrix, rhs)?;
215    let control_points: Vec<Point3> = solved
216        .into_iter()
217        .map(|c| Point3::new(c[0], c[1], c[2]))
218        .collect();
219
220    // Collapse the expanded vector into distinct knots plus multiplicities,
221    // which is the representation BSplineCurve carries.
222    let (knots, multiplicities) = collapse(&expanded);
223
224    Ok(BSplineCurve3 {
225        degree: u16::try_from(degree)
226            .map_err(|_| GeomError::InvalidInput("degree overflows".to_owned()))?,
227        control_points,
228        knots,
229        multiplicities,
230        weights: None,
231        knot_spec: KnotSpec::Unspecified,
232        closed: false,
233        self_intersect: None,
234    })
235}
236
237/// Group a repeated knot vector into distinct values and multiplicities.
238fn collapse(expanded: &[Scalar]) -> (Vec<Scalar>, Vec<u32>) {
239    let mut knots: Vec<Scalar> = Vec::new();
240    let mut multiplicities: Vec<u32> = Vec::new();
241    for &knot in expanded {
242        if knots.last().is_some_and(|&last| last == knot) {
243            *multiplicities
244                .last_mut()
245                .expect("knots and counts stay in step") += 1;
246        } else {
247            knots.push(knot);
248            multiplicities.push(1);
249        }
250    }
251    (knots, multiplicities)
252}
253
254/// Loft a surface through ordered section curves.
255///
256/// Each section becomes a row of the control net, so the surface passes
257/// exactly through every section: at the section's own `u` parameter the
258/// surface reduces to that curve. Section spacing along `u` is chord length
259/// between corresponding control points, matching the curve case.
260///
261/// Sections must share a degree and control-point count. Reconciling
262/// mismatched sections requires knot merging and degree elevation, and doing
263/// that implicitly would change the caller's curves inside what looks like a
264/// pure construction -- so it is refused, and the caller elevates explicitly
265/// with `elevate_degree3`.
266pub fn loft_surface(sections: &[BSplineCurve3]) -> GeomResult<BSplineSurface> {
267    if sections.len() < 2 {
268        return Err(GeomError::InvalidInput(
269            "lofting needs at least two sections".to_owned(),
270        ));
271    }
272
273    let first = &sections[0];
274    let width = first.control_points.len();
275    for (index, section) in sections.iter().enumerate() {
276        if section.degree != first.degree {
277            return Err(GeomError::InvalidInput(format!(
278                "section {index} has degree {} but section 0 has degree {}; \
279                 elevate explicitly rather than having the loft change your curves",
280                section.degree, first.degree
281            )));
282        }
283        if section.control_points.len() != width {
284            return Err(GeomError::InvalidInput(format!(
285                "section {index} has {} control points but section 0 has {width}; \
286                 sections must share a control net width",
287                section.control_points.len()
288            )));
289        }
290        if section.weights.is_some() {
291            return Err(GeomError::Unsupported {
292                backend: axiolid_contracts::BackendId::new("nurbs"),
293                operation: axiolid_contracts::Operation::SurfaceEvaluation,
294            });
295        }
296    }
297
298    // Space sections along u by the average chord between corresponding
299    // control points: a section that sits far from its neighbour gets a
300    // proportionally longer parameter interval, as in the curve case.
301    let mut spans = vec![0.0];
302    let mut total = 0.0;
303    for pair in sections.windows(2) {
304        let mean: Scalar = pair[0]
305            .control_points
306            .iter()
307            .zip(&pair[1].control_points)
308            .map(|(a, b)| (*b - *a).length())
309            .sum::<Scalar>()
310            / width as Scalar;
311        if mean <= 0.0 {
312            return Err(GeomError::Degenerate(
313                "consecutive sections coincide, so loft spacing is undefined".to_owned(),
314            ));
315        }
316        total += mean;
317        spans.push(total);
318    }
319    let u_parameters: Vec<Scalar> = spans.into_iter().map(|d| d / total).collect();
320
321    // Interpolate down each column of control points, so the surface passes
322    // through every section rather than merely near it. Using the sections as
323    // raw control rows would only approximate the interior ones.
324    let u_degree = usize::from(degree_for(sections.len()));
325    let u_expanded = averaged_knots(&u_parameters, u_degree);
326    let rows = sections.len();
327    let n = rows - 1;
328
329    let mut matrix = vec![vec![0.0; rows]; rows];
330    for (row, &t) in u_parameters.iter().enumerate() {
331        let span = span_of(&u_expanded, n, u_degree, t);
332        let basis = basis_at(span, t, u_degree, &u_expanded);
333        for (offset, value) in basis.iter().enumerate() {
334            matrix[row][span - u_degree + offset] = *value;
335        }
336    }
337
338    let mut net: Vec<Vec<Point3>> = vec![Vec::with_capacity(width); rows];
339    for column in 0..width {
340        let rhs: Vec<[Scalar; 3]> = sections
341            .iter()
342            .map(|s| {
343                let p = s.control_points[column];
344                [p.x, p.y, p.z]
345            })
346            .collect();
347        let solved = solve(matrix.clone(), rhs)?;
348        for (row, coordinate) in solved.into_iter().enumerate() {
349            net[row].push(Point3::new(coordinate[0], coordinate[1], coordinate[2]));
350        }
351    }
352
353    let (u_knots, u_multiplicities) = collapse(&u_expanded);
354
355    Ok(BSplineSurface {
356        u_degree: u16::try_from(u_degree)
357            .map_err(|_| GeomError::InvalidInput("u degree overflows".to_owned()))?,
358        v_degree: first.degree,
359        control_points: net,
360        u_knots,
361        u_multiplicities,
362        // The v direction is the sections' own parameterisation, carried
363        // through unchanged so the surface reproduces each section exactly.
364        v_knots: first.knots.clone(),
365        v_multiplicities: first.multiplicities.clone(),
366        weights: None,
367        u_closed: false,
368        v_closed: first.closed,
369        knot_spec: KnotSpec::Unspecified,
370        self_intersect: None,
371    })
372}