Skip to main content

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]`.
58pub(crate) fn 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.
107pub(crate) fn 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.
125pub(crate) fn 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        let pivot_row = matrix[column].clone();
165        let pivot_rhs = rhs[column];
166        for row in (column + 1)..n {
167            let factor = matrix[row][column] / matrix[column][column];
168            if factor == 0.0 {
169                continue;
170            }
171            for (slot, pivot) in matrix[row].iter_mut().zip(&pivot_row).skip(column) {
172                *slot -= factor * pivot;
173            }
174            for (slot, pivot) in rhs[row].iter_mut().zip(pivot_rhs) {
175                *slot -= factor * pivot;
176            }
177        }
178    }
179
180    let mut solution = vec![[0.0; 3]; n];
181    for row in (0..n).rev() {
182        let mut accumulated = rhs[row];
183        for (k, solved) in solution.iter().enumerate().take(n).skip(row + 1) {
184            for (axis, value) in accumulated.iter_mut().enumerate() {
185                *value -= matrix[row][k] * solved[axis];
186            }
187        }
188        let pivot = matrix[row][row];
189        for (axis, value) in solution[row].iter_mut().enumerate() {
190            *value = accumulated[axis] / pivot;
191        }
192    }
193    Ok(solution)
194}
195
196/// Assemble and solve the global interpolation system.
197fn interpolate_with(points: &[Point3], parameters: &[Scalar]) -> GeomResult<BSplineCurve3> {
198    let count = points.len();
199    let degree = usize::from(degree_for(count));
200    let expanded = averaged_knots(parameters, degree);
201    let n = count - 1;
202
203    // One row per point: the basis functions at its parameter must combine
204    // the unknown control points into exactly that point.
205    let mut matrix = vec![vec![0.0; count]; count];
206    let mut rhs = vec![[0.0; 3]; count];
207    for (row, (&t, point)) in parameters.iter().zip(points).enumerate() {
208        let span = span_of(&expanded, n, degree, t);
209        let basis = basis_at(span, t, degree, &expanded);
210        for (offset, value) in basis.iter().enumerate() {
211            matrix[row][span - degree + offset] = *value;
212        }
213        rhs[row] = [point.x, point.y, point.z];
214    }
215
216    let solved = solve(matrix, rhs)?;
217    let control_points: Vec<Point3> = solved
218        .into_iter()
219        .map(|c| Point3::new(c[0], c[1], c[2]))
220        .collect();
221
222    // Collapse the expanded vector into distinct knots plus multiplicities,
223    // which is the representation BSplineCurve carries.
224    let (knots, multiplicities) = collapse(&expanded);
225
226    Ok(BSplineCurve3 {
227        degree: u16::try_from(degree)
228            .map_err(|_| GeomError::InvalidInput("degree overflows".to_owned()))?,
229        control_points,
230        knots,
231        multiplicities,
232        weights: None,
233        knot_spec: KnotSpec::Unspecified,
234        closed: false,
235        self_intersect: None,
236    })
237}
238
239/// Group a repeated knot vector into distinct values and multiplicities.
240pub(crate) fn collapse(expanded: &[Scalar]) -> (Vec<Scalar>, Vec<u32>) {
241    let mut knots: Vec<Scalar> = Vec::new();
242    let mut multiplicities: Vec<u32> = Vec::new();
243    for &knot in expanded {
244        if knots.last().is_some_and(|&last| last == knot) {
245            *multiplicities
246                .last_mut()
247                .expect("knots and counts stay in step") += 1;
248        } else {
249            knots.push(knot);
250            multiplicities.push(1);
251        }
252    }
253    (knots, multiplicities)
254}
255
256/// Loft a surface through ordered section curves.
257///
258/// Each section becomes a row of the control net, so the surface passes
259/// exactly through every section: at the section's own `u` parameter the
260/// surface reduces to that curve. Section spacing along `u` is chord length
261/// between corresponding control points, matching the curve case.
262///
263/// Sections must share a degree and control-point count. Reconciling
264/// mismatched sections requires knot merging and degree elevation, and doing
265/// that implicitly would change the caller's curves inside what looks like a
266/// pure construction -- so it is refused, and the caller elevates explicitly
267/// with `elevate_degree3`.
268pub fn loft_surface(sections: &[BSplineCurve3]) -> GeomResult<BSplineSurface> {
269    if sections.len() < 2 {
270        return Err(GeomError::InvalidInput(
271            "lofting needs at least two sections".to_owned(),
272        ));
273    }
274
275    let first = &sections[0];
276    let width = first.control_points.len();
277    for (index, section) in sections.iter().enumerate() {
278        if section.degree != first.degree {
279            return Err(GeomError::InvalidInput(format!(
280                "section {index} has degree {} but section 0 has degree {}; \
281                 elevate explicitly rather than having the loft change your curves",
282                section.degree, first.degree
283            )));
284        }
285        if section.control_points.len() != width {
286            return Err(GeomError::InvalidInput(format!(
287                "section {index} has {} control points but section 0 has {width}; \
288                 sections must share a control net width",
289                section.control_points.len()
290            )));
291        }
292        if section.weights.is_some() {
293            return Err(GeomError::Unsupported {
294                backend: axiolid_contracts::BackendId::new("nurbs"),
295                operation: axiolid_contracts::Operation::SurfaceEvaluation,
296            });
297        }
298    }
299
300    // Space sections along u by the average chord between corresponding
301    // control points: a section that sits far from its neighbour gets a
302    // proportionally longer parameter interval, as in the curve case.
303    let mut spans = vec![0.0];
304    let mut total = 0.0;
305    for pair in sections.windows(2) {
306        let mean: Scalar = pair[0]
307            .control_points
308            .iter()
309            .zip(&pair[1].control_points)
310            .map(|(a, b)| (*b - *a).length())
311            .sum::<Scalar>()
312            / width as Scalar;
313        if mean <= 0.0 {
314            return Err(GeomError::Degenerate(
315                "consecutive sections coincide, so loft spacing is undefined".to_owned(),
316            ));
317        }
318        total += mean;
319        spans.push(total);
320    }
321    let u_parameters: Vec<Scalar> = spans.into_iter().map(|d| d / total).collect();
322
323    // Interpolate down each column of control points, so the surface passes
324    // through every section rather than merely near it. Using the sections as
325    // raw control rows would only approximate the interior ones.
326    let u_degree = usize::from(degree_for(sections.len()));
327    let u_expanded = averaged_knots(&u_parameters, u_degree);
328    let rows = sections.len();
329    let n = rows - 1;
330
331    let mut matrix = vec![vec![0.0; rows]; rows];
332    for (row, &t) in u_parameters.iter().enumerate() {
333        let span = span_of(&u_expanded, n, u_degree, t);
334        let basis = basis_at(span, t, u_degree, &u_expanded);
335        for (offset, value) in basis.iter().enumerate() {
336            matrix[row][span - u_degree + offset] = *value;
337        }
338    }
339
340    let mut net: Vec<Vec<Point3>> = vec![Vec::with_capacity(width); rows];
341    for column in 0..width {
342        let rhs: Vec<[Scalar; 3]> = sections
343            .iter()
344            .map(|s| {
345                let p = s.control_points[column];
346                [p.x, p.y, p.z]
347            })
348            .collect();
349        let solved = solve(matrix.clone(), rhs)?;
350        for (row, coordinate) in solved.into_iter().enumerate() {
351            net[row].push(Point3::new(coordinate[0], coordinate[1], coordinate[2]));
352        }
353    }
354
355    let (u_knots, u_multiplicities) = collapse(&u_expanded);
356
357    Ok(BSplineSurface {
358        u_degree: u16::try_from(u_degree)
359            .map_err(|_| GeomError::InvalidInput("u degree overflows".to_owned()))?,
360        v_degree: first.degree,
361        control_points: net,
362        u_knots,
363        u_multiplicities,
364        // The v direction is the sections' own parameterisation, carried
365        // through unchanged so the surface reproduces each section exactly.
366        v_knots: first.knots.clone(),
367        v_multiplicities: first.multiplicities.clone(),
368        weights: None,
369        u_closed: false,
370        v_closed: first.closed,
371        knot_spec: KnotSpec::Unspecified,
372        self_intersect: None,
373    })
374}