Skip to main content

brep_kernel/geometry/
fit.rs

1use crate::{DiagnosticSeverity, KernelDiagnostics, KernelStage, KnotVector, NurbsCurve, Vec3, Vec4};
2
3pub fn solve_dense(mut matrix: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Result<Vec<f64>, String> {
4    let count = rhs.len();
5    if count == 0 || matrix.len() != count || matrix.iter().any(|row| row.len() != count) {
6        return Err("solve_dense: matrix must be square and match RHS".into());
7    }
8    let scale = matrix
9        .iter()
10        .flatten()
11        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
12    if scale == 0.0 {
13        return Err("solve_dense: singular matrix".into());
14    }
15    for column in 0..count {
16        let pivot = (column..count)
17            .max_by(|&a, &b| matrix[a][column].abs().total_cmp(&matrix[b][column].abs()))
18            .unwrap();
19        if matrix[pivot][column].abs() <= 1e-13 * scale {
20            return Err("solve_dense: singular matrix".into());
21        }
22        matrix.swap(column, pivot);
23        rhs.swap(column, pivot);
24        let diagonal = matrix[column][column];
25        for row in column + 1..count {
26            let factor = matrix[row][column] / diagonal;
27            matrix[row][column] = 0.0;
28            for entry in column + 1..count {
29                matrix[row][entry] -= factor * matrix[column][entry];
30            }
31            rhs[row] -= factor * rhs[column];
32        }
33    }
34    let mut result = vec![0.0; count];
35    for row in (0..count).rev() {
36        let remainder: f64 = (row + 1..count)
37            .map(|column| matrix[row][column] * result[column])
38            .sum();
39        result[row] = (rhs[row] - remainder) / matrix[row][row];
40    }
41    Ok(result)
42}
43
44/// `solve_dense` for fixed-size systems on the stack — identical pivoting and
45/// thresholds, no allocation. `count <= N` solves the leading `count`-sized
46/// block (for callers that shrink the system by fixing parameters).
47pub fn solve_small<const N: usize>(
48    mut matrix: [[f64; N]; N],
49    mut rhs: [f64; N],
50    count: usize,
51) -> Result<[f64; N], String> {
52    if count == 0 || count > N {
53        return Err("solve_small: invalid system size".into());
54    }
55    let scale = matrix
56        .iter()
57        .take(count)
58        .flat_map(|row| row.iter().take(count))
59        .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
60    if scale == 0.0 {
61        return Err("solve_small: singular matrix".into());
62    }
63    for column in 0..count {
64        let pivot = (column..count)
65            .max_by(|&a, &b| matrix[a][column].abs().total_cmp(&matrix[b][column].abs()))
66            .unwrap();
67        if matrix[pivot][column].abs() <= 1e-13 * scale {
68            return Err("solve_small: singular matrix".into());
69        }
70        matrix.swap(column, pivot);
71        rhs.swap(column, pivot);
72        let diagonal = matrix[column][column];
73        for row in column + 1..count {
74            let factor = matrix[row][column] / diagonal;
75            matrix[row][column] = 0.0;
76            for entry in column + 1..count {
77                matrix[row][entry] -= factor * matrix[column][entry];
78            }
79            rhs[row] -= factor * rhs[column];
80        }
81    }
82    let mut result = [0.0; N];
83    for row in (0..count).rev() {
84        let remainder: f64 = (row + 1..count)
85            .map(|column| matrix[row][column] * result[column])
86            .sum();
87        result[row] = (rhs[row] - remainder) / matrix[row][row];
88    }
89    Ok(result)
90}
91
92/// Solve a B-spline interpolation (collocation) system. The collocation matrix
93/// `N_j(τ_i)` produced by the knot-averaging schemes below is banded with
94/// half-bandwidth ≤ `degree` (Schoenberg–Whitney) and totally positive, so a
95/// no-pivot BANDED elimination is O(n·degree²) — vs the O(n³) of the general
96/// dense solver — and is numerically identical to it on these systems.
97///
98/// The banded result is accepted only when its in-band residual is tiny;
99/// otherwise (a pathological / near-singular system) it falls back to the
100/// partial-pivoting dense solver. This keeps large STEP-import pcurve fits
101/// (which push `n` toward ~500) from turning each solve into a ~500³ blowup.
102pub fn solve_collocation(
103    matrix: &[Vec<f64>],
104    rhs: &[f64],
105    degree: usize,
106) -> Result<Vec<f64>, String> {
107    let count = rhs.len();
108    let bandwidth = degree.max(1).min(count.saturating_sub(1).max(1));
109    if let Ok(solution) = solve_banded(matrix, rhs, bandwidth) {
110        let scale = rhs
111            .iter()
112            .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
113        let tolerance = 1e-9 * scale.max(1.0);
114        let mut residual = 0.0_f64;
115        for row in 0..count {
116            let lo = row.saturating_sub(bandwidth);
117            let hi = (row + bandwidth).min(count - 1);
118            let mut accumulated = 0.0;
119            for col in lo..=hi {
120                accumulated += matrix[row][col] * solution[col];
121            }
122            residual = residual.max((accumulated - rhs[row]).abs());
123        }
124        if residual <= tolerance {
125            return Ok(solution);
126        }
127    }
128    solve_dense(matrix.to_vec(), rhs.to_vec())
129}
130
131/// A collocation matrix held as its band only: row `r` stores columns
132/// `r - bandwidth ..= r + bandwidth`, the entries [`solve_banded`]'s no-pivot
133/// elimination can read or fill. [`solve_collocation`] on a dense
134/// `(n+1)²` matrix allocates and copies O(n²) per axis for an O(n·degree²)
135/// solve; at the 2000-station ceiling of the pcurve fitters that dominated
136/// the solve (28 s of an 81 s `abc_00000011` import, 4051 builds, measured
137/// 2026-09-26). [`solve_collocation_banded`] performs the SAME operations in
138/// the same order on the same entries, so its result is bit-identical.
139pub(crate) struct CollocationBand {
140    count: usize,
141    bandwidth: usize,
142    data: Vec<f64>,
143}
144
145impl CollocationBand {
146    /// Zeroed, for `count` rows at [`solve_collocation`]'s bandwidth for
147    /// `degree`.
148    pub(crate) fn new(count: usize, degree: usize) -> Self {
149        let bandwidth = degree.max(1).min(count.saturating_sub(1).max(1));
150        CollocationBand { count, bandwidth, data: vec![0.0; count * (2 * bandwidth + 1)] }
151    }
152
153    fn slot(&self, row: usize, column: usize) -> Option<usize> {
154        let offset = column as isize - row as isize + self.bandwidth as isize;
155        (offset >= 0 && offset <= 2 * self.bandwidth as isize)
156            .then(|| row * (2 * self.bandwidth + 1) + offset as usize)
157    }
158
159    /// Store an entry; `false` when it lies outside the band (the caller then
160    /// solves densely, as [`solve_collocation`] would after its banded check).
161    pub(crate) fn set(&mut self, row: usize, column: usize, value: f64) -> bool {
162        match self.slot(row, column) {
163            Some(index) => {
164                self.data[index] = value;
165                true
166            }
167            None => value == 0.0,
168        }
169    }
170
171    fn get(&self, row: usize, column: usize) -> f64 {
172        self.slot(row, column).map_or(0.0, |index| self.data[index])
173    }
174
175    fn to_dense(&self) -> Vec<Vec<f64>> {
176        (0..self.count)
177            .map(|row| (0..self.count).map(|column| self.get(row, column)).collect())
178            .collect()
179    }
180}
181
182/// [`solve_collocation`] on a [`CollocationBand`]: the banded elimination with
183/// its in-band residual check, and the dense partial-pivoting fallback
184/// (materialised only then). Bit-identical to [`solve_collocation`] on the
185/// dense form of the same matrix.
186pub(crate) fn solve_collocation_banded(band: &CollocationBand, rhs: &[f64]) -> Result<Vec<f64>, String> {
187    let count = rhs.len();
188    if count == 0 || band.count != count {
189        return Err("solve_banded: matrix must be square and match RHS".into());
190    }
191    let bandwidth = band.bandwidth;
192    if let Ok(solution) = solve_band_storage(band, rhs) {
193        let scale = rhs
194            .iter()
195            .fold(0.0_f64, |maximum, value| maximum.max(value.abs()));
196        let tolerance = 1e-9 * scale.max(1.0);
197        let mut residual = 0.0_f64;
198        for row in 0..count {
199            let lo = row.saturating_sub(bandwidth);
200            let hi = (row + bandwidth).min(count - 1);
201            let mut accumulated = 0.0;
202            for col in lo..=hi {
203                accumulated += band.get(row, col) * solution[col];
204            }
205            residual = residual.max((accumulated - rhs[row]).abs());
206        }
207        if residual <= tolerance {
208            return Ok(solution);
209        }
210    }
211    solve_dense(band.to_dense(), rhs.to_vec())
212}
213
214fn solve_band_storage(band: &CollocationBand, rhs: &[f64]) -> Result<Vec<f64>, String> {
215    let count = rhs.len();
216    let bandwidth = band.bandwidth;
217    let width = 2 * bandwidth + 1;
218    let mut matrix = band.data.clone();
219    let mut rhs = rhs.to_vec();
220    // Row `r`, column `c` lives at `r * width + (c + bandwidth - r)`.
221    let at = |row: usize, column: usize| row * width + column + bandwidth - row;
222    for column in 0..count {
223        let pivot = matrix[at(column, column)];
224        if pivot.abs() <= 1e-300 {
225            return Err("solve_banded: singular matrix".into());
226        }
227        let maximum_row = (column + bandwidth).min(count - 1);
228        for row in column + 1..=maximum_row {
229            let factor = matrix[at(row, column)] / pivot;
230            if factor == 0.0 {
231                continue;
232            }
233            let maximum_column = (column + bandwidth).min(count - 1);
234            for entry in column..=maximum_column {
235                matrix[at(row, entry)] -= factor * matrix[at(column, entry)];
236            }
237            rhs[row] -= factor * rhs[column];
238        }
239    }
240    let mut result = vec![0.0; count];
241    for row in (0..count).rev() {
242        let mut value = rhs[row];
243        let maximum_column = (row + bandwidth).min(count - 1);
244        for column in row + 1..=maximum_column {
245            value -= matrix[at(row, column)] * result[column];
246        }
247        if matrix[at(row, row)].abs() <= 1e-300 {
248            return Err("solve_banded: singular matrix".into());
249        }
250        result[row] = value / matrix[at(row, row)];
251    }
252    Ok(result)
253}
254
255pub fn solve_banded(
256    matrix: &[Vec<f64>],
257    rhs: &[f64],
258    bandwidth: usize,
259) -> Result<Vec<f64>, String> {
260    let count = rhs.len();
261    if count == 0 || matrix.len() != count || matrix.iter().any(|row| row.len() != count) {
262        return Err("solve_banded: matrix must be square and match RHS".into());
263    }
264    let mut matrix = matrix.to_vec();
265    let mut rhs = rhs.to_vec();
266    for column in 0..count {
267        let pivot = matrix[column][column];
268        if pivot.abs() <= 1e-300 {
269            return Err("solve_banded: singular matrix".into());
270        }
271        let maximum_row = (column + bandwidth).min(count - 1);
272        for row in column + 1..=maximum_row {
273            let factor = matrix[row][column] / pivot;
274            if factor == 0.0 {
275                continue;
276            }
277            let maximum_column = (column + bandwidth).min(count - 1);
278            for entry in column..=maximum_column {
279                matrix[row][entry] -= factor * matrix[column][entry];
280            }
281            rhs[row] -= factor * rhs[column];
282        }
283    }
284    let mut result = vec![0.0; count];
285    for row in (0..count).rev() {
286        let mut value = rhs[row];
287        let maximum_column = (row + bandwidth).min(count - 1);
288        for column in row + 1..=maximum_column {
289            value -= matrix[row][column] * result[column];
290        }
291        if matrix[row][row].abs() <= 1e-300 {
292            return Err("solve_banded: singular matrix".into());
293        }
294        result[row] = value / matrix[row][row];
295    }
296    Ok(result)
297}
298
299/// Global B-spline interpolation (The NURBS Book A9.1), matching the
300/// reference implementation's supplied-parameter path.
301/// Interpolate HOMOGENEOUS points (Vec4, rational data) at the given
302/// parameters with the same averaged-knot scheme as `interpolate_curve`.
303/// Parameters must start at 0 and end at 1. Rows fitted with identical
304/// parameters share knots — the contract the blend-surface rows rely on.
305pub fn interpolate_homogeneous(
306    points: &[Vec4],
307    degree: usize,
308    parameters: &[f64],
309) -> Result<NurbsCurve, String> {
310    if points.len() < 2 || parameters.len() != points.len() {
311        return Err(
312            "interpolate_homogeneous: points and parameters must have matching length >= 2".into(),
313        );
314    }
315    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
316        return Err("interpolate_homogeneous: parameters must be strictly increasing".into());
317    }
318    let n = points.len() - 1;
319    let degree = degree.min(n);
320    let m = n + degree + 1;
321    let mut knots = vec![0.0; m + 1];
322    for knot in &mut knots[m - degree..=m] {
323        *knot = 1.0;
324    }
325    for j in 1..=n.saturating_sub(degree) {
326        knots[j + degree] = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
327    }
328    let knot_vector = KnotVector::new(knots.clone(), degree)?;
329    let mut matrix = vec![vec![0.0; n + 1]; n + 1];
330    for (row, &parameter) in parameters.iter().enumerate() {
331        let span = knot_vector.find_span(parameter);
332        let basis = knot_vector.basis_functions(span, parameter);
333        for (offset, value) in basis.into_iter().enumerate() {
334            matrix[row][span - degree + offset] = value;
335        }
336    }
337    let solve_axis = |axis: fn(&Vec4) -> f64| {
338        solve_collocation(
339            &matrix,
340            &points.iter().map(axis).collect::<Vec<_>>(),
341            degree,
342        )
343    };
344    let xs = solve_axis(|point| point.x)?;
345    let ys = solve_axis(|point| point.y)?;
346    let zs = solve_axis(|point| point.z)?;
347    let ws = solve_axis(|point| point.w)?;
348    NurbsCurve::new(
349        degree,
350        knots,
351        (0..=n)
352            .map(|index| Vec4 {
353                x: xs[index],
354                y: ys[index],
355                z: zs[index],
356                w: ws[index],
357            })
358            .collect(),
359    )
360}
361
362/// Global B-spline interpolation (The NURBS Book A9.1) through `points` at the
363/// supplied strictly increasing `parameters`.
364///
365/// The result is CLAMPED over exactly `[parameters[0], parameters[n]]` — the
366/// caller's own parameter interval, not a normalized `[0, 1]`. A9.1's end knots
367/// are `ū_0` and `ū_n`; hardcoding `0.0`/`1.0` there is only correct when the
368/// caller already normalized, and silently emits a knot vector that DESCENDS at
369/// the clamp when it did not (STEP import's `reconcile_edges_onto_surfaces`
370/// re-fits an edge over its own domain — on ABC 00000290 that is `[1.0,
371/// 1.2499]`, producing interior knots up to 1.234 followed by the four 1.0 end
372/// knots, i.e. a 0.234 descent that `validate_knots` rightly rejected). Since
373/// the averaged interior knots always lie strictly between `parameters[0]` and
374/// `parameters[n]`, taking the ends from the parameters makes the vector valid
375/// for ANY strictly increasing input, and is bit-identical for the already-
376/// normalized callers.
377pub fn interpolate_curve(
378    points: &[Vec3],
379    degree: usize,
380    parameters: &[f64],
381) -> Result<NurbsCurve, String> {
382    if points.len() < 2 || parameters.len() != points.len() {
383        return Err(
384            "interpolate_curve: points and parameters must have matching length >= 2".into(),
385        );
386    }
387    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
388        return Err("interpolate_curve: parameters must be strictly increasing".into());
389    }
390    let n = points.len() - 1;
391    let degree = degree.min(n);
392    let m = n + degree + 1;
393    let mut knots = vec![parameters[0]; m + 1];
394    for knot in &mut knots[m - degree..=m] {
395        *knot = parameters[n];
396    }
397    for j in 1..=n.saturating_sub(degree) {
398        knots[j + degree] = parameters[j..j + degree].iter().sum::<f64>() / degree as f64;
399    }
400    let knot_vector = KnotVector::new(knots.clone(), degree)?;
401    // The band alone ([`CollocationBand`]); the dense matrix only when a basis
402    // value falls outside it, which the dense banded solve could not use either.
403    let mut band = CollocationBand::new(n + 1, degree);
404    let mut dense: Option<Vec<Vec<f64>>> = None;
405    for (row, &parameter) in parameters.iter().enumerate() {
406        let span = knot_vector.find_span(parameter);
407        let basis = knot_vector.basis_functions(span, parameter);
408        for (offset, value) in basis.into_iter().enumerate() {
409            let column = span - degree + offset;
410            if !band.set(row, column, value) {
411                dense.get_or_insert_with(|| band.to_dense())[row][column] = value;
412            } else if let Some(dense) = dense.as_mut() {
413                dense[row][column] = value;
414            }
415        }
416    }
417    let solve_axis = |axis: fn(Vec3) -> f64| {
418        let rhs = points.iter().copied().map(axis).collect::<Vec<_>>();
419        match &dense {
420            Some(matrix) => solve_collocation(matrix, &rhs, degree),
421            None => solve_collocation_banded(&band, &rhs),
422        }
423    };
424    let xs = solve_axis(|point| point.x)?;
425    let ys = solve_axis(|point| point.y)?;
426    let zs = solve_axis(|point| point.z)?;
427    NurbsCurve::new(
428        degree,
429        knots,
430        (0..=n)
431            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
432            .collect(),
433    )
434}
435
436/// Cubic interpolation with PRESCRIBED end derivatives (Piegl–Tiller §9.2.2):
437/// n+1 points plus two tangent rows give n+3 clamped control points. The
438/// derivative conditions use the exact clamped end forms
439/// C'(t0) = p/(u_{p+1}−t0)·(Q1−Q0) and C'(t1) = p/(t1−u_{m−p−1})·(Qn−Qn−1),
440/// so the requested tangents are reproduced exactly — the §5.8 loft tangency
441/// building block.
442pub fn interpolate_curve_with_end_tangents(
443    points: &[Vec3],
444    parameters: &[f64],
445    start_tangent: Vec3,
446    end_tangent: Vec3,
447) -> Result<NurbsCurve, String> {
448    if points.len() < 2 || parameters.len() != points.len() {
449        return Err(
450            "interpolate_curve_with_end_tangents: points and parameters must match, >= 2".into(),
451        );
452    }
453    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
454        return Err("interpolate_curve_with_end_tangents: parameters must increase".into());
455    }
456    let degree = 3usize;
457    let n = points.len() - 1;
458    let control_count = n + 3;
459    let t0 = parameters[0];
460    let t1 = parameters[n];
461    // Clamped knots with n−1 interior values averaged over parameter runs
462    // (the tangent rows consume the two extra controls).
463    let mut knots = vec![t0; degree + 1];
464    for j in 0..n.saturating_sub(1) {
465        let window = &parameters[j + 1..(j + degree).min(n) + 1];
466        knots.push(window.iter().sum::<f64>() / window.len() as f64);
467    }
468    knots.extend(std::iter::repeat(t1).take(degree + 1));
469    if knots.len() != control_count + degree + 1 {
470        return Err(format!(
471            "interpolate_curve_with_end_tangents: internal knot count {} for {} controls",
472            knots.len(),
473            control_count
474        ));
475    }
476    let knot_vector = KnotVector::new(knots.clone(), degree)?;
477    let mut matrix = vec![vec![0.0; control_count]; control_count];
478    let mut rhs_points = vec![Vec3::default(); control_count];
479    // Row 0: C(t0) = P0; row 1: start tangent; rows 2..=n: interior + end
480    // interpolation; row n+1... reorganized: standard layout is
481    // [P0, T0, P1..Pn-1, T1, Pn].
482    matrix[0][0] = 1.0;
483    rhs_points[0] = points[0];
484    let start_span = knots[degree + 1] - t0;
485    matrix[1][0] = -(degree as f64) / start_span;
486    matrix[1][1] = (degree as f64) / start_span;
487    rhs_points[1] = start_tangent;
488    for (index, &parameter) in parameters.iter().enumerate().take(n).skip(1) {
489        let row = index + 1;
490        let span = knot_vector.find_span(parameter);
491        let basis = knot_vector.basis_functions(span, parameter);
492        for (offset, value) in basis.into_iter().enumerate() {
493            matrix[row][span - degree + offset] = value;
494        }
495        rhs_points[row] = points[index];
496    }
497    let end_span = t1 - knots[control_count - 1];
498    matrix[control_count - 2][control_count - 2] = -(degree as f64) / end_span;
499    matrix[control_count - 2][control_count - 1] = (degree as f64) / end_span;
500    rhs_points[control_count - 2] = end_tangent;
501    matrix[control_count - 1][control_count - 1] = 1.0;
502    rhs_points[control_count - 1] = points[n];
503    let solve_axis = |axis: fn(Vec3) -> f64| {
504        solve_dense(
505            matrix.clone(),
506            rhs_points.iter().copied().map(axis).collect::<Vec<_>>(),
507        )
508    };
509    let xs = solve_axis(|point| point.x)?;
510    let ys = solve_axis(|point| point.y)?;
511    let zs = solve_axis(|point| point.z)?;
512    NurbsCurve::new(
513        degree,
514        knots,
515        (0..control_count)
516            .map(|index| Vec4::from_point(Vec3::new(xs[index], ys[index], zs[index]), 1.0))
517            .collect(),
518    )
519}
520
521pub fn interpolate_curve_thinned(
522    points: &[Vec3],
523    degree: usize,
524    maximum_points: usize,
525) -> Result<NurbsCurve, String> {
526    if maximum_points < 2 {
527        return Err("interpolate_curve_thinned: maximum point count must be at least 2".into());
528    }
529    if points.len() <= maximum_points {
530        let parameters = chord_parameters(points);
531        return interpolate_curve(points, degree, &parameters);
532    }
533    let step = (points.len() - 1) as f64 / (maximum_points - 1) as f64;
534    let thinned = (0..maximum_points)
535        .map(|index| points[(index as f64 * step).round() as usize])
536        .collect::<Vec<_>>();
537    let parameters = chord_parameters(&thinned);
538    interpolate_curve(&thinned, degree, &parameters)
539}
540
541fn chord_parameters(points: &[Vec3]) -> Vec<f64> {
542    if points.len() < 2 {
543        return vec![0.0; points.len()];
544    }
545    let mut parameters = vec![0.0; points.len()];
546    for index in 1..points.len() {
547        parameters[index] = parameters[index - 1] + points[index].sub(points[index - 1]).length();
548    }
549    let length = parameters[points.len() - 1];
550    if length <= 1e-15 {
551        for (index, parameter) in parameters.iter_mut().enumerate() {
552            *parameter = index as f64 / (points.len() - 1) as f64;
553        }
554    } else {
555        for parameter in &mut parameters {
556            *parameter /= length;
557        }
558    }
559    parameters
560}
561
562/// Douglas–Peucker against `tolerance`, as INDICES into `points`.
563///
564/// The indices are what a FIT needs: they carry the correspondence between an
565/// interpolation station and the input station it came from, and that
566/// correspondence is what lets [`fit_polyline`] measure its own result against
567/// the input points BETWEEN its stations without a global projection per point.
568/// [`simplify_polyline`] is a thin wrapper over this; the arithmetic is
569/// unchanged.
570pub fn simplify_polyline_indices(points: &[Vec3], tolerance: f64) -> Vec<usize> {
571    if points.len() <= 2 {
572        return (0..points.len()).collect();
573    }
574    let mut keep = vec![false; points.len()];
575    keep[0] = true;
576    keep[points.len() - 1] = true;
577    let mut stack = vec![(0usize, points.len() - 1)];
578    while let Some((start, end)) = stack.pop() {
579        if end - start < 2 {
580            continue;
581        }
582        let a = points[start];
583        let direction = points[end].sub(a);
584        let length_squared = direction.length_squared().max(1e-300);
585        let mut worst = None;
586        let mut worst_distance = tolerance;
587        for (index, point) in points.iter().enumerate().take(end).skip(start + 1) {
588            let fraction = point.sub(a).dot(direction) / length_squared;
589            let fraction = fraction.clamp(0.0, 1.0);
590            let distance = point.sub(a.add(direction.scale(fraction))).length();
591            if distance > worst_distance {
592                worst_distance = distance;
593                worst = Some(index);
594            }
595        }
596        if let Some(index) = worst {
597            keep[index] = true;
598            stack.push((start, index));
599            stack.push((index, end));
600        }
601    }
602    keep.into_iter()
603        .enumerate()
604        .filter_map(|(index, keep)| keep.then_some(index))
605        .collect()
606}
607
608pub fn simplify_polyline(points: &[Vec3], tolerance: f64) -> Vec<Vec3> {
609    simplify_polyline_indices(points, tolerance)
610        .into_iter()
611        .map(|index| points[index])
612        .collect()
613}
614
615pub fn interpolate_curve_local(
616    points: &[Vec3],
617    parameters: &[f64],
618    tension: f64,
619) -> Result<NurbsCurve, String> {
620    if points.len() != parameters.len() || points.len() < 2 {
621        return Err(
622            "interpolate_curve_local: points and parameters must have matching length >= 2".into(),
623        );
624    }
625    if points.len() == 2 {
626        return interpolate_curve(points, 1, parameters);
627    }
628    let count = points.len();
629    let mut tangents = vec![Vec3::default(); count];
630    tangents[0] = points[1]
631        .sub(points[0])
632        .scale(1.0 / (parameters[1] - parameters[0]));
633    tangents[count - 1] = points[count - 1]
634        .sub(points[count - 2])
635        .scale(1.0 / (parameters[count - 1] - parameters[count - 2]));
636    for index in 1..count - 1 {
637        let previous_interval = parameters[index] - parameters[index - 1];
638        let next_interval = parameters[index + 1] - parameters[index];
639        let total = previous_interval + next_interval;
640        let previous_secant = points[index]
641            .sub(points[index - 1])
642            .scale(1.0 / previous_interval);
643        let next_secant = points[index + 1]
644            .sub(points[index])
645            .scale(1.0 / next_interval);
646        let mut tangent =
647            points[index - 1]
648                .scale(-next_interval / (previous_interval * total))
649                .add(points[index].scale(
650                    (next_interval - previous_interval) / (previous_interval * next_interval),
651                ))
652                .add(points[index + 1].scale(previous_interval / (next_interval * total)));
653        if previous_secant.dot(next_secant) <= 0.0
654            || tangent.dot(previous_secant) <= 0.0
655            || tangent.dot(next_secant) <= 0.0
656        {
657            tangent = Vec3::default();
658        } else {
659            let maximum = 3.0 * previous_secant.length().min(next_secant.length());
660            if tangent.length() > maximum {
661                tangent = tangent.normalized()?.scale(maximum);
662            }
663        }
664        tangents[index] = tangent;
665    }
666    if tension != 1.0 {
667        for tangent in &mut tangents {
668            *tangent = tangent.scale(tension);
669        }
670    }
671    let mut control_points = vec![Vec4::from_point(points[0], 1.0)];
672    for index in 0..count - 1 {
673        let interval = parameters[index + 1] - parameters[index];
674        control_points.extend([
675            Vec4::from_point(
676                points[index].add(tangents[index].scale(interval / 3.0)),
677                1.0,
678            ),
679            Vec4::from_point(
680                points[index + 1].sub(tangents[index + 1].scale(interval / 3.0)),
681                1.0,
682            ),
683            Vec4::from_point(points[index + 1], 1.0),
684        ]);
685    }
686    let mut knots = vec![parameters[0]; 4];
687    for parameter in &parameters[1..count - 1] {
688        knots.extend([*parameter; 3]);
689    }
690    knots.extend([parameters[count - 1]; 4]);
691    NurbsCurve::new(3, knots, control_points)
692}
693
694/// How far past the caller's own station budget the ladder may climb.
695///
696/// The budget a caller passes is its declaration of what it can afford
697/// DOWNSTREAM, and downstream cost is not linear in a section curve's span
698/// count: measured on the torus×torus singular pair, 4x the stations (80 → 320)
699/// took one imprint from 1.4 s to over 200 s, because every pcurve fit on the
700/// section then needs more samples than `MAX_PCURVE_SAMPLES` allows and takes
701/// the raised pass, 400 times over. So the ladder may DOUBLE the caller's
702/// budget and no more. With the input's own band as the floor
703/// ([`PolylineFitReport::floor`]) that meets 275 of the 282 fits in the case
704/// population; the other 7 are all `offset_shell` sections that stop at 192
705/// stations holding 2.33e-7 to 3.42e-6, and they REPORT it
706/// ([`PolylineFitExit::StationCeiling`]) instead of widening to it. Meeting every
707/// bar is not this bound's job — capping the cost of the ones it cannot at 2x
708/// what the caller chose, rather than at an absolute this module invented, is.
709const LADDER_BUDGET_FACTOR: usize = 2;
710
711/// Absolute station ceiling, whatever the caller's budget.
712///
713/// The ladder doubles the interpolation budget from what the caller asked for
714/// while the MEASUREMENT says the curve misses its tolerance, and stops here —
715/// or at the input's own station count, whichever comes first.
716///
717/// 1024 is a COST cap, not a geometric one. As SHIPPED it is a BACKSTOP that
718/// nothing in the corpus reaches, because [`LADDER_BUDGET_FACTOR`] binds first
719/// for every caller here (80 → 160 for the imprint driver, 96 → 192 for
720/// `offset_shell`). The reading below is the ladder let OFF that bound, kept
721/// because it is what the ceiling exists for. Measured over the 55-row case
722/// population: 282 fits, 21 of which miss at the caller's own budget; 16 of
723/// those reach their tolerance inside the ladder, and 5 do not — three at this
724/// ceiling with 3,070 control points and two stalled at 2,302 — every one of
725/// them an `offset_shell` section on the LOCAL interpolation lane, which
726/// converges at h² and cannot reach 1e-7 on a curved section at any station
727/// count this input offers. The lane is the fix there, not the ceiling; raising
728/// the ceiling buys accuracy in the fourth significant figure and pays for it
729/// in control points on every consumer downstream. A fit that stops here
730/// reports [`PolylineFitExit::StationCeiling`] with the deviation it actually
731/// holds, the way a pcurve fit reports its sample ceiling.
732pub const MAX_FIT_STATIONS: usize = 1024;
733
734/// Newton steps spent per point when the measurement locates the curve's
735/// nearest point to an input station. Three is enough on a seed that is already
736/// inside the right span (the seed comes from the station correspondence, not
737/// from a search), and the best iterate is kept, so a non-convergent step can
738/// only cost time.
739const MEASURE_NEWTON_STEPS: usize = 3;
740
741/// Fractions of every station span the measurement probes BETWEEN the input's
742/// own stations — the same three the pcurve refinement probes (`probe_residual`
743/// in `geometry/pcurve.rs`), and for the same reason: an interpolant that
744/// passes exactly through every station it was built from can still bulge in
745/// between, and that bulge is what becomes topology.
746const MEASURE_SPAN_FRACTIONS: [f64; 3] = [0.25, 0.5, 0.75];
747
748/// A rung must beat the one below it by this factor to be worth the next
749/// doubling. Global cubic interpolation improves 16x per doubling and the local
750/// lane 4x, so anything above 0.9 says the input — not the station count — is
751/// what binds, and the ladder stops with [`PolylineFitExit::Stalled`].
752const STALL_FACTOR: f64 = 0.9;
753
754/// What a polyline fit ACHIEVED, returned beside the curve so a budget exit can
755/// never be mistaken for a met tolerance.
756///
757/// [`fit_polyline`] is handed a tolerance by every caller that fits a marched
758/// section. Until 2026-09-13 that tolerance only ever reached
759/// [`simplify_polyline`] and the conditioning floor — the INPUT side — and the
760/// returned curve was never compared against it, while `maximum_points`
761/// DECIMATED the input before interpolation. A cap on the number of
762/// interpolation points is not a tolerance: on the 5,925-point marched base
763/// circle of an offset apex cone the delivered rim sagged 2.179e-3 against a
764/// requested 1e-7, four orders, silently, and the shell's volume read 4.23e-7
765/// relative where its analytically recognized sibling read 2.40e-11.
766///
767/// This report is a MEASUREMENT taken at a construction, not a band. Nothing
768/// may read `deviation` as the acceptance tolerance of the curve it describes;
769/// its consumers are the [`PolylineFitLedger`] (operation diagnostics) and the
770/// ladder's own stopping decision.
771#[derive(Clone, Copy, Debug, PartialEq)]
772pub struct PolylineFitReport {
773    /// Largest distance from an input point to the returned curve, over every
774    /// point of the input run. THE acceptance measurement: the points the
775    /// decimation dropped are the ones that read it.
776    pub deviation: f64,
777    /// Root mean square of the same sample set.
778    pub rms: f64,
779    /// Largest distance from the curve to the input POLYLINE, sampled between
780    /// the fit's own stations. Reported, never compared against the tolerance:
781    /// see [`measure_polyline_fit`] — its floor is the input's chord sag.
782    pub between_stations: f64,
783    /// The deviation the caller asked for.
784    pub tolerance: f64,
785    /// The deviation the ladder actually chased: `tolerance.max(dropped_band)`.
786    ///
787    /// A fit cannot demonstrate fidelity finer than its own INPUT's band. The
788    /// conditioning that produced the station pool drops points up to
789    /// `dropped_band` from the polyline through the survivors, so a curve that
790    /// reproduces the pool to 1e-8 while the pool stands 3.75e-7 from the input
791    /// is not a 1e-8 curve — it is a 3.75e-7 curve with an expensive interior.
792    /// Refining past the band buys control points and nothing else, and control
793    /// points are not free: the imprint's cost in a section curve's span count
794    /// is superlinear (measured on the torus×torus singular pair, where 4x the
795    /// stations cost two orders of magnitude of `process_curve` time).
796    pub floor: f64,
797    /// Interpolation stations in the returned curve.
798    pub stations: usize,
799    /// Control points in the returned curve — what the fit costs downstream.
800    pub controls: usize,
801    /// Points in the input run.
802    pub input_points: usize,
803    /// Stations the input offers after [`simplify_polyline`] and conditioning:
804    /// the ceiling the ladder can climb to on this input.
805    pub pool_points: usize,
806    /// Largest distance from an input point DROPPED by the simplification to
807    /// the polyline through the stations that survived it — the part of
808    /// `deviation` no interpolation can remove, since those points are not
809    /// stations any more. The analogue of [`crate::PcurveFitReport::off_surface`]:
810    /// a value near `tolerance` says the input's own band explains the
811    /// residual. Zero when the simplification dropped nothing.
812    pub dropped_band: f64,
813    /// Rungs the ladder spent. One means rung zero met the tolerance, or
814    /// nothing was refined.
815    pub rungs: usize,
816    /// Whether the returned curve came from the LOCAL interpolation lane. Not
817    /// always the lane the caller asked for: a ladder that ends unmet is
818    /// re-run in the other lane and the better MEASURED result is returned
819    /// (see [`fit_polyline`]).
820    pub local_lane: bool,
821    /// Whether that is the other lane — the caller's own ladder could not meet
822    /// the tolerance and this one measured better.
823    pub lane_switched: bool,
824    /// Why the ladder stopped.
825    pub exit: PolylineFitExit,
826}
827
828impl PolylineFitReport {
829    /// Whether the returned curve reaches the floor the fit chased — the
830    /// caller's tolerance, or the input's own band where that is coarser (see
831    /// [`PolylineFitReport::floor`]).
832    pub fn met_tolerance(&self) -> bool {
833        self.deviation <= self.floor
834    }
835
836    /// Whether the caller's own tolerance was reached, band or no band.
837    pub fn met_requested_tolerance(&self) -> bool {
838        self.deviation <= self.tolerance
839    }
840
841    /// Whether every station the input offers is an interpolation station — in
842    /// which case `deviation` is the interpolation SOLVE's residual and not a
843    /// statement about the curve between the input's samples.
844    ///
845    /// This matters for reading a met tolerance honestly. A fit has no oracle
846    /// but its input: on the exact-circle bench the pool-exhausted rung reads
847    /// 8.9e-16 in both lanes while the curves are 5.61e-7 (local) and 2.36e-13
848    /// (global) from the true circle. So a saturated `Converged` says "this
849    /// curve reproduces every point the marcher produced", which is the most a
850    /// fit can claim — not "this curve is within the tolerance of the section".
851    pub fn saturated(&self) -> bool {
852        self.stations >= self.pool_points
853    }
854}
855
856/// Why a polyline fit's refinement stopped. Only
857/// [`PolylineFitExit::Converged`] means the tolerance was met; every other
858/// variant is a BUDGET exit whose deviation was measured on the curve actually
859/// returned.
860#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
861pub enum PolylineFitExit {
862    /// A measured rung came in at or under the tolerance.
863    Converged,
864    /// [`MAX_FIT_STATIONS`] bound with the tolerance unmet and the input still
865    /// holding stations the ladder never used.
866    StationCeiling,
867    /// Every station the input offers is an interpolation station and the
868    /// tolerance is still unmet. More points cannot help; a different lane, a
869    /// finer march or a looser bar can.
870    InputExhausted,
871    /// A doubling failed to improve the deviation by [`STALL_FACTOR`], so the
872    /// input rather than the station count is what binds. The better of the two
873    /// curves is returned.
874    Stalled,
875    /// No refinement was attempted: [`fit_polyline_at`] fits exactly one rung,
876    /// and `BREP_POLYLINE_FIT_REFINE=0` makes [`fit_polyline`] do the same.
877    Unrefined,
878}
879
880#[derive(Clone, Debug)]
881pub struct PolylineFit {
882    pub curve: NurbsCurve,
883    pub parameters: Vec<f64>,
884    pub kept: Vec<Vec3>,
885    /// What this fit achieved against the tolerance it was given.
886    pub report: PolylineFitReport,
887}
888
889/// The interpolation stations a fit is ENTITLED to on this input: the points
890/// [`simplify_polyline`] keeps at `tolerance`, conditioned against
891/// near-duplicates (a station a fraction of a step from its neighbour, or from
892/// either end, rings the interpolant). Indices into `points`, ascending.
893///
894/// The decimation to a point BUDGET is deliberately not here
895/// ([`decimate_stations`]): the ladder lifts the budget and never the pool, so
896/// this runs once per fit however many rungs it takes.
897fn station_pool(points: &[Vec3], tolerance: f64) -> Vec<usize> {
898    let simplified = simplify_polyline_indices(points, tolerance);
899    if simplified.len() > 2 {
900        let total: f64 = simplified
901            .windows(2)
902            .map(|pair| points[pair[1]].sub(points[pair[0]]).length())
903            .sum();
904        let floor = (tolerance * 0.01).max(total * 1e-4);
905        let first = points[simplified[0]];
906        let last = points[simplified[simplified.len() - 1]];
907        let mut conditioned = vec![simplified[0]];
908        for index in &simplified[1..simplified.len() - 1] {
909            let point = points[*index];
910            if point.sub(first).length() > floor
911                && point.sub(last).length() > floor
912                && point.sub(points[*conditioned.last().unwrap()]).length() > floor
913            {
914                conditioned.push(*index);
915            }
916        }
917        conditioned.push(simplified[simplified.len() - 1]);
918        conditioned
919    } else {
920        let mut distinct: Vec<usize> = Vec::new();
921        for index in simplified {
922            if distinct.last().is_none_or(|previous: &usize| {
923                points[index].sub(points[*previous]).length() > tolerance * 0.01
924            }) {
925                distinct.push(index);
926            }
927        }
928        distinct
929    }
930}
931
932/// `maximum_points` stations spread evenly over the pool, both ends kept.
933fn decimate_stations(pool: &[usize], maximum_points: usize) -> Vec<usize> {
934    if pool.len() <= maximum_points {
935        return pool.to_vec();
936    }
937    let step = (pool.len() - 1) as f64 / (maximum_points - 1) as f64;
938    (0..maximum_points)
939        .map(|index| pool[(index as f64 * step).round() as usize])
940        .collect()
941}
942
943/// Normalized chord-length parameters for `stations`, or `None` when they span
944/// no length at all.
945fn station_parameters(points: &[Vec3], stations: &[usize]) -> Option<Vec<f64>> {
946    let total: f64 = stations
947        .windows(2)
948        .map(|pair| points[pair[1]].sub(points[pair[0]]).length())
949        .sum();
950    if total <= 0.0 {
951        return None;
952    }
953    let mut parameters = vec![0.0; stations.len()];
954    let mut accumulated = 0.0;
955    for index in 1..stations.len() {
956        accumulated += points[stations[index]]
957            .sub(points[stations[index - 1]])
958            .length();
959        parameters[index] = accumulated / total;
960    }
961    *parameters.last_mut().unwrap() = 1.0;
962    Some(parameters)
963}
964
965fn distance_to_segment(point: Vec3, from: Vec3, to: Vec3) -> f64 {
966    let direction = to.sub(from);
967    let length_squared = direction.length_squared();
968    if length_squared <= 0.0 {
969        return point.sub(from).length();
970    }
971    let fraction = (point.sub(from).dot(direction) / length_squared).clamp(0.0, 1.0);
972    point.sub(from.add(direction.scale(fraction))).length()
973}
974
975/// Distance from `point` to `curve`, minimized inside `[lower, upper]` from a
976/// seed that is already in the right span.
977///
978/// NOT [`crate::project_point_to_curve`]: that one sweeps `4.max(degree + 2)`
979/// samples of EVERY span before its Newton — 1,700 evaluations on a
980/// 286-control fit, per query, where this is a fixed four — and it stops
981/// refining at a 1e-7 residual, which is exactly the bar this measurement has
982/// to resolve.
983fn distance_to_curve_near(
984    curve: &NurbsCurve,
985    point: Vec3,
986    seed: f64,
987    lower: f64,
988    upper: f64,
989) -> Result<f64, String> {
990    let mut parameter = seed.clamp(lower, upper);
991    let mut best = f64::INFINITY;
992    for _ in 0..MEASURE_NEWTON_STEPS {
993        let derivatives = curve.derivatives_small(parameter, 2)?;
994        let residual = derivatives[0].sub(point);
995        best = best.min(residual.length_squared());
996        let slope = derivatives[1].dot(residual);
997        let curvature = derivatives[2].dot(residual) + derivatives[1].length_squared();
998        if curvature.abs() <= f64::MIN_POSITIVE {
999            break;
1000        }
1001        let next = (parameter - slope / curvature).clamp(lower, upper);
1002        if next == parameter {
1003            break;
1004        }
1005        parameter = next;
1006    }
1007    let residual = curve.evaluate(parameter)?.sub(point).length_squared();
1008    Ok(best.min(residual).sqrt())
1009}
1010
1011/// Deviation of `curve` from the input run: `(max, rms, between_stations)`.
1012///
1013/// `max` and `rms` are taken AT the input's own stations — the distance from
1014/// every input point to the curve. That is the acceptance measurement, and it
1015/// is the one the decimation defeats: a curve interpolating 96 of 5,925 marched
1016/// points reproduces those 96 exactly and misses the other 5,829 by however far
1017/// the interpolant sags, 2.179e-3 on the apex cone's base circle.
1018///
1019/// `between_stations` is the same curve sampled at [`MEASURE_SPAN_FRACTIONS`] of
1020/// every station span and measured against the input POLYLINE. It is reported
1021/// and deliberately NOT compared against the tolerance, because its floor is
1022/// the input's own sampling: a polyline through exact samples of a circle of
1023/// radius R taken every h cuts the corner by h²/(8R), so the perfect curve —
1024/// the circle itself — reads 9.6e-7 against a 7.2e-3-step march of a
1025/// radius-6.798 section. Measuring acceptance against chords would make the
1026/// bar unreachable for being right. What it does catch is a curve that wanders
1027/// between the points it was given by much MORE than that sag.
1028///
1029/// `stations` are indices into `points` and `parameters` their curve
1030/// parameters, so every query starts from a seed inside the right span and
1031/// nothing here searches the whole curve.
1032fn measure_polyline_fit(
1033    curve: &NurbsCurve,
1034    points: &[Vec3],
1035    stations: &[usize],
1036    parameters: &[f64],
1037    arc: &[f64],
1038) -> Result<(f64, f64, f64), String> {
1039    let mut worst = 0.0_f64;
1040    let mut sum_squares = 0.0_f64;
1041    let mut samples = 0usize;
1042    let mut between = 0.0_f64;
1043    let last = stations.len() - 1;
1044    for span in 0..last {
1045        let (from, to) = (stations[span], stations[span + 1]);
1046        let (t0, t1) = (parameters[span], parameters[span + 1]);
1047        // One span each side, so a station's nearest point may sit past its own
1048        // span's end without the bracket cutting the minimization short.
1049        let lower = parameters[span.saturating_sub(1)];
1050        let upper = parameters[(span + 2).min(last)];
1051        // AT the input's stations: every input point of this span, the left end
1052        // included (the right end belongs to the next span, and the very last
1053        // one is taken after the loop).
1054        let length = arc[to] - arc[from];
1055        for index in from..to {
1056            let seed = if length > 0.0 {
1057                t0 + (t1 - t0) * (arc[index] - arc[from]) / length
1058            } else {
1059                t0
1060            };
1061            let distance = distance_to_curve_near(curve, points[index], seed, lower, upper)?;
1062            worst = worst.max(distance);
1063            sum_squares += distance * distance;
1064            samples += 1;
1065        }
1066        // BETWEEN them: the curve's own span samples against the input run, in
1067        // the index window this span covers, widened by one segment each side.
1068        let window_start = from.saturating_sub(1);
1069        let window_end = (to + 1).min(points.len() - 1);
1070        for fraction in MEASURE_SPAN_FRACTIONS {
1071            let sample = curve.evaluate(t0 + (t1 - t0) * fraction)?;
1072            let mut nearest = f64::INFINITY;
1073            for index in window_start..window_end {
1074                nearest = nearest.min(distance_to_segment(
1075                    sample,
1076                    points[index],
1077                    points[index + 1],
1078                ));
1079            }
1080            if nearest.is_finite() {
1081                between = between.max(nearest);
1082            }
1083        }
1084    }
1085    let distance = distance_to_curve_near(
1086        curve,
1087        points[stations[last]],
1088        parameters[last],
1089        parameters[last.saturating_sub(1)],
1090        parameters[last],
1091    )?;
1092    worst = worst.max(distance);
1093    sum_squares += distance * distance;
1094    samples += 1;
1095    let rms = (sum_squares / samples as f64).sqrt();
1096    Ok((worst, rms, between))
1097}
1098
1099/// Largest distance from an input point the simplification DROPPED to the
1100/// polyline through the stations that survived — the input-side floor under
1101/// every rung of the ladder. See [`PolylineFitReport::dropped_band`].
1102fn dropped_band(points: &[Vec3], pool: &[usize]) -> f64 {
1103    let mut worst = 0.0_f64;
1104    for span in 0..pool.len() - 1 {
1105        let (from, to) = (pool[span], pool[span + 1]);
1106        for index in from + 1..to {
1107            worst = worst.max(distance_to_segment(
1108                points[index],
1109                points[from],
1110                points[to],
1111            ));
1112        }
1113    }
1114    worst
1115}
1116
1117/// Cumulative chord length of `points`, in the input's own order.
1118fn cumulative_chord(points: &[Vec3]) -> Vec<f64> {
1119    let mut arc = vec![0.0; points.len()];
1120    for index in 1..points.len() {
1121        arc[index] = arc[index - 1] + points[index].sub(points[index - 1]).length();
1122    }
1123    arc
1124}
1125
1126/// ONE rung: interpolate `stations` and measure the result against the input.
1127fn fit_rung(
1128    points: &[Vec3],
1129    pool: &[usize],
1130    arc: &[f64],
1131    maximum_points: usize,
1132    local_interpolation: bool,
1133    degree: usize,
1134) -> Result<(NurbsCurve, Vec<usize>, Vec<f64>, f64, f64, f64), String> {
1135    let stations = decimate_stations(pool, maximum_points);
1136    fit_station_set(points, stations, arc, local_interpolation, degree)
1137}
1138
1139fn fit_station_set(
1140    points: &[Vec3],
1141    stations: Vec<usize>,
1142    arc: &[f64],
1143    local_interpolation: bool,
1144    degree: usize,
1145) -> Result<(NurbsCurve, Vec<usize>, Vec<f64>, f64, f64, f64), String> {
1146    let parameters = station_parameters(points, &stations)
1147        .ok_or_else(|| "fit_polyline: degenerate polyline".to_string())?;
1148    let kept = stations.iter().map(|index| points[*index]).collect::<Vec<_>>();
1149    // A run that closes on itself is fitted PERIODICALLY on the global lane:
1150    // an open interpolant errs most at its ends, and a closed run's ends are
1151    // the march's origin, not a feature of the curve.
1152    let closed = kept.len() > degree + 1
1153        && kept[0].sub(kept[kept.len() - 1]).length() <= 1e-12 * (1.0 + kept[0].length());
1154    let curve = if local_interpolation {
1155        interpolate_curve_local(&kept, &parameters, 1.0)?
1156    } else if closed
1157        && degree > 3
1158        && degree % 2 == 1
1159        && std::env::var("BREP_SECTION_FIT_PERIODIC").as_deref() != Ok("0")
1160    {
1161        interpolate_curve_closed_of_degree(&kept[..kept.len() - 1], &parameters, degree)?
1162    } else {
1163        interpolate_curve(&kept, degree.min(kept.len() - 1), &parameters)?
1164    };
1165    let (deviation, rms, between) =
1166        measure_polyline_fit(&curve, points, &stations, &parameters, arc)?;
1167    Ok((curve, stations, parameters, deviation, rms, between))
1168}
1169
1170/// Fit ONE rung at the given station budget and measure it — no refinement.
1171///
1172/// This is [`fit_polyline`] before the ladder: the bench that pins the fit's
1173/// convergence orders drives it directly, because the ladder by design does
1174/// NOT return a 96-station curve when 96 stations miss the tolerance. Its
1175/// report always exits [`PolylineFitExit::Unrefined`] when the tolerance is
1176/// unmet.
1177pub fn fit_polyline_at(
1178    points: &[Vec3],
1179    tolerance: f64,
1180    maximum_points: usize,
1181    local_interpolation: bool,
1182) -> Result<PolylineFit, String> {
1183    let fit = fit_polyline_inner(points, tolerance, maximum_points, local_interpolation, false, 3)?;
1184    record_polyline_fit(&fit.report);
1185    Ok(fit)
1186}
1187
1188/// Interpolate a curve through a polyline and REFINE it until the curve itself
1189/// is within `tolerance` of that polyline.
1190///
1191/// `tolerance` is the deviation the RESULT must reach; `maximum_points` is
1192/// where the station ladder STARTS, not where it ends. Rung zero is exactly
1193/// the fit this routine returned before the ladder existed — same
1194/// simplification, same conditioning, same decimation, same parameters — so a
1195/// section that already met its tolerance returns the identical curve, to the
1196/// bit. Only a MEASURED miss spends anything: the budget doubles, each rung is
1197/// measured, and the best is kept, up to [`MAX_FIT_STATIONS`] or the input's
1198/// own station count.
1199///
1200/// Where it cannot get there it says so, in [`PolylineFit::report`], rather
1201/// than returning silently — the interpolant becomes a trim boundary and every
1202/// mass property downstream is read off it, so "within a step of the marched
1203/// points" is not good enough. This is the same contract
1204/// [`offset::fold_locus::fit_locus_pcurve`] already keeps for a traced locus
1205/// and [`crate::PcurveFitReport`] for a pcurve.
1206///
1207/// THE LANE IS ALSO MEASURED. `local_interpolation` is the lane the ladder
1208/// STARTS in, not the lane that is returned: where the caller's own ladder ends
1209/// unmet, the other lane's ladder is run on the same input and the better
1210/// MEASURED result is returned. Neither lane wins in general and the two fail
1211/// differently — measured on the exact-circle bench and the kink bench below:
1212///
1213/// * on a SMOOTH section the global cubic converges at h⁴ and the local lane at
1214///   h², so at the 96 stations `offset_shell` asks for the local lane delivers
1215///   286 control points 2.179e-3 from the input and the global one delivers 96
1216///   at 3.700e-6 — 589x better with a third of the controls, and the local lane
1217///   cannot reach 1e-7 there at any station count;
1218/// * at a KINK — a rim through a corner — the local lane's monotonicity limiter
1219///   holds the corner where the global cubic rings across it: 8.377e-2 against
1220///   4.095e-1 at the same 32 stations, 4.9x.
1221///
1222/// So the flag is a caller's PREFERENCE — which ladder is paid for first — and
1223/// never a decision about which curve is delivered. `offset_shell` asks for the
1224/// global lane since 2026-09-13 because its sections are mostly smooth and the
1225/// global rungs are three times cheaper per station; its corner sections reach
1226/// the local lane through this fallback instead. This is the
1227/// same shape as `degenerate_apex_frustum`'s branch selection in
1228/// `geometry/analytic_surface/intersect.rs`: a deterministic choice between two
1229/// constructions on their measured error, never a tolerance, and it can only
1230/// return the more accurate of the two.
1231///
1232/// Escape hatch: `BREP_POLYLINE_FIT_REFINE=0` fits rung zero alone, for
1233/// bisecting a moved number against the pre-ladder tree.
1234///
1235/// [`offset::fold_locus::fit_locus_pcurve`]: crate::offset
1236pub fn fit_polyline(
1237    points: &[Vec3],
1238    tolerance: f64,
1239    maximum_points: usize,
1240    local_interpolation: bool,
1241) -> Result<PolylineFit, String> {
1242    fit_polyline_of_degree(points, tolerance, maximum_points, local_interpolation, 3)
1243}
1244
1245/// [`fit_polyline`] with the GLOBAL lane's interpolation degree chosen by the
1246/// caller (the local lane is cubic Hermite whatever is asked). A marched
1247/// section's stations lie on both carriers to the march's residual, and a
1248/// degree-`p` interpolant through them errs by O(h^(p+1)) between stations: on
1249/// `BadBoolean`'s sphere×bore rim at its own 135 stations the cubic sits
1250/// 2.5e-7 off a carrier with a mean signed bias of −1.2e-8, the quintic 8.5e-9
1251/// and +6e-11 — the SAME stations, knots and control-point count, so nothing
1252/// downstream that samples per knot pays for it (2026-09-27).
1253pub fn fit_polyline_of_degree(
1254    points: &[Vec3],
1255    tolerance: f64,
1256    maximum_points: usize,
1257    local_interpolation: bool,
1258    degree: usize,
1259) -> Result<PolylineFit, String> {
1260    fit_polyline_of_degree_with_recovery(points, tolerance, maximum_points, local_interpolation, degree)
1261        .map(|(fit, _)| fit)
1262}
1263
1264/// Internal provenance for the SSI caller: recovering fidelity to the march
1265/// points does not establish accuracy between them against the carriers.
1266/// Keep this out of the public fit/report API.
1267pub(crate) fn fit_polyline_of_degree_with_recovery(
1268    points: &[Vec3],
1269    tolerance: f64,
1270    maximum_points: usize,
1271    local_interpolation: bool,
1272    degree: usize,
1273) -> Result<(PolylineFit, bool), String> {
1274    let refine = std::env::var("BREP_POLYLINE_FIT_REFINE").as_deref() != Ok("0");
1275    let asked = fit_polyline_inner(points, tolerance, maximum_points, local_interpolation, refine, degree)?;
1276    if asked.report.met_tolerance() || !refine {
1277        record_polyline_fit(&asked.report);
1278        return Ok((asked, false));
1279    }
1280    let mut other =
1281        fit_polyline_inner(points, tolerance, maximum_points, !local_interpolation, refine, degree)?;
1282    let spent = asked.report.rungs + other.report.rungs;
1283    let mut best = if other.report.deviation < asked.report.deviation {
1284        other.report.lane_switched = true;
1285        other
1286    } else {
1287        asked
1288    };
1289    // Uniform decimation can spend its whole budget away from a short,
1290    // highly curved part of the run. Retry a missed fit by adding the worst
1291    // eligible station per span, within the same station ceiling. Only a
1292    // measured converged fit replaces the already available fallback.
1293    let mut spent = spent;
1294    let mut recovered = false;
1295    if !best.report.met_tolerance() {
1296        let (adaptive, rounds) = fit_polyline_adaptive(points, tolerance, maximum_points, degree)?;
1297        spent += rounds;
1298        if let Some(mut adaptive) = adaptive {
1299            adaptive.report.lane_switched = local_interpolation;
1300            best = adaptive;
1301            recovered = true;
1302        }
1303    }
1304    // Include discarded attempts in the work ledger.
1305    best.report.rungs = spent;
1306    record_polyline_fit(&best.report);
1307    Ok((best, recovered))
1308}
1309
1310/// Redistribute the existing station budget after both uniform ladders miss.
1311/// Measurements still include every input point, not just eligible stations.
1312/// This proves fidelity to the sampled run, not accuracy between march points.
1313fn fit_polyline_adaptive(
1314    points: &[Vec3],
1315    tolerance: f64,
1316    maximum_points: usize,
1317    degree: usize,
1318) -> Result<(Option<PolylineFit>, usize), String> {
1319    let pool = station_pool(points, tolerance);
1320    let arc = cumulative_chord(points);
1321    let band = dropped_band(points, &pool);
1322    let floor = tolerance.max(band);
1323    let budget = maximum_points.max(2);
1324    let ceiling = pool.len().min(MAX_FIT_STATIONS)
1325        .min(budget.saturating_mul(LADDER_BUDGET_FACTOR)).max(budget);
1326    let mut stations = decimate_stations(&pool, budget);
1327    // Limit work independently of the station ceiling. A difficult run keeps
1328    // the existing measured fallback when this recovery does not converge.
1329    for round in 0..=8 {
1330        let Ok((curve, current, parameters, deviation, rms, between)) =
1331            fit_station_set(points, stations, &arc, false, degree)
1332        else { return Ok((None, round + 1)); };
1333        stations = current;
1334        if deviation <= floor {
1335            let report = PolylineFitReport {
1336                deviation, rms, between_stations: between, tolerance, floor,
1337                stations: stations.len(), controls: curve.control_points.len(),
1338                input_points: points.len(), pool_points: pool.len(), dropped_band: band,
1339                rungs: round + 1, local_lane: false, lane_switched: false,
1340                exit: PolylineFitExit::Converged,
1341            };
1342            return Ok((Some(PolylineFit {
1343                curve, parameters, kept: stations.iter().map(|&i| points[i]).collect(), report,
1344            }), round + 1));
1345        }
1346        if stations.len() >= ceiling || round == 8 { return Ok((None, round + 1)); }
1347        let mut candidates = Vec::new();
1348        for span in 0..stations.len() - 1 {
1349            let (from, to) = (stations[span], stations[span + 1]);
1350            let (t0, t1) = (parameters[span], parameters[span + 1]);
1351            let lower = parameters[span.saturating_sub(1)];
1352            let upper = parameters[(span + 2).min(stations.len() - 1)];
1353            let mut candidate: Option<(usize, f64)> = None;
1354            for &index in pool.iter().filter(|&&i| i > from && i < to) {
1355                let length = arc[to] - arc[from];
1356                let seed = if length > 0.0 {
1357                    t0 + (t1 - t0) * (arc[index] - arc[from]) / length
1358                } else { t0 };
1359                let error = distance_to_curve_near(&curve, points[index], seed, lower, upper)?;
1360                if candidate.is_none_or(|(_, previous)| error > previous) {
1361                    candidate = Some((index, error));
1362                }
1363            }
1364            if let Some((index, error)) = candidate {
1365                if error > floor { candidates.push((index, error)); }
1366            }
1367        }
1368        candidates.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1369        let added = candidates.len().min(16).min(ceiling - stations.len());
1370        if added == 0 { return Ok((None, round + 1)); }
1371        stations.extend(candidates.iter().take(added).map(|&(index, _)| index));
1372        stations.sort_unstable();
1373    }
1374    unreachable!("the bounded refinement returns on its last round")
1375}
1376
1377fn fit_polyline_inner(
1378    points: &[Vec3],
1379    tolerance: f64,
1380    maximum_points: usize,
1381    local_interpolation: bool,
1382    refine: bool,
1383    degree: usize,
1384) -> Result<PolylineFit, String> {
1385    let pool = station_pool(points, tolerance);
1386    if pool.len() < 2 {
1387        return Err("fit_polyline: degenerate polyline".into());
1388    }
1389    let arc = cumulative_chord(points);
1390    let band = dropped_band(points, &pool);
1391    let floor = tolerance.max(band);
1392    let mut budget = maximum_points.max(2);
1393    let ceiling = pool
1394        .len()
1395        .min(MAX_FIT_STATIONS)
1396        .min(budget.saturating_mul(LADDER_BUDGET_FACTOR))
1397        .max(budget);
1398    let (mut curve, mut stations, mut parameters, mut deviation, mut rms, mut between) =
1399        fit_rung(points, &pool, &arc, budget, local_interpolation, degree)?;
1400    let mut rungs = 1usize;
1401    let mut exit = if deviation <= floor {
1402        PolylineFitExit::Converged
1403    } else {
1404        PolylineFitExit::Unrefined
1405    };
1406    while refine && deviation > floor {
1407        if budget >= ceiling {
1408            exit = if ceiling >= pool.len() {
1409                PolylineFitExit::InputExhausted
1410            } else {
1411                PolylineFitExit::StationCeiling
1412            };
1413            break;
1414        }
1415        budget = (budget * 2).min(ceiling);
1416        let rung = fit_rung(points, &pool, &arc, budget, local_interpolation, degree)?;
1417        rungs += 1;
1418        if rung.3 >= deviation * STALL_FACTOR {
1419            if rung.3 < deviation {
1420                (curve, stations, parameters, deviation, rms, between) = rung;
1421            }
1422            // A rung can stall INSIDE the tolerance — it bought little because
1423            // there was little left to buy. The exit names what the curve is,
1424            // so `met_tolerance()` and `exit` can never disagree.
1425            exit = if deviation <= floor {
1426                PolylineFitExit::Converged
1427            } else {
1428                PolylineFitExit::Stalled
1429            };
1430            break;
1431        }
1432        (curve, stations, parameters, deviation, rms, between) = rung;
1433        if deviation <= floor {
1434            exit = PolylineFitExit::Converged;
1435        }
1436    }
1437    let report = PolylineFitReport {
1438        deviation,
1439        rms,
1440        between_stations: between,
1441        tolerance,
1442        floor,
1443        stations: stations.len(),
1444        controls: curve.control_points.len(),
1445        input_points: points.len(),
1446        pool_points: pool.len(),
1447        dropped_band: band,
1448        rungs,
1449        local_lane: local_interpolation,
1450        lane_switched: false,
1451        exit,
1452    };
1453    Ok(PolylineFit {
1454        curve,
1455        parameters,
1456        kept: stations.iter().map(|index| points[*index]).collect(),
1457        report,
1458    })
1459}
1460
1461/// Per-operation tally of polyline fits and how many missed their tolerance.
1462///
1463/// The same shape, and for the same reason, as [`crate::PcurveFitLedger`]:
1464/// [`fit_polyline`] is called from the imprint driver and from the offset
1465/// reintersection, neither of which has a diagnostics record to write into. An
1466/// operation that returns [`KernelDiagnostics`] opens a [`PolylineFitScope`];
1467/// every fit made on that thread while the scope is open is tallied here, and
1468/// closing it hands the tally back for [`PolylineFitLedger::report_into`].
1469/// Scopes nest — closing a child folds it into its parent — and a scope DROPPED
1470/// without being closed is discarded, so a refused boolean attempt does not
1471/// leave its fits on the caller's tally. With no scope open, recording is a
1472/// no-op.
1473#[derive(Clone, Copy, Debug, Default, PartialEq)]
1474pub struct PolylineFitLedger {
1475    /// Fits recorded, met ones included.
1476    pub fits: u64,
1477    /// Fits whose returned curve did not reach its tolerance.
1478    pub unmet: u64,
1479    /// Unmet fits that stopped at [`MAX_FIT_STATIONS`].
1480    pub station_ceiling: u64,
1481    /// Unmet fits that had already used every station the input offers.
1482    pub input_exhausted: u64,
1483    /// Unmet fits whose last doubling bought nothing.
1484    pub stalled: u64,
1485    /// Unmet fits that never refined (a single-rung fit, or the escape hatch).
1486    pub unrefined: u64,
1487    /// Fits that spent more than one rung, met or not.
1488    pub refined: u64,
1489    /// Fits that met their tolerance only with every station the input offers —
1490    /// a SATURATED reading (see [`PolylineFitReport::saturated`]).
1491    pub met_at_pool: u64,
1492    /// Fits returned from the lane the caller did NOT ask for, because the
1493    /// caller's own ladder ended unmet and the other lane measured better.
1494    pub lane_switched: u64,
1495    /// Extra rungs spent over every fit — what the ladder cost.
1496    pub extra_rungs: u64,
1497    /// Largest deviation among the unmet fits; zero when there are none.
1498    pub worst_unmet_deviation: f64,
1499    /// Largest [`PolylineFitReport::dropped_band`] over every fit.
1500    pub worst_dropped_band: f64,
1501}
1502
1503impl PolylineFitLedger {
1504    fn record(&mut self, report: &PolylineFitReport) {
1505        self.fits += 1;
1506        self.extra_rungs += (report.rungs - 1) as u64;
1507        if report.rungs > 1 {
1508            self.refined += 1;
1509        }
1510        self.worst_dropped_band = self.worst_dropped_band.max(report.dropped_band);
1511        if report.lane_switched {
1512            self.lane_switched += 1;
1513        }
1514        if report.met_tolerance() {
1515            if report.saturated() {
1516                self.met_at_pool += 1;
1517            }
1518            return;
1519        }
1520        self.unmet += 1;
1521        match report.exit {
1522            PolylineFitExit::Converged => {}
1523            PolylineFitExit::StationCeiling => self.station_ceiling += 1,
1524            PolylineFitExit::InputExhausted => self.input_exhausted += 1,
1525            PolylineFitExit::Stalled => self.stalled += 1,
1526            PolylineFitExit::Unrefined => self.unrefined += 1,
1527        }
1528        self.worst_unmet_deviation = self.worst_unmet_deviation.max(report.deviation);
1529    }
1530
1531    fn fold(&mut self, child: &PolylineFitLedger) {
1532        self.fits += child.fits;
1533        self.unmet += child.unmet;
1534        self.station_ceiling += child.station_ceiling;
1535        self.input_exhausted += child.input_exhausted;
1536        self.stalled += child.stalled;
1537        self.unrefined += child.unrefined;
1538        self.refined += child.refined;
1539        self.met_at_pool += child.met_at_pool;
1540        self.lane_switched += child.lane_switched;
1541        self.extra_rungs += child.extra_rungs;
1542        self.worst_unmet_deviation = self.worst_unmet_deviation.max(child.worst_unmet_deviation);
1543        self.worst_dropped_band = self.worst_dropped_band.max(child.worst_dropped_band);
1544    }
1545
1546    /// Write the tally into an operation's diagnostics: the `fit.polyline.*`
1547    /// counters always, plus one `fit.polyline.unmet_tolerance` event at
1548    /// [`DiagnosticSeverity::Degraded`] when any fit missed its tolerance.
1549    /// Degraded, not Error: the curve is still the kernel's best interpolant of
1550    /// the points it was given and the solid stays shippable, but it no longer
1551    /// claims the accuracy the caller asked for.
1552    pub fn report_into(&self, diagnostics: &mut KernelDiagnostics) {
1553        diagnostics.count_n("fit.polyline.fits", self.fits);
1554        diagnostics.count_n("fit.polyline.unmet_tolerance", self.unmet);
1555        diagnostics.count_n("fit.polyline.exit.station_ceiling", self.station_ceiling);
1556        diagnostics.count_n("fit.polyline.exit.input_exhausted", self.input_exhausted);
1557        diagnostics.count_n("fit.polyline.exit.stalled", self.stalled);
1558        diagnostics.count_n("fit.polyline.exit.unrefined", self.unrefined);
1559        diagnostics.count_n("fit.polyline.refined", self.refined);
1560        diagnostics.count_n("fit.polyline.met_at_pool", self.met_at_pool);
1561        diagnostics.count_n("fit.polyline.lane_switched", self.lane_switched);
1562        diagnostics.count_n("fit.polyline.extra_rungs", self.extra_rungs);
1563        diagnostics.measure_max("fit.polyline.worst_unmet_deviation", self.worst_unmet_deviation);
1564        diagnostics.measure_max("fit.polyline.worst_dropped_band", self.worst_dropped_band);
1565        if self.unmet > 0 {
1566            diagnostics.event(
1567                DiagnosticSeverity::Degraded,
1568                KernelStage::Intersect,
1569                "fit.polyline.unmet_tolerance",
1570                format!(
1571                    "{} of {} polyline fits did not reach the tolerance they were given \
1572                     (station ceiling {}, input exhausted {}, stalled {}, unrefined {}); \
1573                     worst deviation {:.3e}; the simplification's own band reaches {:.3e}",
1574                    self.unmet,
1575                    self.fits,
1576                    self.station_ceiling,
1577                    self.input_exhausted,
1578                    self.stalled,
1579                    self.unrefined,
1580                    self.worst_unmet_deviation,
1581                    self.worst_dropped_band,
1582                ),
1583            );
1584        }
1585    }
1586}
1587
1588thread_local! {
1589    /// The open [`PolylineFitScope`]s on this thread, innermost last.
1590    static POLYLINE_FIT_SCOPES: std::cell::RefCell<Vec<PolylineFitLedger>> =
1591        const { std::cell::RefCell::new(Vec::new()) };
1592}
1593
1594/// An open tally of the polyline fits made on this thread — see
1595/// [`PolylineFitLedger`]. Open it where an operation starts, [`close`] it where
1596/// the operation returns its diagnostics; a `?` that unwinds past it drops the
1597/// tally.
1598///
1599/// [`close`]: PolylineFitScope::close
1600pub struct PolylineFitScope {
1601    /// Not `Send`: the scope must close on the thread that opened it.
1602    _thread_bound: std::marker::PhantomData<*const ()>,
1603}
1604
1605impl PolylineFitScope {
1606    pub fn open() -> Self {
1607        POLYLINE_FIT_SCOPES.with(|scopes| scopes.borrow_mut().push(PolylineFitLedger::default()));
1608        Self {
1609            _thread_bound: std::marker::PhantomData,
1610        }
1611    }
1612
1613    /// Take the tally, folding it into the enclosing scope if there is one.
1614    pub fn close(self) -> PolylineFitLedger {
1615        let ledger = POLYLINE_FIT_SCOPES.with(|scopes| {
1616            let mut scopes = scopes.borrow_mut();
1617            let ledger = scopes.pop().unwrap_or_default();
1618            if let Some(parent) = scopes.last_mut() {
1619                parent.fold(&ledger);
1620            }
1621            ledger
1622        });
1623        std::mem::forget(self);
1624        ledger
1625    }
1626}
1627
1628impl Drop for PolylineFitScope {
1629    fn drop(&mut self) {
1630        POLYLINE_FIT_SCOPES.with(|scopes| {
1631            scopes.borrow_mut().pop();
1632        });
1633    }
1634}
1635
1636fn record_polyline_fit(report: &PolylineFitReport) {
1637    // Per-fit trace for the fit bench and the lane census, the way
1638    // `BREP_DEBUG_PCURVE_FIT` traces a pcurve fit.
1639    if std::env::var_os("BREP_DEBUG_POLYLINE_FIT").is_some() {
1640        eprintln!("POLYLINE-FIT {report:?}");
1641    }
1642    POLYLINE_FIT_SCOPES.with(|scopes| {
1643        if let Some(open) = scopes.borrow_mut().last_mut() {
1644            open.record(report);
1645        }
1646    });
1647}
1648
1649
1650
1651/// Cox–de Boor basis over a RAW (possibly unclamped) knot array — the local
1652/// helper the periodic interpolation needs; `KnotVector` validation rightly
1653/// rejects unclamped arrays, so this stays private to the fit module.
1654fn raw_basis(knots: &[f64], degree: usize, span: usize, parameter: f64) -> Vec<f64> {
1655    let mut basis = vec![0.0; degree + 1];
1656    let mut left = vec![0.0; degree + 1];
1657    let mut right = vec![0.0; degree + 1];
1658    basis[0] = 1.0;
1659    for j in 1..=degree {
1660        left[j] = parameter - knots[span + 1 - j];
1661        right[j] = knots[span + j] - parameter;
1662        let mut saved = 0.0;
1663        for r in 0..j {
1664            let denominator = right[r + 1] + left[j - r];
1665            let temp = if denominator.abs() > 0.0 {
1666                basis[r] / denominator
1667            } else {
1668                0.0
1669            };
1670            basis[r] = saved + right[r + 1] * temp;
1671            saved = left[j - r] * temp;
1672        }
1673        basis[j] = saved;
1674    }
1675    basis
1676}
1677
1678/// Boehm single-knot insertion on raw arrays (degree fixed by caller).
1679fn raw_insert_knot(knots: &mut Vec<f64>, controls: &mut Vec<Vec3>, degree: usize, parameter: f64) {
1680    // span: last index with knots[span] <= parameter, clamped to the valid
1681    // control range (the textbook find_span clamp — inserting at the domain
1682    // end otherwise indexes one past the control array).
1683    let span = knots
1684        .iter()
1685        .rposition(|&knot| knot <= parameter + 1e-14)
1686        .unwrap()
1687        .min(controls.len() - 1);
1688    let mut fresh = Vec::with_capacity(controls.len() + 1);
1689    fresh.extend_from_slice(&controls[..=span - degree]);
1690    for i in span - degree + 1..=span {
1691        let denominator = knots[i + degree] - knots[i];
1692        let alpha = if denominator.abs() > 0.0 {
1693            (parameter - knots[i]) / denominator
1694        } else {
1695            0.0
1696        };
1697        fresh.push(
1698            controls[i - 1]
1699                .scale(1.0 - alpha)
1700                .add(controls[i].scale(alpha)),
1701        );
1702    }
1703    fresh.extend_from_slice(&controls[span..]);
1704    *controls = fresh;
1705    knots.insert(span + 1, parameter);
1706}
1707
1708/// EXACT closed (periodic) cubic interpolation. `points` are the S >= 4
1709/// distinct stations (first NOT repeated); `parameters` has S+1 strictly
1710/// increasing values whose last entry closes the period. The cyclic
1711/// collocation system is solved densely (S is small for lofts), and the
1712/// periodic B-spline is re-expressed in CLAMPED form by Boehm-inserting the
1713/// domain ends to full multiplicity — the representation every kernel
1714/// consumer expects — so the seam is C² by construction, not by welding.
1715pub fn interpolate_curve_closed(points: &[Vec3], parameters: &[f64]) -> Result<NurbsCurve, String> {
1716    interpolate_curve_closed_of_degree(points, parameters, 3)
1717}
1718
1719/// [`interpolate_curve_closed`] at an odd `degree` (3 or 5): the cyclic knot
1720/// line carries `degree` knots either side of the period, and interpolating at
1721/// the knots is well posed for an odd degree. A closed marched section fitted
1722/// OPEN errs most at its ends — the march's origin, where the loop was cut —
1723/// and that end error is what the periodic fit removes (2026-09-27).
1724pub fn interpolate_curve_closed_of_degree(
1725    points: &[Vec3],
1726    parameters: &[f64],
1727    degree: usize,
1728) -> Result<NurbsCurve, String> {
1729    if degree % 2 == 0 || degree < 3 {
1730        return Err("interpolate_curve_closed: the degree must be odd and at least 3".into());
1731    }
1732    let station_count = points.len();
1733    if station_count < degree + 1 {
1734        return Err("interpolate_curve_closed: need more stations than the degree".into());
1735    }
1736    if parameters.len() != station_count + 1 {
1737        return Err(
1738            "interpolate_curve_closed: parameters must have one more entry than points".into(),
1739        );
1740    }
1741    if parameters.windows(2).any(|pair| pair[1] <= pair[0]) {
1742        return Err("interpolate_curve_closed: parameters must increase".into());
1743    }
1744    let period = parameters[station_count] - parameters[0];
1745    // Cyclic knot line u_j = t_{j mod S} + floor(j/S)·T for j in −p..S+p,
1746    // stored with offset p (the degree): raw[k] = u_{k−p}.
1747    let cyclic = |j: i64| -> f64 {
1748        let s = station_count as i64;
1749        let wrap = j.div_euclid(s);
1750        parameters[j.rem_euclid(s) as usize] + wrap as f64 * period
1751    };
1752    let raw_knots: Vec<f64> = (-(degree as i64)..=(station_count as i64 + degree as i64)).map(cyclic).collect();
1753    // Collocation: row i evaluates the cubic basis at t_i; the span in the
1754    // raw array is the one containing t_i (raw index i+p == u_i).
1755    let mut matrix = vec![vec![0.0; station_count]; station_count];
1756    for i in 0..station_count {
1757        let span = i + degree;
1758        let basis = raw_basis(&raw_knots, degree, span, parameters[i]);
1759        for (offset, value) in basis.iter().enumerate() {
1760            // Control j = span − degree + offset in unclamped indexing, i.e.
1761            // cyclic control (i + offset − p) mod S.
1762            let index = (i as i64 + offset as i64 - degree as i64).rem_euclid(station_count as i64) as usize;
1763            matrix[i][index] += value;
1764        }
1765    }
1766    let solve_axis = |axis: fn(Vec3) -> f64| {
1767        solve_dense(
1768            matrix.clone(),
1769            points.iter().copied().map(axis).collect::<Vec<_>>(),
1770        )
1771    };
1772    let xs = solve_axis(|point| point.x)?;
1773    let ys = solve_axis(|point| point.y)?;
1774    let zs = solve_axis(|point| point.z)?;
1775    let cyclic_controls: Vec<Vec3> = (0..station_count)
1776        .map(|index| Vec3::new(xs[index], ys[index], zs[index]))
1777        .collect();
1778    // Window covering [t_0, t_S]: controls D_{−p..S−1} cyclically.
1779    let mut window_controls: Vec<Vec3> = (-(degree as i64)..(station_count as i64))
1780        .map(|j| cyclic_controls[j.rem_euclid(station_count as i64) as usize])
1781        .collect();
1782    let mut window_knots = raw_knots.clone();
1783    // Clamp both domain ends to full multiplicity (degree insertions each —
1784    // the ends currently sit at multiplicity 1).
1785    for _ in 0..degree {
1786        raw_insert_knot(
1787            &mut window_knots,
1788            &mut window_controls,
1789            degree,
1790            parameters[0],
1791        );
1792    }
1793    for _ in 0..degree {
1794        raw_insert_knot(
1795            &mut window_knots,
1796            &mut window_controls,
1797            degree,
1798            parameters[station_count],
1799        );
1800    }
1801    // Slice out the clamped sub-curve over [t_0, t_S]: knots from the first
1802    // occurrence of t_0 through the last of t_S, controls aligned so that
1803    // control k pairs with knot span k..k+degree+1.
1804    let first = window_knots
1805        .iter()
1806        .position(|&knot| (knot - parameters[0]).abs() < 1e-12)
1807        .ok_or("interpolate_curve_closed: clamp lost the start knot")?;
1808    let last = window_knots
1809        .iter()
1810        .rposition(|&knot| (knot - parameters[station_count]).abs() < 1e-12)
1811        .ok_or("interpolate_curve_closed: clamp lost the end knot")?;
1812    let clamped_knots: Vec<f64> = window_knots[first..=last].to_vec();
1813    let control_count = clamped_knots.len() - degree - 1;
1814    let clamped_controls: Vec<Vec4> = window_controls[first..first + control_count]
1815        .iter()
1816        .map(|point| Vec4::from_point(*point, 1.0))
1817        .collect();
1818    NurbsCurve::new(degree, clamped_knots, clamped_controls)
1819}
1820