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