Skip to main content

brep_kernel/geometry/
fit.rs

1use crate::{KnotVector, NurbsCurve, Vec3, Vec4};
2
3#[derive(Clone, Debug)]
4pub struct PolylineFit {
5    pub curve: NurbsCurve,
6    pub parameters: Vec<f64>,
7    pub kept: Vec<Vec3>,
8}
9
10pub fn solve_dense(mut matrix: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Result<Vec<f64>, String> {
11    let count = rhs.len();
12    if count == 0 || matrix.len() != count || matrix.iter().any(|row| row.len() != count) {
13        return Err("solve_dense: matrix must be square and match RHS".into());
14    }
15    let scale = matrix
16        .iter()
17        .flatten()
18        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
19    if scale == 0.0 {
20        return Err("solve_dense: singular matrix".into());
21    }
22    for column in 0..count {
23        let pivot = (column..count)
24            .max_by(|&a, &b| matrix[a][column].abs().total_cmp(&matrix[b][column].abs()))
25            .unwrap();
26        if matrix[pivot][column].abs() <= 1e-13 * scale {
27            return Err("solve_dense: singular matrix".into());
28        }
29        matrix.swap(column, pivot);
30        rhs.swap(column, pivot);
31        let diagonal = matrix[column][column];
32        for row in column + 1..count {
33            let factor = matrix[row][column] / diagonal;
34            matrix[row][column] = 0.0;
35            for entry in column + 1..count {
36                matrix[row][entry] -= factor * matrix[column][entry];
37            }
38            rhs[row] -= factor * rhs[column];
39        }
40    }
41    let mut result = vec![0.0; count];
42    for row in (0..count).rev() {
43        let remainder: f64 = (row + 1..count)
44            .map(|column| matrix[row][column] * result[column])
45            .sum();
46        result[row] = (rhs[row] - remainder) / matrix[row][row];
47    }
48    Ok(result)
49}
50
51/// `solve_dense` for fixed-size systems on the stack — identical pivoting and
52/// thresholds, no allocation. `count <= N` solves the leading `count`-sized
53/// block (for callers that shrink the system by fixing parameters).
54pub fn solve_small<const N: usize>(
55    mut matrix: [[f64; N]; N],
56    mut rhs: [f64; N],
57    count: usize,
58) -> Result<[f64; N], String> {
59    if count == 0 || count > N {
60        return Err("solve_small: invalid system size".into());
61    }
62    let scale = matrix
63        .iter()
64        .take(count)
65        .flat_map(|row| row.iter().take(count))
66        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
67    if scale == 0.0 {
68        return Err("solve_small: singular matrix".into());
69    }
70    for column in 0..count {
71        let pivot = (column..count)
72            .max_by(|&a, &b| matrix[a][column].abs().total_cmp(&matrix[b][column].abs()))
73            .unwrap();
74        if matrix[pivot][column].abs() <= 1e-13 * scale {
75            return Err("solve_small: singular matrix".into());
76        }
77        matrix.swap(column, pivot);
78        rhs.swap(column, pivot);
79        let diagonal = matrix[column][column];
80        for row in column + 1..count {
81            let factor = matrix[row][column] / diagonal;
82            matrix[row][column] = 0.0;
83            for entry in column + 1..count {
84                matrix[row][entry] -= factor * matrix[column][entry];
85            }
86            rhs[row] -= factor * rhs[column];
87        }
88    }
89    let mut result = [0.0; N];
90    for row in (0..count).rev() {
91        let remainder: f64 = (row + 1..count)
92            .map(|column| matrix[row][column] * result[column])
93            .sum();
94        result[row] = (rhs[row] - remainder) / matrix[row][row];
95    }
96    Ok(result)
97}
98
99/// Solve a B-spline interpolation (collocation) system. The collocation matrix
100/// `N_j(τ_i)` produced by the knot-averaging schemes below is banded with
101/// half-bandwidth ≤ `degree` (Schoenberg–Whitney) and totally positive, so a
102/// no-pivot BANDED elimination is O(n·degree²) — vs the O(n³) of the general
103/// dense solver — and is numerically identical to it on these systems.
104///
105/// The banded result is accepted only when its in-band residual is tiny;
106/// otherwise (a pathological / near-singular system) it falls back to the
107/// partial-pivoting dense solver. This keeps large STEP-import pcurve fits
108/// (which push `n` toward ~500) from turning each solve into a ~500³ blowup.
109pub fn solve_collocation(
110    matrix: &[Vec<f64>],
111    rhs: &[f64],
112    degree: usize,
113) -> Result<Vec<f64>, String> {
114    let count = rhs.len();
115    let bandwidth = degree.max(1).min(count.saturating_sub(1).max(1));
116    if let Ok(solution) = solve_banded(matrix, rhs, bandwidth) {
117        let scale = rhs
118            .iter()
119            .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
120        let tolerance = 1e-9 * scale.max(1.0);
121        let mut residual = 0.0_f64;
122        for row in 0..count {
123            let lo = row.saturating_sub(bandwidth);
124            let hi = (row + bandwidth).min(count - 1);
125            let mut accumulated = 0.0;
126            for col in lo..=hi {
127                accumulated += matrix[row][col] * solution[col];
128            }
129            residual = residual.max((accumulated - rhs[row]).abs());
130        }
131        if residual <= tolerance {
132            return Ok(solution);
133        }
134    }
135    solve_dense(matrix.to_vec(), rhs.to_vec())
136}
137
138pub fn solve_banded(
139    matrix: &[Vec<f64>],
140    rhs: &[f64],
141    bandwidth: usize,
142) -> Result<Vec<f64>, String> {
143    let count = rhs.len();
144    if count == 0 || matrix.len() != count || matrix.iter().any(|row| row.len() != count) {
145        return Err("solve_banded: matrix must be square and match RHS".into());
146    }
147    let mut matrix = matrix.to_vec();
148    let mut rhs = rhs.to_vec();
149    for column in 0..count {
150        let pivot = matrix[column][column];
151        if pivot.abs() <= 1e-300 {
152            return Err("solve_banded: singular matrix".into());
153        }
154        let maximum_row = (column + bandwidth).min(count - 1);
155        for row in column + 1..=maximum_row {
156            let factor = matrix[row][column] / pivot;
157            if factor == 0.0 {
158                continue;
159            }
160            let maximum_column = (column + bandwidth).min(count - 1);
161            for entry in column..=maximum_column {
162                matrix[row][entry] -= factor * matrix[column][entry];
163            }
164            rhs[row] -= factor * rhs[column];
165        }
166    }
167    let mut result = vec![0.0; count];
168    for row in (0..count).rev() {
169        let mut value = rhs[row];
170        let maximum_column = (row + bandwidth).min(count - 1);
171        for column in row + 1..=maximum_column {
172            value -= matrix[row][column] * result[column];
173        }
174        if matrix[row][row].abs() <= 1e-300 {
175            return Err("solve_banded: singular matrix".into());
176        }
177        result[row] = value / matrix[row][row];
178    }
179    Ok(result)
180}
181
182/// Global B-spline interpolation (The NURBS Book A9.1), matching the
183/// reference implementation's supplied-parameter path.
184/// Interpolate HOMOGENEOUS points (Vec4, rational data) at the given
185/// parameters with the same averaged-knot scheme as `interpolate_curve`.
186/// Parameters must start at 0 and end at 1. Rows fitted with identical
187/// parameters share knots — the contract the blend-surface rows rely on.
188pub fn interpolate_homogeneous(
189    points: &[Vec4],
190    degree: usize,
191    parameters: &[f64],
192) -> Result<NurbsCurve, String> {
193    if points.len() < 2 || parameters.len() != points.len() {
194        return Err(
195            "interpolate_homogeneous: points and parameters must have matching length >= 2".into(),
196        );
197    }
198    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
199        return Err("interpolate_homogeneous: parameters must be strictly increasing".into());
200    }
201    let n = points.len() - 1;
202    let degree = degree.min(n);
203    let m = n + degree + 1;
204    let mut knots = vec![0.0; m + 1];
205    for knot in &mut knots[m - degree..=m] {
206        *knot = 1.0;
207    }
208    for j in 1..=n.saturating_sub(degree) {
209        knots[j + degree] = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
210    }
211    let knot_vector = KnotVector::new(knots.clone(), degree)?;
212    let mut matrix = vec![vec![0.0; n + 1]; n + 1];
213    for (row, &parameter) in parameters.iter().enumerate() {
214        let span = knot_vector.find_span(parameter);
215        let basis = knot_vector.basis_functions(span, parameter);
216        for (offset, value) in basis.into_iter().enumerate() {
217            matrix[row][span - degree + offset] = value;
218        }
219    }
220    let solve_axis = |axis: fn(&Vec4) -> f64| {
221        solve_collocation(
222            &matrix,
223            &points.iter().map(axis).collect::<Vec<_>>(),
224            degree,
225        )
226    };
227    let xs = solve_axis(|point| point.x)?;
228    let ys = solve_axis(|point| point.y)?;
229    let zs = solve_axis(|point| point.z)?;
230    let ws = solve_axis(|point| point.w)?;
231    NurbsCurve::new(
232        degree,
233        knots,
234        (0..=n)
235            .map(|index| Vec4 {
236                x: xs[index],
237                y: ys[index],
238                z: zs[index],
239                w: ws[index],
240            })
241            .collect(),
242    )
243}
244
245pub fn interpolate_curve(
246    points: &[Vec3],
247    degree: usize,
248    parameters: &[f64],
249) -> Result<NurbsCurve, String> {
250    if points.len() < 2 || parameters.len() != points.len() {
251        return Err(
252            "interpolate_curve: points and parameters must have matching length >= 2".into(),
253        );
254    }
255    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
256        return Err("interpolate_curve: parameters must be strictly increasing".into());
257    }
258    let n = points.len() - 1;
259    let degree = degree.min(n);
260    let m = n + degree + 1;
261    let mut knots = vec![0.0; m + 1];
262    for knot in &mut knots[m - degree..=m] {
263        *knot = 1.0;
264    }
265    for j in 1..=n.saturating_sub(degree) {
266        knots[j + degree] = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
267    }
268    let knot_vector = KnotVector::new(knots.clone(), degree)?;
269    let mut matrix = vec![vec![0.0; n + 1]; n + 1];
270    for (row, &parameter) in parameters.iter().enumerate() {
271        let span = knot_vector.find_span(parameter);
272        let basis = knot_vector.basis_functions(span, parameter);
273        for (offset, value) in basis.into_iter().enumerate() {
274            matrix[row][span - degree + offset] = value;
275        }
276    }
277    let solve_axis = |axis: fn(Vec3) -> f64| {
278        solve_collocation(
279            &matrix,
280            &points.iter().copied().map(axis).collect::<Vec<_>>(),
281            degree,
282        )
283    };
284    let xs = solve_axis(|point| point.x)?;
285    let ys = solve_axis(|point| point.y)?;
286    let zs = solve_axis(|point| point.z)?;
287    NurbsCurve::new(
288        degree,
289        knots,
290        (0..=n)
291            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
292            .collect(),
293    )
294}
295
296/// Cubic interpolation with PRESCRIBED end derivatives (Piegl–Tiller §9.2.2):
297/// n+1 points plus two tangent rows give n+3 clamped control points. The
298/// derivative conditions use the exact clamped end forms
299/// C'(t0) = p/(u_{p+1}−t0)·(Q1−Q0) and C'(t1) = p/(t1−u_{m−p−1})·(Qn−Qn−1),
300/// so the requested tangents are reproduced exactly — the §5.8 loft tangency
301/// building block.
302pub fn interpolate_curve_with_end_tangents(
303    points: &[Vec3],
304    parameters: &[f64],
305    start_tangent: Vec3,
306    end_tangent: Vec3,
307) -> Result<NurbsCurve, String> {
308    if points.len() < 2 || parameters.len() != points.len() {
309        return Err(
310            "interpolate_curve_with_end_tangents: points and parameters must match, >= 2".into(),
311        );
312    }
313    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
314        return Err("interpolate_curve_with_end_tangents: parameters must increase".into());
315    }
316    let degree = 3usize;
317    let n = points.len() - 1;
318    let control_count = n + 3;
319    let t0 = parameters[0];
320    let t1 = parameters[n];
321    // Clamped knots with n−1 interior values averaged over parameter runs
322    // (the tangent rows consume the two extra controls).
323    let mut knots = vec![t0; degree + 1];
324    for j in 0..n.saturating_sub(1) {
325        let window = &parameters[j + 1..(j + degree).min(n) + 1];
326        knots.push(window.iter().sum::<f64>() / window.len() as f64);
327    }
328    knots.extend(std::iter::repeat(t1).take(degree + 1));
329    if knots.len() != control_count + degree + 1 {
330        return Err(format!(
331            "interpolate_curve_with_end_tangents: internal knot count {} for {} controls",
332            knots.len(),
333            control_count
334        ));
335    }
336    let knot_vector = KnotVector::new(knots.clone(), degree)?;
337    let mut matrix = vec![vec![0.0; control_count]; control_count];
338    let mut rhs_points = vec![Vec3::default(); control_count];
339    // Row 0: C(t0) = P0; row 1: start tangent; rows 2..=n: interior + end
340    // interpolation; row n+1... reorganized: standard layout is
341    // [P0, T0, P1..Pn-1, T1, Pn].
342    matrix[0][0] = 1.0;
343    rhs_points[0] = points[0];
344    let start_span = knots[degree + 1] - t0;
345    matrix[1][0] = -(degree as f64) / start_span;
346    matrix[1][1] = (degree as f64) / start_span;
347    rhs_points[1] = start_tangent;
348    for (index, &parameter) in parameters.iter().enumerate().take(n).skip(1) {
349        let row = index + 1;
350        let span = knot_vector.find_span(parameter);
351        let basis = knot_vector.basis_functions(span, parameter);
352        for (offset, value) in basis.into_iter().enumerate() {
353            matrix[row][span - degree + offset] = value;
354        }
355        rhs_points[row] = points[index];
356    }
357    let end_span = t1 - knots[control_count - 1];
358    matrix[control_count - 2][control_count - 2] = -(degree as f64) / end_span;
359    matrix[control_count - 2][control_count - 1] = (degree as f64) / end_span;
360    rhs_points[control_count - 2] = end_tangent;
361    matrix[control_count - 1][control_count - 1] = 1.0;
362    rhs_points[control_count - 1] = points[n];
363    let solve_axis = |axis: fn(Vec3) -> f64| {
364        solve_dense(
365            matrix.clone(),
366            rhs_points.iter().copied().map(axis).collect::<Vec<_>>(),
367        )
368    };
369    let xs = solve_axis(|point| point.x)?;
370    let ys = solve_axis(|point| point.y)?;
371    let zs = solve_axis(|point| point.z)?;
372    NurbsCurve::new(
373        degree,
374        knots,
375        (0..control_count)
376            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
377            .collect(),
378    )
379}
380
381pub fn interpolate_curve_thinned(
382    points: &[Vec3],
383    degree: usize,
384    maximum_points: usize,
385) -> Result<NurbsCurve, String> {
386    if maximum_points < 2 {
387        return Err("interpolate_curve_thinned: maximum point count must be at least 2".into());
388    }
389    if points.len() <= maximum_points {
390        let parameters = chord_parameters(points);
391        return interpolate_curve(points, degree, &parameters);
392    }
393    let step = (points.len() - 1) as f64 / (maximum_points - 1) as f64;
394    let thinned = (0..maximum_points)
395        .map(|index| points[(index as f64 * step).round() as usize])
396        .collect::<Vec<_>>();
397    let parameters = chord_parameters(&thinned);
398    interpolate_curve(&thinned, degree, &parameters)
399}
400
401fn chord_parameters(points: &[Vec3]) -> Vec<f64> {
402    if points.len() < 2 {
403        return vec![0.0; points.len()];
404    }
405    let mut parameters = vec![0.0; points.len()];
406    for index in 1..points.len() {
407        parameters[index] = parameters[index - 1] + points[index].sub(points[index - 1]).length();
408    }
409    let length = parameters[points.len() - 1];
410    if length <= 1e-15 {
411        for (index, parameter) in parameters.iter_mut().enumerate() {
412            *parameter = index as f64 / (points.len() - 1) as f64;
413        }
414    } else {
415        for parameter in &mut parameters {
416            *parameter /= length;
417        }
418    }
419    parameters
420}
421
422pub fn simplify_polyline(points: &[Vec3], tolerance: f64) -> Vec<Vec3> {
423    if points.len() <= 2 {
424        return points.to_vec();
425    }
426    let mut keep = vec![false; points.len()];
427    keep[0] = true;
428    keep[points.len() - 1] = true;
429    let mut stack = vec![(0usize, points.len() - 1)];
430    while let Some((start, end)) = stack.pop() {
431        if end - start < 2 {
432            continue;
433        }
434        let a = points[start];
435        let direction = points[end].sub(a);
436        let length_squared = direction.length_squared().max(1e-300);
437        let mut worst = None;
438        let mut worst_distance = tolerance;
439        for (index, point) in points.iter().enumerate().take(end).skip(start + 1) {
440            let fraction = point.sub(a).dot(direction) / length_squared;
441            let fraction = fraction.clamp(0.0, 1.0);
442            let distance = point.sub(a.add(direction.scale(fraction))).length();
443            if distance > worst_distance {
444                worst_distance = distance;
445                worst = Some(index);
446            }
447        }
448        if let Some(index) = worst {
449            keep[index] = true;
450            stack.push((start, index));
451            stack.push((index, end));
452        }
453    }
454    points
455        .iter()
456        .copied()
457        .zip(keep)
458        .filter_map(|(point, keep)| keep.then_some(point))
459        .collect()
460}
461
462pub fn interpolate_curve_local(
463    points: &[Vec3],
464    parameters: &[f64],
465    tension: f64,
466) -> Result<NurbsCurve, String> {
467    if points.len() != parameters.len() || points.len() < 2 {
468        return Err(
469            "interpolate_curve_local: points and parameters must have matching length >= 2".into(),
470        );
471    }
472    if points.len() == 2 {
473        return interpolate_curve(points, 1, parameters);
474    }
475    let count = points.len();
476    let mut tangents = vec![Vec3::default(); count];
477    tangents[0] = points[1]
478        .sub(points[0])
479        .scale(1.0 / (parameters[1] - parameters[0]));
480    tangents[count - 1] = points[count - 1]
481        .sub(points[count - 2])
482        .scale(1.0 / (parameters[count - 1] - parameters[count - 2]));
483    for index in 1..count - 1 {
484        let previous_interval = parameters[index] - parameters[index - 1];
485        let next_interval = parameters[index + 1] - parameters[index];
486        let total = previous_interval + next_interval;
487        let previous_secant = points[index]
488            .sub(points[index - 1])
489            .scale(1.0 / previous_interval);
490        let next_secant = points[index + 1]
491            .sub(points[index])
492            .scale(1.0 / next_interval);
493        let mut tangent =
494            points[index - 1]
495                .scale(-next_interval / (previous_interval * total))
496                .add(points[index].scale(
497                    (next_interval - previous_interval) / (previous_interval * next_interval),
498                ))
499                .add(points[index + 1].scale(previous_interval / (next_interval * total)));
500        if previous_secant.dot(next_secant) <= 0.0
501            || tangent.dot(previous_secant) <= 0.0
502            || tangent.dot(next_secant) <= 0.0
503        {
504            tangent = Vec3::default();
505        } else {
506            let maximum = 3.0 * previous_secant.length().min(next_secant.length());
507            if tangent.length() > maximum {
508                tangent = tangent.normalized()?.scale(maximum);
509            }
510        }
511        tangents[index] = tangent;
512    }
513    if tension != 1.0 {
514        for tangent in &mut tangents {
515            *tangent = tangent.scale(tension);
516        }
517    }
518    let mut control_points = vec![Vec4::from_point(points[0], 1.0)];
519    for index in 0..count - 1 {
520        let interval = parameters[index + 1] - parameters[index];
521        control_points.extend([
522            Vec4::from_point(
523                points[index].add(tangents[index].scale(interval / 3.0)),
524                1.0,
525            ),
526            Vec4::from_point(
527                points[index + 1].sub(tangents[index + 1].scale(interval / 3.0)),
528                1.0,
529            ),
530            Vec4::from_point(points[index + 1], 1.0),
531        ]);
532    }
533    let mut knots = vec![parameters[0]; 4];
534    for parameter in &parameters[1..count - 1] {
535        knots.extend([*parameter; 3]);
536    }
537    knots.extend([parameters[count - 1]; 4]);
538    NurbsCurve::new(3, knots, control_points)
539}
540
541pub fn fit_polyline(
542    points: &[Vec3],
543    tolerance: f64,
544    maximum_points: usize,
545    local_interpolation: bool,
546) -> Result<PolylineFit, String> {
547    let mut kept = simplify_polyline(points, tolerance);
548    if kept.len() > 2 {
549        let total: f64 = kept
550            .windows(2)
551            .map(|pair| pair[1].sub(pair[0]).length())
552            .sum();
553        let floor = (tolerance * 0.01).max(total * 1e-4);
554        let first = kept[0];
555        let last = kept[kept.len() - 1];
556        let mut conditioned = vec![first];
557        for point in &kept[1..kept.len() - 1] {
558            if point.sub(first).length() > floor
559                && point.sub(last).length() > floor
560                && point.sub(*conditioned.last().unwrap()).length() > floor
561            {
562                conditioned.push(*point);
563            }
564        }
565        conditioned.push(last);
566        kept = conditioned;
567    } else {
568        let mut distinct = Vec::new();
569        for point in kept {
570            if distinct
571                .last()
572                .is_none_or(|previous: &Vec3| point.sub(*previous).length() > tolerance * 0.01)
573            {
574                distinct.push(point);
575            }
576        }
577        kept = distinct;
578    }
579    if kept.len() < 2 {
580        return Err("fit_polyline: degenerate polyline".into());
581    }
582    let maximum_points = maximum_points.max(2);
583    if kept.len() > maximum_points {
584        let step = (kept.len() - 1) as f64 / (maximum_points - 1) as f64;
585        kept = (0..maximum_points)
586            .map(|index| kept[(index as f64 * step).round() as usize])
587            .collect();
588    }
589    let total: f64 = kept
590        .windows(2)
591        .map(|pair| pair[1].sub(pair[0]).length())
592        .sum();
593    if total <= 0.0 {
594        return Err("fit_polyline: degenerate polyline".into());
595    }
596    let mut parameters = vec![0.0; kept.len()];
597    let mut accumulated = 0.0;
598    for index in 1..kept.len() {
599        accumulated += kept[index].sub(kept[index - 1]).length();
600        parameters[index] = accumulated / total;
601    }
602    *parameters.last_mut().unwrap() = 1.0;
603    let curve = if local_interpolation {
604        interpolate_curve_local(&kept, &parameters, 1.0)?
605    } else {
606        interpolate_curve(&kept, 3usize.min(kept.len() - 1), &parameters)?
607    };
608    Ok(PolylineFit {
609        curve,
610        parameters,
611        kept,
612    })
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn global_interpolation_passes_through_supplied_parameters() {
621        let points = [
622            Vec3::new(0.0, 0.0, 0.0),
623            Vec3::new(1.0, 2.0, 0.5),
624            Vec3::new(3.0, -1.0, 1.0),
625            Vec3::new(5.0, 0.0, 2.0),
626        ];
627        let parameters = [0.0, 0.2, 0.75, 1.0];
628        let curve = interpolate_curve(&points, 3, &parameters).unwrap();
629        for (point, parameter) in points.iter().zip(parameters) {
630            assert!(curve.evaluate(parameter).unwrap().sub(*point).length() < 1e-10);
631        }
632    }
633
634    #[test]
635    fn polyline_fit_matches_retained_samples() {
636        let points = (0..=40)
637            .map(|index| {
638                let x = index as f64 / 10.0;
639                Vec3::new(x, x.sin(), 0.1 * x * x)
640            })
641            .collect::<Vec<_>>();
642        let fit = fit_polyline(&points, 1e-4, 20, false).unwrap();
643        assert!(fit.kept.len() <= 20);
644        for (point, parameter) in fit.kept.iter().zip(&fit.parameters) {
645            assert!(fit.curve.evaluate(*parameter).unwrap().sub(*point).length() < 1e-9);
646        }
647    }
648
649    #[test]
650    fn local_fit_has_bounded_spans() {
651        let points = [
652            Vec3::new(0.0, 0.0, 0.0),
653            Vec3::new(1.0, 0.0, 0.0),
654            Vec3::new(1.1, 1.0, 0.0),
655            Vec3::new(2.0, 1.0, 0.0),
656        ];
657        let fit = fit_polyline(&points, 1e-8, 20, true).unwrap();
658        for (point, parameter) in fit.kept.iter().zip(&fit.parameters) {
659            assert!(fit.curve.evaluate(*parameter).unwrap().sub(*point).length() < 1e-9);
660        }
661    }
662
663    #[test]
664    fn banded_solver_matches_known_tridiagonal_solution() {
665        let matrix = vec![
666            vec![2.0, -1.0, 0.0, 0.0],
667            vec![-1.0, 2.0, -1.0, 0.0],
668            vec![0.0, -1.0, 2.0, -1.0],
669            vec![0.0, 0.0, -1.0, 2.0],
670        ];
671        let solution = solve_banded(&matrix, &[0.0, 0.0, 0.0, 5.0], 1).unwrap();
672        for (actual, expected) in solution.iter().zip([1.0, 2.0, 3.0, 4.0]) {
673            assert!((actual - expected).abs() < 1e-12);
674        }
675    }
676
677    #[test]
678    fn thinned_interpolation_caps_control_points_and_keeps_endpoints() {
679        let points = (0..100)
680            .map(|index| Vec3::new(index as f64, (index as f64 * 0.1).sin(), 0.0))
681            .collect::<Vec<_>>();
682        let curve = interpolate_curve_thinned(&points, 3, 12).unwrap();
683        assert_eq!(curve.control_points.len(), 12);
684        let [start, end] = curve.domain().unwrap();
685        assert!(curve.evaluate(start).unwrap().sub(points[0]).length() < 1e-12);
686        assert!(
687            curve
688                .evaluate(end)
689                .unwrap()
690                .sub(points[points.len() - 1])
691                .length()
692                < 1e-12
693        );
694    }
695}
696
697#[cfg(test)]
698mod end_tangent_tests {
699    use super::*;
700
701    #[test]
702    fn end_tangent_interpolation_reproduces_points_and_tangents_exactly() {
703        let points = [
704            Vec3::new(0.0, 0.0, 0.0),
705            Vec3::new(1.0, 2.0, 0.5),
706            Vec3::new(3.0, 2.5, 1.0),
707            Vec3::new(5.0, 1.0, 2.0),
708        ];
709        let parameters = [0.0, 0.3, 0.65, 1.0];
710        let start = Vec3::new(0.0, 5.0, 1.0);
711        let end = Vec3::new(4.0, -3.0, 2.0);
712        let curve = interpolate_curve_with_end_tangents(&points, &parameters, start, end).unwrap();
713        for (point, parameter) in points.iter().zip(&parameters) {
714            let value = curve.evaluate(*parameter).unwrap();
715            assert!(value.sub(*point).length() < 1e-9, "point at {parameter}");
716        }
717        let d0 = curve.derivatives(0.0, 1).unwrap()[1];
718        let d1 = curve.derivatives(1.0, 1).unwrap()[1];
719        assert!(d0.sub(start).length() < 1e-9, "start tangent {d0:?}");
720        assert!(d1.sub(end).length() < 1e-9, "end tangent {d1:?}");
721    }
722
723    #[test]
724    fn two_point_end_tangent_interpolation_is_a_cubic_hermite() {
725        let points = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)];
726        let parameters = [0.0, 1.0];
727        let start = Vec3::new(0.0, 3.0, 0.0);
728        let end = Vec3::new(0.0, -3.0, 0.0);
729        let curve = interpolate_curve_with_end_tangents(&points, &parameters, start, end).unwrap();
730        // Hermite midpoint: h00·P0 + h10·T0 + h01·P1 + h11·T1 at t=0.5.
731        let expected = Vec3::new(1.0, 3.0 * 0.125 - 3.0 * -0.125, 0.0);
732        let mid = curve.evaluate(0.5).unwrap();
733        assert!(mid.sub(expected).length() < 1e-9, "{mid:?} vs {expected:?}");
734    }
735}
736
737/// Cox–de Boor basis over a RAW (possibly unclamped) knot array — the local
738/// helper the periodic interpolation needs; `KnotVector` validation rightly
739/// rejects unclamped arrays, so this stays private to the fit module.
740fn raw_basis(knots: &[f64], degree: usize, span: usize, parameter: f64) -> Vec<f64> {
741    let mut basis = vec![0.0; degree + 1];
742    let mut left = vec![0.0; degree + 1];
743    let mut right = vec![0.0; degree + 1];
744    basis[0] = 1.0;
745    for j in 1..=degree {
746        left[j] = parameter - knots[span + 1 - j];
747        right[j] = knots[span + j] - parameter;
748        let mut saved = 0.0;
749        for r in 0..j {
750            let denominator = right[r + 1] + left[j - r];
751            let temp = if denominator.abs() > 0.0 {
752                basis[r] / denominator
753            } else {
754                0.0
755            };
756            basis[r] = saved + right[r + 1] * temp;
757            saved = left[j - r] * temp;
758        }
759        basis[j] = saved;
760    }
761    basis
762}
763
764/// Boehm single-knot insertion on raw arrays (degree fixed by caller).
765fn raw_insert_knot(knots: &mut Vec<f64>, controls: &mut Vec<Vec3>, degree: usize, parameter: f64) {
766    // span: last index with knots[span] <= parameter, clamped to the valid
767    // control range (the textbook find_span clamp — inserting at the domain
768    // end otherwise indexes one past the control array).
769    let span = knots
770        .iter()
771        .rposition(|&knot| knot <= parameter + 1e-14)
772        .unwrap()
773        .min(controls.len() - 1);
774    let mut fresh = Vec::with_capacity(controls.len() + 1);
775    fresh.extend_from_slice(&controls[..=span - degree]);
776    for i in span - degree + 1..=span {
777        let denominator = knots[i + degree] - knots[i];
778        let alpha = if denominator.abs() > 0.0 {
779            (parameter - knots[i]) / denominator
780        } else {
781            0.0
782        };
783        fresh.push(
784            controls[i - 1]
785                .scale(1.0 - alpha)
786                .add(controls[i].scale(alpha)),
787        );
788    }
789    fresh.extend_from_slice(&controls[span..]);
790    *controls = fresh;
791    knots.insert(span + 1, parameter);
792}
793
794/// EXACT closed (periodic) cubic interpolation. `points` are the S >= 4
795/// distinct stations (first NOT repeated); `parameters` has S+1 strictly
796/// increasing values whose last entry closes the period. The cyclic
797/// collocation system is solved densely (S is small for lofts), and the
798/// periodic B-spline is re-expressed in CLAMPED form by Boehm-inserting the
799/// domain ends to full multiplicity — the representation every kernel
800/// consumer expects — so the seam is C² by construction, not by welding.
801pub fn interpolate_curve_closed(points: &[Vec3], parameters: &[f64]) -> Result<NurbsCurve, String> {
802    let degree = 3usize;
803    let station_count = points.len();
804    if station_count < 4 {
805        return Err("interpolate_curve_closed: need at least 4 stations".into());
806    }
807    if parameters.len() != station_count + 1 {
808        return Err(
809            "interpolate_curve_closed: parameters must have one more entry than points".into(),
810        );
811    }
812    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
813        return Err("interpolate_curve_closed: parameters must increase".into());
814    }
815    let period = parameters[station_count] - parameters[0];
816    // Cyclic knot line u_j = t_{j mod S} + floor(j/S)·T for j in −3..S+4,
817    // stored with offset 3: raw[k] = u_{k−3}.
818    let cyclic = |j: i64| -> f64 {
819        let s = station_count as i64;
820        let wrap = j.div_euclid(s);
821        parameters[j.rem_euclid(s) as usize] + wrap as f64 * period
822    };
823    let raw_knots: Vec<f64> = (-3..=(station_count as i64 + 3)).map(cyclic).collect();
824    // Collocation: row i evaluates the cubic basis at t_i; the span in the
825    // raw array is the one containing t_i (raw index i+3 == u_i).
826    let mut matrix = vec![vec![0.0; station_count]; station_count];
827    for i in 0..station_count {
828        let span = i + 3;
829        let basis = raw_basis(&raw_knots, degree, span, parameters[i]);
830        for (offset, value) in basis.iter().enumerate() {
831            // Control j = span − degree + offset in unclamped indexing, i.e.
832            // cyclic control (i + offset − 3) mod S.
833            let index = (i as i64 + offset as i64 - 3).rem_euclid(station_count as i64) as usize;
834            matrix[i][index] += value;
835        }
836    }
837    let solve_axis = |axis: fn(Vec3) -> f64| {
838        solve_dense(
839            matrix.clone(),
840            points.iter().copied().map(axis).collect::<Vec<_>>(),
841        )
842    };
843    let xs = solve_axis(|point| point.x)?;
844    let ys = solve_axis(|point| point.y)?;
845    let zs = solve_axis(|point| point.z)?;
846    let cyclic_controls: Vec<Vec3> = (0..station_count)
847        .map(|index| Vec3::new(xs[index], ys[index], zs[index]))
848        .collect();
849    // Window covering [t_0, t_S]: controls D_{−3..S−1} cyclically.
850    let mut window_controls: Vec<Vec3> = (-3..(station_count as i64))
851        .map(|j| cyclic_controls[j.rem_euclid(station_count as i64) as usize])
852        .collect();
853    let mut window_knots = raw_knots.clone();
854    // Clamp both domain ends to full multiplicity (degree insertions each —
855    // the ends currently sit at multiplicity 1).
856    for _ in 0..degree {
857        raw_insert_knot(
858            &mut window_knots,
859            &mut window_controls,
860            degree,
861            parameters[0],
862        );
863    }
864    for _ in 0..degree {
865        raw_insert_knot(
866            &mut window_knots,
867            &mut window_controls,
868            degree,
869            parameters[station_count],
870        );
871    }
872    // Slice out the clamped sub-curve over [t_0, t_S]: knots from the first
873    // occurrence of t_0 through the last of t_S, controls aligned so that
874    // control k pairs with knot span k..k+degree+1.
875    let first = window_knots
876        .iter()
877        .position(|&knot| (knot - parameters[0]).abs() < 1e-12)
878        .ok_or("interpolate_curve_closed: clamp lost the start knot")?;
879    let last = window_knots
880        .iter()
881        .rposition(|&knot| (knot - parameters[station_count]).abs() < 1e-12)
882        .ok_or("interpolate_curve_closed: clamp lost the end knot")?;
883    let clamped_knots: Vec<f64> = window_knots[first..=last].to_vec();
884    let control_count = clamped_knots.len() - degree - 1;
885    let clamped_controls: Vec<Vec4> = window_controls[first..first + control_count]
886        .iter()
887        .map(|point| Vec4::from_point(*point, 1.0))
888        .collect();
889    NurbsCurve::new(degree, clamped_knots, clamped_controls)
890}
891
892#[cfg(test)]
893mod closed_interpolation_tests {
894    use super::*;
895
896    #[test]
897    fn closed_interpolation_hits_stations_and_closes_c2() {
898        let count = 8usize;
899        let radius = 5.0;
900        let points: Vec<Vec3> = (0..count)
901            .map(|index| {
902                let angle = std::f64::consts::TAU * index as f64 / count as f64;
903                Vec3::new(radius * angle.cos(), radius * angle.sin(), 1.0)
904            })
905            .collect();
906        let parameters: Vec<f64> = (0..=count).map(|index| index as f64).collect();
907        let curve = interpolate_curve_closed(&points, &parameters).unwrap();
908        for (index, point) in points.iter().enumerate() {
909            let value = curve.evaluate(parameters[index]).unwrap();
910            assert!(
911                value.sub(*point).length() < 1e-9,
912                "station {index}: {value:?} vs {point:?}"
913            );
914        }
915        // Seam closure: value, first AND second derivative match end-to-end.
916        let start = curve.derivatives(parameters[0], 2).unwrap();
917        let end = curve.derivatives(parameters[count], 2).unwrap();
918        assert!(start[0].sub(end[0]).length() < 1e-9, "C0 seam");
919        assert!(start[1].sub(end[1]).length() < 1e-9, "C1 seam");
920        assert!(start[2].sub(end[2]).length() < 1e-6, "C2 seam");
921        // The interpolant stays near the circle between stations.
922        for sample in 0..64 {
923            let t = parameters[0] + (parameters[count] - parameters[0]) * sample as f64 / 64.0;
924            let value = curve.evaluate(t).unwrap();
925            let planar = (value.x * value.x + value.y * value.y).sqrt();
926            assert!(
927                (planar - radius).abs() < 0.05,
928                "radial sag at {t}: {planar}"
929            );
930            assert!((value.z - 1.0).abs() < 1e-9);
931        }
932    }
933
934    #[test]
935    fn closed_interpolation_handles_nonuniform_parameters() {
936        let points = [
937            Vec3::new(4.0, 0.0, 0.0),
938            Vec3::new(1.0, 3.0, 0.5),
939            Vec3::new(-3.0, 2.0, 1.0),
940            Vec3::new(-4.0, -1.0, 0.5),
941            Vec3::new(0.0, -3.5, 0.0),
942        ];
943        let parameters = [0.0, 0.8, 2.1, 3.0, 4.4, 5.5];
944        let curve = interpolate_curve_closed(&points, &parameters).unwrap();
945        for (index, point) in points.iter().enumerate() {
946            let value = curve.evaluate(parameters[index]).unwrap();
947            assert!(value.sub(*point).length() < 1e-9, "station {index}");
948        }
949        let start = curve.derivatives(0.0, 2).unwrap();
950        let end = curve.derivatives(5.5, 2).unwrap();
951        assert!(start[1].sub(end[1]).length() < 1e-9, "C1 seam");
952        assert!(start[2].sub(end[2]).length() < 1e-6, "C2 seam");
953    }
954}