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
245/// Global B-spline interpolation (The NURBS Book A9.1) through `points` at the
246/// supplied strictly increasing `parameters`.
247///
248/// The result is CLAMPED over exactly `[parameters[0], parameters[n]]` — the
249/// caller's own parameter interval, not a normalized `[0, 1]`. A9.1's end knots
250/// are `ū_0` and `ū_n`; hardcoding `0.0`/`1.0` there is only correct when the
251/// caller already normalized, and silently emits a knot vector that DESCENDS at
252/// the clamp when it did not (STEP import's `reconcile_edges_onto_surfaces`
253/// re-fits an edge over its own domain — on ABC 00000290 that is `[1.0,
254/// 1.2499]`, producing interior knots up to 1.234 followed by the four 1.0 end
255/// knots, i.e. a 0.234 descent that `validate_knots` rightly rejected). Since
256/// the averaged interior knots always lie strictly between `parameters[0]` and
257/// `parameters[n]`, taking the ends from the parameters makes the vector valid
258/// for ANY strictly increasing input, and is bit-identical for the already-
259/// normalized callers.
260pub fn interpolate_curve(
261    points: &[Vec3],
262    degree: usize,
263    parameters: &[f64],
264) -> Result<NurbsCurve, String> {
265    if points.len() < 2 || parameters.len() != points.len() {
266        return Err(
267            "interpolate_curve: points and parameters must have matching length >= 2".into(),
268        );
269    }
270    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
271        return Err("interpolate_curve: parameters must be strictly increasing".into());
272    }
273    let n = points.len() - 1;
274    let degree = degree.min(n);
275    let m = n + degree + 1;
276    let mut knots = vec![parameters[0]; m + 1];
277    for knot in &mut knots[m - degree..=m] {
278        *knot = parameters[n];
279    }
280    for j in 1..=n.saturating_sub(degree) {
281        knots[j + degree] = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
282    }
283    let knot_vector = KnotVector::new(knots.clone(), degree)?;
284    let mut matrix = vec![vec![0.0; n + 1]; n + 1];
285    for (row, &parameter) in parameters.iter().enumerate() {
286        let span = knot_vector.find_span(parameter);
287        let basis = knot_vector.basis_functions(span, parameter);
288        for (offset, value) in basis.into_iter().enumerate() {
289            matrix[row][span - degree + offset] = value;
290        }
291    }
292    let solve_axis = |axis: fn(Vec3) -> f64| {
293        solve_collocation(
294            &matrix,
295            &points.iter().copied().map(axis).collect::<Vec<_>>(),
296            degree,
297        )
298    };
299    let xs = solve_axis(|point| point.x)?;
300    let ys = solve_axis(|point| point.y)?;
301    let zs = solve_axis(|point| point.z)?;
302    NurbsCurve::new(
303        degree,
304        knots,
305        (0..=n)
306            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
307            .collect(),
308    )
309}
310
311/// Cubic interpolation with PRESCRIBED end derivatives (Piegl–Tiller §9.2.2):
312/// n+1 points plus two tangent rows give n+3 clamped control points. The
313/// derivative conditions use the exact clamped end forms
314/// C'(t0) = p/(u_{p+1}−t0)·(Q1−Q0) and C'(t1) = p/(t1−u_{m−p−1})·(Qn−Qn−1),
315/// so the requested tangents are reproduced exactly — the §5.8 loft tangency
316/// building block.
317pub fn interpolate_curve_with_end_tangents(
318    points: &[Vec3],
319    parameters: &[f64],
320    start_tangent: Vec3,
321    end_tangent: Vec3,
322) -> Result<NurbsCurve, String> {
323    if points.len() < 2 || parameters.len() != points.len() {
324        return Err(
325            "interpolate_curve_with_end_tangents: points and parameters must match, >= 2".into(),
326        );
327    }
328    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
329        return Err("interpolate_curve_with_end_tangents: parameters must increase".into());
330    }
331    let degree = 3usize;
332    let n = points.len() - 1;
333    let control_count = n + 3;
334    let t0 = parameters[0];
335    let t1 = parameters[n];
336    // Clamped knots with n−1 interior values averaged over parameter runs
337    // (the tangent rows consume the two extra controls).
338    let mut knots = vec![t0; degree + 1];
339    for j in 0..n.saturating_sub(1) {
340        let window = &parameters[j + 1..(j + degree).min(n) + 1];
341        knots.push(window.iter().sum::<f64>() / window.len() as f64);
342    }
343    knots.extend(std::iter::repeat(t1).take(degree + 1));
344    if knots.len() != control_count + degree + 1 {
345        return Err(format!(
346            "interpolate_curve_with_end_tangents: internal knot count {} for {} controls",
347            knots.len(),
348            control_count
349        ));
350    }
351    let knot_vector = KnotVector::new(knots.clone(), degree)?;
352    let mut matrix = vec![vec![0.0; control_count]; control_count];
353    let mut rhs_points = vec![Vec3::default(); control_count];
354    // Row 0: C(t0) = P0; row 1: start tangent; rows 2..=n: interior + end
355    // interpolation; row n+1... reorganized: standard layout is
356    // [P0, T0, P1..Pn-1, T1, Pn].
357    matrix[0][0] = 1.0;
358    rhs_points[0] = points[0];
359    let start_span = knots[degree + 1] - t0;
360    matrix[1][0] = -(degree as f64) / start_span;
361    matrix[1][1] = (degree as f64) / start_span;
362    rhs_points[1] = start_tangent;
363    for (index, &parameter) in parameters.iter().enumerate().take(n).skip(1) {
364        let row = index + 1;
365        let span = knot_vector.find_span(parameter);
366        let basis = knot_vector.basis_functions(span, parameter);
367        for (offset, value) in basis.into_iter().enumerate() {
368            matrix[row][span - degree + offset] = value;
369        }
370        rhs_points[row] = points[index];
371    }
372    let end_span = t1 - knots[control_count - 1];
373    matrix[control_count - 2][control_count - 2] = -(degree as f64) / end_span;
374    matrix[control_count - 2][control_count - 1] = (degree as f64) / end_span;
375    rhs_points[control_count - 2] = end_tangent;
376    matrix[control_count - 1][control_count - 1] = 1.0;
377    rhs_points[control_count - 1] = points[n];
378    let solve_axis = |axis: fn(Vec3) -> f64| {
379        solve_dense(
380            matrix.clone(),
381            rhs_points.iter().copied().map(axis).collect::<Vec<_>>(),
382        )
383    };
384    let xs = solve_axis(|point| point.x)?;
385    let ys = solve_axis(|point| point.y)?;
386    let zs = solve_axis(|point| point.z)?;
387    NurbsCurve::new(
388        degree,
389        knots,
390        (0..control_count)
391            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
392            .collect(),
393    )
394}
395
396pub fn interpolate_curve_thinned(
397    points: &[Vec3],
398    degree: usize,
399    maximum_points: usize,
400) -> Result<NurbsCurve, String> {
401    if maximum_points < 2 {
402        return Err("interpolate_curve_thinned: maximum point count must be at least 2".into());
403    }
404    if points.len() <= maximum_points {
405        let parameters = chord_parameters(points);
406        return interpolate_curve(points, degree, &parameters);
407    }
408    let step = (points.len() - 1) as f64 / (maximum_points - 1) as f64;
409    let thinned = (0..maximum_points)
410        .map(|index| points[(index as f64 * step).round() as usize])
411        .collect::<Vec<_>>();
412    let parameters = chord_parameters(&thinned);
413    interpolate_curve(&thinned, degree, &parameters)
414}
415
416fn chord_parameters(points: &[Vec3]) -> Vec<f64> {
417    if points.len() < 2 {
418        return vec![0.0; points.len()];
419    }
420    let mut parameters = vec![0.0; points.len()];
421    for index in 1..points.len() {
422        parameters[index] = parameters[index - 1] + points[index].sub(points[index - 1]).length();
423    }
424    let length = parameters[points.len() - 1];
425    if length <= 1e-15 {
426        for (index, parameter) in parameters.iter_mut().enumerate() {
427            *parameter = index as f64 / (points.len() - 1) as f64;
428        }
429    } else {
430        for parameter in &mut parameters {
431            *parameter /= length;
432        }
433    }
434    parameters
435}
436
437pub fn simplify_polyline(points: &[Vec3], tolerance: f64) -> Vec<Vec3> {
438    if points.len() <= 2 {
439        return points.to_vec();
440    }
441    let mut keep = vec![false; points.len()];
442    keep[0] = true;
443    keep[points.len() - 1] = true;
444    let mut stack = vec![(0usize, points.len() - 1)];
445    while let Some((start, end)) = stack.pop() {
446        if end - start < 2 {
447            continue;
448        }
449        let a = points[start];
450        let direction = points[end].sub(a);
451        let length_squared = direction.length_squared().max(1e-300);
452        let mut worst = None;
453        let mut worst_distance = tolerance;
454        for (index, point) in points.iter().enumerate().take(end).skip(start + 1) {
455            let fraction = point.sub(a).dot(direction) / length_squared;
456            let fraction = fraction.clamp(0.0, 1.0);
457            let distance = point.sub(a.add(direction.scale(fraction))).length();
458            if distance > worst_distance {
459                worst_distance = distance;
460                worst = Some(index);
461            }
462        }
463        if let Some(index) = worst {
464            keep[index] = true;
465            stack.push((start, index));
466            stack.push((index, end));
467        }
468    }
469    points
470        .iter()
471        .copied()
472        .zip(keep)
473        .filter_map(|(point, keep)| keep.then_some(point))
474        .collect()
475}
476
477pub fn interpolate_curve_local(
478    points: &[Vec3],
479    parameters: &[f64],
480    tension: f64,
481) -> Result<NurbsCurve, String> {
482    if points.len() != parameters.len() || points.len() < 2 {
483        return Err(
484            "interpolate_curve_local: points and parameters must have matching length >= 2".into(),
485        );
486    }
487    if points.len() == 2 {
488        return interpolate_curve(points, 1, parameters);
489    }
490    let count = points.len();
491    let mut tangents = vec![Vec3::default(); count];
492    tangents[0] = points[1]
493        .sub(points[0])
494        .scale(1.0 / (parameters[1] - parameters[0]));
495    tangents[count - 1] = points[count - 1]
496        .sub(points[count - 2])
497        .scale(1.0 / (parameters[count - 1] - parameters[count - 2]));
498    for index in 1..count - 1 {
499        let previous_interval = parameters[index] - parameters[index - 1];
500        let next_interval = parameters[index + 1] - parameters[index];
501        let total = previous_interval + next_interval;
502        let previous_secant = points[index]
503            .sub(points[index - 1])
504            .scale(1.0 / previous_interval);
505        let next_secant = points[index + 1]
506            .sub(points[index])
507            .scale(1.0 / next_interval);
508        let mut tangent =
509            points[index - 1]
510                .scale(-next_interval / (previous_interval * total))
511                .add(points[index].scale(
512                    (next_interval - previous_interval) / (previous_interval * next_interval),
513                ))
514                .add(points[index + 1].scale(previous_interval / (next_interval * total)));
515        if previous_secant.dot(next_secant) <= 0.0
516            || tangent.dot(previous_secant) <= 0.0
517            || tangent.dot(next_secant) <= 0.0
518        {
519            tangent = Vec3::default();
520        } else {
521            let maximum = 3.0 * previous_secant.length().min(next_secant.length());
522            if tangent.length() > maximum {
523                tangent = tangent.normalized()?.scale(maximum);
524            }
525        }
526        tangents[index] = tangent;
527    }
528    if tension != 1.0 {
529        for tangent in &mut tangents {
530            *tangent = tangent.scale(tension);
531        }
532    }
533    let mut control_points = vec![Vec4::from_point(points[0], 1.0)];
534    for index in 0..count - 1 {
535        let interval = parameters[index + 1] - parameters[index];
536        control_points.extend([
537            Vec4::from_point(
538                points[index].add(tangents[index].scale(interval / 3.0)),
539                1.0,
540            ),
541            Vec4::from_point(
542                points[index + 1].sub(tangents[index + 1].scale(interval / 3.0)),
543                1.0,
544            ),
545            Vec4::from_point(points[index + 1], 1.0),
546        ]);
547    }
548    let mut knots = vec![parameters[0]; 4];
549    for parameter in &parameters[1..count - 1] {
550        knots.extend([*parameter; 3]);
551    }
552    knots.extend([parameters[count - 1]; 4]);
553    NurbsCurve::new(3, knots, control_points)
554}
555
556pub fn fit_polyline(
557    points: &[Vec3],
558    tolerance: f64,
559    maximum_points: usize,
560    local_interpolation: bool,
561) -> Result<PolylineFit, String> {
562    let mut kept = simplify_polyline(points, tolerance);
563    if kept.len() > 2 {
564        let total: f64 = kept
565            .windows(2)
566            .map(|pair| pair[1].sub(pair[0]).length())
567            .sum();
568        let floor = (tolerance * 0.01).max(total * 1e-4);
569        let first = kept[0];
570        let last = kept[kept.len() - 1];
571        let mut conditioned = vec![first];
572        for point in &kept[1..kept.len() - 1] {
573            if point.sub(first).length() > floor
574                && point.sub(last).length() > floor
575                && point.sub(*conditioned.last().unwrap()).length() > floor
576            {
577                conditioned.push(*point);
578            }
579        }
580        conditioned.push(last);
581        kept = conditioned;
582    } else {
583        let mut distinct = Vec::new();
584        for point in kept {
585            if distinct
586                .last()
587                .is_none_or(|previous: &Vec3| point.sub(*previous).length() > tolerance * 0.01)
588            {
589                distinct.push(point);
590            }
591        }
592        kept = distinct;
593    }
594    if kept.len() < 2 {
595        return Err("fit_polyline: degenerate polyline".into());
596    }
597    let maximum_points = maximum_points.max(2);
598    if kept.len() > maximum_points {
599        let step = (kept.len() - 1) as f64 / (maximum_points - 1) as f64;
600        kept = (0..maximum_points)
601            .map(|index| kept[(index as f64 * step).round() as usize])
602            .collect();
603    }
604    let total: f64 = kept
605        .windows(2)
606        .map(|pair| pair[1].sub(pair[0]).length())
607        .sum();
608    if total <= 0.0 {
609        return Err("fit_polyline: degenerate polyline".into());
610    }
611    let mut parameters = vec![0.0; kept.len()];
612    let mut accumulated = 0.0;
613    for index in 1..kept.len() {
614        accumulated += kept[index].sub(kept[index - 1]).length();
615        parameters[index] = accumulated / total;
616    }
617    *parameters.last_mut().unwrap() = 1.0;
618    let curve = if local_interpolation {
619        interpolate_curve_local(&kept, &parameters, 1.0)?
620    } else {
621        interpolate_curve(&kept, 3usize.min(kept.len() - 1), &parameters)?
622    };
623    Ok(PolylineFit {
624        curve,
625        parameters,
626        kept,
627    })
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn global_interpolation_passes_through_supplied_parameters() {
636        let points = [
637            Vec3::new(0.0, 0.0, 0.0),
638            Vec3::new(1.0, 2.0, 0.5),
639            Vec3::new(3.0, -1.0, 1.0),
640            Vec3::new(5.0, 0.0, 2.0),
641        ];
642        let parameters = [0.0, 0.2, 0.75, 1.0];
643        let curve = interpolate_curve(&points, 3, &parameters).unwrap();
644        for (point, parameter) in points.iter().zip(parameters) {
645            assert!(curve.evaluate(parameter).unwrap().sub(*point).length() < 1e-10);
646        }
647    }
648
649    /// A9.1 clamps to the SUPPLIED parameter interval. Callers that re-fit a
650    /// curve over its own (un-normalized) domain — STEP import's edge/carrier
651    /// reconciliation, whose ABC 00000290 edge lives on [1.0, 1.2499] — used to
652    /// get interior knots above the hardcoded 1.0 end knots, i.e. a knot vector
653    /// that DESCENDS at the clamp. Guard both the domain and monotonicity.
654    #[test]
655    fn global_interpolation_clamps_to_the_supplied_parameter_interval() {
656        const SAMPLES: usize = 32;
657        let (t0, t1) = (1.0, 1.2498908);
658        let parameters = (0..=SAMPLES)
659            .map(|index| t0 + (t1 - t0) * index as f64 / SAMPLES as f64)
660            .collect::<Vec<_>>();
661        let points = parameters
662            .iter()
663            .map(|t| Vec3::new(*t, (6.0 * t).sin(), t * t))
664            .collect::<Vec<_>>();
665        let curve = interpolate_curve(&points, 3, &parameters).unwrap();
666        assert!(
667            curve.knots.windows(2).all(|pair| pair[1] >= pair[0]),
668            "knot vector must be non-decreasing: {:?}",
669            curve.knots
670        );
671        let [d0, d1] = curve.domain().unwrap();
672        assert!((d0 - t0).abs() < 1e-12 && (d1 - t1).abs() < 1e-12);
673        for (point, parameter) in points.iter().zip(&parameters) {
674            assert!(curve.evaluate(*parameter).unwrap().sub(*point).length() < 1e-9);
675        }
676    }
677
678    #[test]
679    fn polyline_fit_matches_retained_samples() {
680        let points = (0..=40)
681            .map(|index| {
682                let x = index as f64 / 10.0;
683                Vec3::new(x, x.sin(), 0.1 * x * x)
684            })
685            .collect::<Vec<_>>();
686        let fit = fit_polyline(&points, 1e-4, 20, false).unwrap();
687        assert!(fit.kept.len() <= 20);
688        for (point, parameter) in fit.kept.iter().zip(&fit.parameters) {
689            assert!(fit.curve.evaluate(*parameter).unwrap().sub(*point).length() < 1e-9);
690        }
691    }
692
693    #[test]
694    fn local_fit_has_bounded_spans() {
695        let points = [
696            Vec3::new(0.0, 0.0, 0.0),
697            Vec3::new(1.0, 0.0, 0.0),
698            Vec3::new(1.1, 1.0, 0.0),
699            Vec3::new(2.0, 1.0, 0.0),
700        ];
701        let fit = fit_polyline(&points, 1e-8, 20, true).unwrap();
702        for (point, parameter) in fit.kept.iter().zip(&fit.parameters) {
703            assert!(fit.curve.evaluate(*parameter).unwrap().sub(*point).length() < 1e-9);
704        }
705    }
706
707    #[test]
708    fn banded_solver_matches_known_tridiagonal_solution() {
709        let matrix = vec![
710            vec![2.0, -1.0, 0.0, 0.0],
711            vec![-1.0, 2.0, -1.0, 0.0],
712            vec![0.0, -1.0, 2.0, -1.0],
713            vec![0.0, 0.0, -1.0, 2.0],
714        ];
715        let solution = solve_banded(&matrix, &[0.0, 0.0, 0.0, 5.0], 1).unwrap();
716        for (actual, expected) in solution.iter().zip([1.0, 2.0, 3.0, 4.0]) {
717            assert!((actual - expected).abs() < 1e-12);
718        }
719    }
720
721    #[test]
722    fn thinned_interpolation_caps_control_points_and_keeps_endpoints() {
723        let points = (0..100)
724            .map(|index| Vec3::new(index as f64, (index as f64 * 0.1).sin(), 0.0))
725            .collect::<Vec<_>>();
726        let curve = interpolate_curve_thinned(&points, 3, 12).unwrap();
727        assert_eq!(curve.control_points.len(), 12);
728        let [start, end] = curve.domain().unwrap();
729        assert!(curve.evaluate(start).unwrap().sub(points[0]).length() < 1e-12);
730        assert!(
731            curve
732                .evaluate(end)
733                .unwrap()
734                .sub(points[points.len() - 1])
735                .length()
736                < 1e-12
737        );
738    }
739}
740
741#[cfg(test)]
742mod end_tangent_tests {
743    use super::*;
744
745    #[test]
746    fn end_tangent_interpolation_reproduces_points_and_tangents_exactly() {
747        let points = [
748            Vec3::new(0.0, 0.0, 0.0),
749            Vec3::new(1.0, 2.0, 0.5),
750            Vec3::new(3.0, 2.5, 1.0),
751            Vec3::new(5.0, 1.0, 2.0),
752        ];
753        let parameters = [0.0, 0.3, 0.65, 1.0];
754        let start = Vec3::new(0.0, 5.0, 1.0);
755        let end = Vec3::new(4.0, -3.0, 2.0);
756        let curve = interpolate_curve_with_end_tangents(&points, &parameters, start, end).unwrap();
757        for (point, parameter) in points.iter().zip(&parameters) {
758            let value = curve.evaluate(*parameter).unwrap();
759            assert!(value.sub(*point).length() < 1e-9, "point at {parameter}");
760        }
761        let d0 = curve.derivatives(0.0, 1).unwrap()[1];
762        let d1 = curve.derivatives(1.0, 1).unwrap()[1];
763        assert!(d0.sub(start).length() < 1e-9, "start tangent {d0:?}");
764        assert!(d1.sub(end).length() < 1e-9, "end tangent {d1:?}");
765    }
766
767    #[test]
768    fn two_point_end_tangent_interpolation_is_a_cubic_hermite() {
769        let points = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)];
770        let parameters = [0.0, 1.0];
771        let start = Vec3::new(0.0, 3.0, 0.0);
772        let end = Vec3::new(0.0, -3.0, 0.0);
773        let curve = interpolate_curve_with_end_tangents(&points, &parameters, start, end).unwrap();
774        // Hermite midpoint: h00·P0 + h10·T0 + h01·P1 + h11·T1 at t=0.5.
775        let expected = Vec3::new(1.0, 3.0 * 0.125 - 3.0 * -0.125, 0.0);
776        let mid = curve.evaluate(0.5).unwrap();
777        assert!(mid.sub(expected).length() < 1e-9, "{mid:?} vs {expected:?}");
778    }
779}
780
781/// Cox–de Boor basis over a RAW (possibly unclamped) knot array — the local
782/// helper the periodic interpolation needs; `KnotVector` validation rightly
783/// rejects unclamped arrays, so this stays private to the fit module.
784fn raw_basis(knots: &[f64], degree: usize, span: usize, parameter: f64) -> Vec<f64> {
785    let mut basis = vec![0.0; degree + 1];
786    let mut left = vec![0.0; degree + 1];
787    let mut right = vec![0.0; degree + 1];
788    basis[0] = 1.0;
789    for j in 1..=degree {
790        left[j] = parameter - knots[span + 1 - j];
791        right[j] = knots[span + j] - parameter;
792        let mut saved = 0.0;
793        for r in 0..j {
794            let denominator = right[r + 1] + left[j - r];
795            let temp = if denominator.abs() > 0.0 {
796                basis[r] / denominator
797            } else {
798                0.0
799            };
800            basis[r] = saved + right[r + 1] * temp;
801            saved = left[j - r] * temp;
802        }
803        basis[j] = saved;
804    }
805    basis
806}
807
808/// Boehm single-knot insertion on raw arrays (degree fixed by caller).
809fn raw_insert_knot(knots: &mut Vec<f64>, controls: &mut Vec<Vec3>, degree: usize, parameter: f64) {
810    // span: last index with knots[span] <= parameter, clamped to the valid
811    // control range (the textbook find_span clamp — inserting at the domain
812    // end otherwise indexes one past the control array).
813    let span = knots
814        .iter()
815        .rposition(|&knot| knot <= parameter + 1e-14)
816        .unwrap()
817        .min(controls.len() - 1);
818    let mut fresh = Vec::with_capacity(controls.len() + 1);
819    fresh.extend_from_slice(&controls[..=span - degree]);
820    for i in span - degree + 1..=span {
821        let denominator = knots[i + degree] - knots[i];
822        let alpha = if denominator.abs() > 0.0 {
823            (parameter - knots[i]) / denominator
824        } else {
825            0.0
826        };
827        fresh.push(
828            controls[i - 1]
829                .scale(1.0 - alpha)
830                .add(controls[i].scale(alpha)),
831        );
832    }
833    fresh.extend_from_slice(&controls[span..]);
834    *controls = fresh;
835    knots.insert(span + 1, parameter);
836}
837
838/// EXACT closed (periodic) cubic interpolation. `points` are the S >= 4
839/// distinct stations (first NOT repeated); `parameters` has S+1 strictly
840/// increasing values whose last entry closes the period. The cyclic
841/// collocation system is solved densely (S is small for lofts), and the
842/// periodic B-spline is re-expressed in CLAMPED form by Boehm-inserting the
843/// domain ends to full multiplicity — the representation every kernel
844/// consumer expects — so the seam is C² by construction, not by welding.
845pub fn interpolate_curve_closed(points: &[Vec3], parameters: &[f64]) -> Result<NurbsCurve, String> {
846    let degree = 3usize;
847    let station_count = points.len();
848    if station_count < 4 {
849        return Err("interpolate_curve_closed: need at least 4 stations".into());
850    }
851    if parameters.len() != station_count + 1 {
852        return Err(
853            "interpolate_curve_closed: parameters must have one more entry than points".into(),
854        );
855    }
856    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
857        return Err("interpolate_curve_closed: parameters must increase".into());
858    }
859    let period = parameters[station_count] - parameters[0];
860    // Cyclic knot line u_j = t_{j mod S} + floor(j/S)·T for j in −3..S+4,
861    // stored with offset 3: raw[k] = u_{k−3}.
862    let cyclic = |j: i64| -> f64 {
863        let s = station_count as i64;
864        let wrap = j.div_euclid(s);
865        parameters[j.rem_euclid(s) as usize] + wrap as f64 * period
866    };
867    let raw_knots: Vec<f64> = (-3..=(station_count as i64 + 3)).map(cyclic).collect();
868    // Collocation: row i evaluates the cubic basis at t_i; the span in the
869    // raw array is the one containing t_i (raw index i+3 == u_i).
870    let mut matrix = vec![vec![0.0; station_count]; station_count];
871    for i in 0..station_count {
872        let span = i + 3;
873        let basis = raw_basis(&raw_knots, degree, span, parameters[i]);
874        for (offset, value) in basis.iter().enumerate() {
875            // Control j = span − degree + offset in unclamped indexing, i.e.
876            // cyclic control (i + offset − 3) mod S.
877            let index = (i as i64 + offset as i64 - 3).rem_euclid(station_count as i64) as usize;
878            matrix[i][index] += value;
879        }
880    }
881    let solve_axis = |axis: fn(Vec3) -> f64| {
882        solve_dense(
883            matrix.clone(),
884            points.iter().copied().map(axis).collect::<Vec<_>>(),
885        )
886    };
887    let xs = solve_axis(|point| point.x)?;
888    let ys = solve_axis(|point| point.y)?;
889    let zs = solve_axis(|point| point.z)?;
890    let cyclic_controls: Vec<Vec3> = (0..station_count)
891        .map(|index| Vec3::new(xs[index], ys[index], zs[index]))
892        .collect();
893    // Window covering [t_0, t_S]: controls D_{−3..S−1} cyclically.
894    let mut window_controls: Vec<Vec3> = (-3..(station_count as i64))
895        .map(|j| cyclic_controls[j.rem_euclid(station_count as i64) as usize])
896        .collect();
897    let mut window_knots = raw_knots.clone();
898    // Clamp both domain ends to full multiplicity (degree insertions each —
899    // the ends currently sit at multiplicity 1).
900    for _ in 0..degree {
901        raw_insert_knot(
902            &mut window_knots,
903            &mut window_controls,
904            degree,
905            parameters[0],
906        );
907    }
908    for _ in 0..degree {
909        raw_insert_knot(
910            &mut window_knots,
911            &mut window_controls,
912            degree,
913            parameters[station_count],
914        );
915    }
916    // Slice out the clamped sub-curve over [t_0, t_S]: knots from the first
917    // occurrence of t_0 through the last of t_S, controls aligned so that
918    // control k pairs with knot span k..k+degree+1.
919    let first = window_knots
920        .iter()
921        .position(|&knot| (knot - parameters[0]).abs() < 1e-12)
922        .ok_or("interpolate_curve_closed: clamp lost the start knot")?;
923    let last = window_knots
924        .iter()
925        .rposition(|&knot| (knot - parameters[station_count]).abs() < 1e-12)
926        .ok_or("interpolate_curve_closed: clamp lost the end knot")?;
927    let clamped_knots: Vec<f64> = window_knots[first..=last].to_vec();
928    let control_count = clamped_knots.len() - degree - 1;
929    let clamped_controls: Vec<Vec4> = window_controls[first..first + control_count]
930        .iter()
931        .map(|point| Vec4::from_point(*point, 1.0))
932        .collect();
933    NurbsCurve::new(degree, clamped_knots, clamped_controls)
934}
935
936#[cfg(test)]
937mod closed_interpolation_tests {
938    use super::*;
939
940    #[test]
941    fn closed_interpolation_hits_stations_and_closes_c2() {
942        let count = 8usize;
943        let radius = 5.0;
944        let points: Vec<Vec3> = (0..count)
945            .map(|index| {
946                let angle = std::f64::consts::TAU * index as f64 / count as f64;
947                Vec3::new(radius * angle.cos(), radius * angle.sin(), 1.0)
948            })
949            .collect();
950        let parameters: Vec<f64> = (0..=count).map(|index| index as f64).collect();
951        let curve = interpolate_curve_closed(&points, &parameters).unwrap();
952        for (index, point) in points.iter().enumerate() {
953            let value = curve.evaluate(parameters[index]).unwrap();
954            assert!(
955                value.sub(*point).length() < 1e-9,
956                "station {index}: {value:?} vs {point:?}"
957            );
958        }
959        // Seam closure: value, first AND second derivative match end-to-end.
960        let start = curve.derivatives(parameters[0], 2).unwrap();
961        let end = curve.derivatives(parameters[count], 2).unwrap();
962        assert!(start[0].sub(end[0]).length() < 1e-9, "C0 seam");
963        assert!(start[1].sub(end[1]).length() < 1e-9, "C1 seam");
964        assert!(start[2].sub(end[2]).length() < 1e-6, "C2 seam");
965        // The interpolant stays near the circle between stations.
966        for sample in 0..64 {
967            let t = parameters[0] + (parameters[count] - parameters[0]) * sample as f64 / 64.0;
968            let value = curve.evaluate(t).unwrap();
969            let planar = (value.x * value.x + value.y * value.y).sqrt();
970            assert!(
971                (planar - radius).abs() < 0.05,
972                "radial sag at {t}: {planar}"
973            );
974            assert!((value.z - 1.0).abs() < 1e-9);
975        }
976    }
977
978    #[test]
979    fn closed_interpolation_handles_nonuniform_parameters() {
980        let points = [
981            Vec3::new(4.0, 0.0, 0.0),
982            Vec3::new(1.0, 3.0, 0.5),
983            Vec3::new(-3.0, 2.0, 1.0),
984            Vec3::new(-4.0, -1.0, 0.5),
985            Vec3::new(0.0, -3.5, 0.0),
986        ];
987        let parameters = [0.0, 0.8, 2.1, 3.0, 4.4, 5.5];
988        let curve = interpolate_curve_closed(&points, &parameters).unwrap();
989        for (index, point) in points.iter().enumerate() {
990            let value = curve.evaluate(parameters[index]).unwrap();
991            assert!(value.sub(*point).length() < 1e-9, "station {index}");
992        }
993        let start = curve.derivatives(0.0, 2).unwrap();
994        let end = curve.derivatives(5.5, 2).unwrap();
995        assert!(start[1].sub(end[1]).length() < 1e-9, "C1 seam");
996        assert!(start[2].sub(end[2]).length() < 1e-6, "C2 seam");
997    }
998}