Skip to main content

brep_kernel/geometry/
pcurve.rs

1use crate::{
2    interpolate_curve, project_point_to_surface, project_point_to_surface_seeded, AnalyticSurface,
3    DiagnosticSeverity, KernelDiagnostics, KernelStage, KnotVector, NurbsCurve, NurbsSurface, Vec3,
4    Vec4, KNOT_IDENTITY_TOL,
5};
6
7const EPSILON: f64 = 1e-12;
8const LINEAR_TOLERANCE: f64 = 1e-7;
9
10fn surface_domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
11    Ok((
12        KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
13        KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
14    ))
15}
16
17fn surface_closedness(surface: &NurbsSurface) -> Result<(bool, bool), String> {
18    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
19    let mut closed_u = true;
20    let mut closed_v = true;
21    for fraction in [0.19, 0.52, 0.87] {
22        let v = v0 + (v1 - v0) * fraction;
23        if surface
24            .evaluate(u0, v)?
25            .sub(surface.evaluate(u1, v)?)
26            .length()
27            > LINEAR_TOLERANCE * 10.0
28        {
29            closed_u = false;
30        }
31        let u = u0 + (u1 - u0) * fraction;
32        if surface
33            .evaluate(u, v0)?
34            .sub(surface.evaluate(u, v1)?)
35            .length()
36            > LINEAR_TOLERANCE * 10.0
37        {
38            closed_v = false;
39        }
40    }
41    Ok((closed_u, closed_v))
42}
43
44fn invert_checked(surface: &NurbsSurface, point: Vec3) -> Result<([f64; 2], f64), String> {
45    if surface.is_affine()? {
46        let ([u0, _], [v0, _]) = surface_domains(surface)?;
47        let (origin, du, dv) = surface.deriv1(u0, v0)?;
48        let delta = point.sub(origin);
49        let uu = du.dot(du);
50        let uv = du.dot(dv);
51        let vv = dv.dot(dv);
52        let along_u = delta.dot(du);
53        let along_v = delta.dot(dv);
54        let determinant = uu * vv - uv * uv;
55        if determinant.abs() > EPSILON {
56            return Ok((
57                [
58                    u0 + (along_u * vv - along_v * uv) / determinant,
59                    v0 + (along_v * uu - along_u * uv) / determinant,
60                ],
61                0.0,
62            ));
63        }
64    }
65    let projection = project_point_to_surface(surface, point)?;
66    Ok(([projection.u, projection.v], projection.distance))
67}
68
69fn unwrap_periodic(values: &mut [f64], minimum: f64, maximum: f64) {
70    let period = maximum - minimum;
71    for index in 1..values.len() {
72        while values[index] - values[index - 1] > period / 2.0 {
73            values[index] -= period;
74        }
75        while values[index] - values[index - 1] < -period / 2.0 {
76            values[index] += period;
77        }
78    }
79    if values.len() > 1 {
80        while values[0] - values[1] > period / 2.0 {
81            values[0] -= period;
82        }
83        while values[0] - values[1] < -period / 2.0 {
84            values[0] += period;
85        }
86    }
87    // Recenter the whole (now continuous) sequence into the domain by the
88    // whole-period shift that leaves the LEAST parameter outside [minimum,
89    // maximum]. A single middle/mean sample is NOT representative of a curve
90    // that bulges out to touch — or straddle — the periodic seam: when that
91    // sample is a boundary-kissing point (the at-seam projection unwraps a hair
92    // past the boundary), a single-sample test misfires and shoves the entire
93    // curve a full period out of range (ABC helmet 00000011/12 side channels:
94    // a touching edge's parameter run snapped a whole period off its true
95    // interior, tearing the loop open in parameter space).
96    if values.len() > 1 {
97        let excursion = |shift: f64| -> f64 {
98            values
99                .iter()
100                .map(|value| {
101                    let v = value + shift;
102                    (minimum - v).max(0.0) + (v - maximum).max(0.0)
103                })
104                .sum::<f64>()
105        };
106        let mean = values.iter().copied().sum::<f64>() / values.len() as f64;
107        let center = 0.5 * (minimum + maximum);
108        let base_k = ((center - mean) / period).round() as i64;
109        let mut best_shift = 0.0;
110        let mut best_excursion = f64::INFINITY;
111        for k in (base_k - 1)..=(base_k + 1) {
112            let shift = k as f64 * period;
113            let value = excursion(shift);
114            if value < best_excursion {
115                best_excursion = value;
116                best_shift = shift;
117            }
118        }
119        if best_shift != 0.0 {
120            for value in values.iter_mut() {
121                *value += best_shift;
122            }
123        }
124    }
125}
126
127fn build_interpolant(
128    surface: &NurbsSurface,
129    raw_parameters: &[[f64; 2]],
130    curve_parameters: &[f64],
131) -> Result<NurbsCurve, String> {
132    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
133    let (closed_u, closed_v) = surface_closedness(surface)?;
134    let mut parameters = raw_parameters.to_vec();
135    // Longitude is undefined at a sphere pole: the surface's u tangent
136    // vanishes there, and closest-point inversion is free to return any u.
137    // Letting that arbitrary value participate in periodic unwrapping can move
138    // the first real meridian by a whole period (e.g. 0.75 -> -0.25). Analytic
139    // carriers are subsequently clamped, collapsing that meridian onto u=0.
140    //
141    // Keep this deliberately sphere- and singularity-gated. Split the u values
142    // into maximal non-pole runs, unwrap each run independently, and leave the
143    // pole samples untouched. The existing interpolant degree stays unchanged;
144    // lowering it here would also change endpoint-tangent decisions made later
145    // while stitching the face loop.
146    let sphere_u_singular = if matches!(surface.analytic(), Some(AnalyticSurface::Sphere { .. })) {
147        let speeds = parameters
148            .iter()
149            .map(|parameter| {
150                surface
151                    .deriv1(parameter[0], parameter[1])
152                    .map(|(_, su, _)| su.length())
153            })
154            .collect::<Result<Vec<_>, _>>()?;
155        let reference = speeds.iter().copied().fold(0.0_f64, f64::max);
156        speeds
157            .into_iter()
158            // Uniform edge stations need not hit the pole exactly (ABC 5360
159            // bottoms out at v=5.5e-4). In this small conditioning band,
160            // longitude is already numerically arbitrary and must not connect
161            // the two meridian runs across the pole.
162            .map(|speed| speed <= reference * 1e-2)
163            .collect::<Vec<_>>()
164    } else {
165        vec![false; parameters.len()]
166    };
167    // Do not perturb ordinary pole-touching trims. The special path is needed
168    // only when a pole band is bracketed by nonsingular samples and its raw
169    // longitude changes by at least half a period: a true through-pole branch
170    // reset whose two meridian runs must be unwrapped independently.
171    // Prefix/suffix pole samples on normal sphere caps retain the legacy
172    // interpolation byte-for-byte. Include the exactly-half-period case so
173    // floating-point noise cannot decide which entire run is lifted (ABC 5603).
174    let has_interior_pole_crossing = sphere_u_singular
175        .iter()
176        .position(|singular| !singular)
177        .zip(sphere_u_singular.iter().rposition(|singular| !singular))
178        .is_some_and(|(first, last)| sphere_u_singular[first..=last].iter().any(|value| *value));
179    let period = (u1 - u0).abs();
180    let interior_pole_branch_reset = closed_u
181        && has_interior_pole_crossing
182        && parameters.windows(2).zip(sphere_u_singular.windows(2)).any(
183            |(parameter_pair, singular_pair)| {
184                (singular_pair[0] || singular_pair[1])
185                    && (parameter_pair[1][0] - parameter_pair[0][0]).abs()
186                        >= 0.5 * period - 1e-12 * period.max(1.0)
187            },
188        );
189    if closed_u {
190        let mut values = parameters.iter().map(|value| value[0]).collect::<Vec<_>>();
191        if interior_pole_branch_reset {
192            let mut start = 0;
193            while start < values.len() {
194                while start < values.len() && sphere_u_singular[start] {
195                    start += 1;
196                }
197                let mut end = start;
198                while end < values.len() && !sphere_u_singular[end] {
199                    end += 1;
200                }
201                unwrap_periodic(&mut values[start..end], u0, u1);
202                start = end;
203            }
204        } else {
205            unwrap_periodic(&mut values, u0, u1);
206        }
207        for (parameter, value) in parameters.iter_mut().zip(values) {
208            parameter[0] = value;
209        }
210    }
211    if closed_v {
212        let mut values = parameters.iter().map(|value| value[1]).collect::<Vec<_>>();
213        unwrap_periodic(&mut values, v0, v1);
214        for (parameter, value) in parameters.iter_mut().zip(values) {
215            parameter[1] = value;
216        }
217    }
218    // A closed direction WRAPS: an edge that straddles the seam has no single
219    // in-domain parameter run, so keep the unwrapped (possibly slightly
220    // out-of-domain) values and let the periodic evaluator wrap them — clamping
221    // them onto the seam boundary would collapse the straddling geometry (and,
222    // via the interpolant, spike neighbouring stations). Restrict this to
223    // GENERAL (B-spline) carriers: the analytic cylinders/cones/tori keep their
224    // exact, in-domain seam handling (split rims, biperiodic bands), which the
225    // straddle path is not meant to replace. Open directions must stay in-domain
226    // regardless (their extension is a tangent-plane ruling, not a wrap).
227    let wrap = surface.analytic().is_none();
228    let points = parameters
229        .into_iter()
230        .map(|parameter| {
231            let u = if closed_u && wrap {
232                parameter[0]
233            } else {
234                parameter[0].clamp(u0, u1)
235            };
236            let v = if closed_v && wrap {
237                parameter[1]
238            } else {
239                parameter[1].clamp(v0, v1)
240            };
241            Vec3::new(u, v, 0.0)
242        })
243        .collect::<Vec<_>>();
244    interpolate_curve(&points, 3usize.min(points.len() - 1), curve_parameters)
245}
246
247/// The 3D residual a pcurve's image must reach before `build_pcurve_on_surface`
248/// accepts it, and the sample ceiling it refines up to.
249///
250/// A trimmed face's boundary is only ever as accurate as these two numbers, and
251/// every mass property is an integral over that boundary — so this is the floor
252/// under area, volume and centroid for every curved trim in the kernel.
253///
254/// These were `min(1e-3, 1e-4 * scale)` and 160, where `scale` is
255/// `1 + ||curve midpoint||` — the DISTANCE FROM THE WORLD ORIGIN of the curve's
256/// midpoint, not the part's size. That expression pinned to an absolute 1e-3
257/// once the midpoint sat more than about nine units from the origin, so on an
258/// ordinary part the bar was 1e-3 and tracked nothing.
259///
260/// Measured on `offset-shell-box-bore-dished-face`, whose dished wall is a
261/// closed-form identity at 2468.431782123 (three independent routes agree). The
262/// floor is one of TWO errors there; the other is the refitted sphere carrier
263/// that `sphere_offset_surface` now builds in closed form, and before both moved
264/// they partly CANCELLED — which is why either alone reads as a regression:
265///
266/// ```text
267/// floor     Box_O.S2            abs err     relative   gate total  worst case
268/// 1e-3   2468.442270355      +1.049e-02     4.25e-06        (old)
269/// 1e-6   2468.431785429      +3.306e-06     1.34e-09       181.4 s     31.2 s
270/// 1e-7   2468.431782170      +4.746e-08     1.92e-11       218.5 s     32.1 s
271/// 1e-9   2468.431782103      -2.037e-08    -8.25e-12       305.5 s     46.8 s
272/// ```
273///
274/// 1e-7 is chosen: 70x the accuracy of 1e-6 for +20% gate time, where 1e-9 costs
275/// a further +40% for a 2.3x residual no oracle in this corpus could resolve.
276/// All four cells are one session on one binary, so the times are comparable to
277/// each other. Every case carrying a closed-form oracle moves TOWARD it and none
278/// away (`PushFaceTest` 339x, `PushFaceTest2` 275x); no case changes class at
279/// any floor.
280///
281/// A flat ABSOLUTE is what was measured, not what was designed. This codebase
282/// otherwise keys identity bands to part size (`KernelTolerances::heal_band`,
283/// the `1e-11*(1+scale)` blend family), and on a 3000 mm part 1e-7 is 3e-11
284/// relative — refinement will exhaust `MAX_PCURVE_SAMPLES` and return whatever
285/// it reached. Until 2026-09-12 it did so SILENTLY; now the fit's
286/// [`PcurveFitReport`] carries the residual actually achieved and a
287/// [`PcurveFitExit::SampleCeiling`] exit, and the owning operation's
288/// diagnostics count it (`pcurve.unmet_floor`). That is far better than the
289/// 1e-3 it replaces but it is not a size-aware bar; deriving one from the
290/// surface's own extent is separate, unmeasured work. Being absolute does remove this gate's dependence on
291/// distance from the origin as a side effect — a real translation-VARIANCE
292/// defect, the same anti-pattern `tolerance::merge_scale` records being removed
293/// elsewhere. `drop_tolerance` and `endpoint_tolerance` below still carry it.
294pub(crate) const PCURVE_REFINEMENT_TOLERANCE: f64 = 1e-7;
295
296/// Sample ceiling for a fit's FIRST refinement pass. 160 could not reach
297/// [`PCURVE_REFINEMENT_TOLERANCE`] on a curved trim of any length; 600 is the
298/// value every cell of the table above was measured at.
299///
300/// It BINDS. On `boolean_fuzz_corpus/25_cube_pierce_solid` the fitter stopped
301/// here 1.65e-3 of volume short of the converged answer (2000 and 4000 samples
302/// agree exactly). Raising it globally to 2000 was costed and rejected: +23%
303/// case-gate time, concentrated in three offset/fillet cases, and one case
304/// (`inbox-20260910-offset-shell-collapsed-fillet`) driven 26x outside its
305/// volume band. So it is not raised here: a fit that stops at this ceiling
306/// with a residual still worse than its edge curve's own distance from the
307/// surface re-enters refinement with [`PCURVE_RAISED_SAMPLES`]
308/// ([`refine_pcurve`]); one whose residual is already that gap stops here and
309/// says so ([`PcurveFitReport::off_surface`]), because no sample count can
310/// close it.
311const MAX_PCURVE_SAMPLES: usize = 600;
312
313/// Sample ceiling for the ONE raised pass a fit may take past
314/// [`MAX_PCURVE_SAMPLES`]. 2000 is the ceiling the convergence sweep measured
315/// on `25_cube_pierce_solid`: its 2000 and 4000 cells agree to every printed
316/// digit because refinement there stops itself near 1540 samples, so the cap
317/// is not what binds. Per invariant I3 of `per-entity-tolerances.md` the
318/// budget grows once, to a fixed cap, on a deterministic condition; a fit
319/// that stops here too reports [`PcurveFitExit::SampleCeiling`] with the
320/// samples it actually holds.
321const PCURVE_RAISED_SAMPLES: usize = 2000;
322
323/// What a pcurve fit ACHIEVED, returned beside the curve so a ceiling exit can
324/// never be mistaken for a met floor.
325///
326/// [`fit_pcurve_on_surface`] asks for [`PCURVE_REFINEMENT_TOLERANCE`] and stops
327/// at [`MAX_PCURVE_SAMPLES`] — or, where that ceiling bound and the floor is
328/// reachable, at [`PCURVE_RAISED_SAMPLES`]. Until 2026-09-12 a trim that ran
329/// out of samples came back exactly like one that met the floor; on
330/// `boolean_fuzz_corpus/25_cube_pierce_solid` that silence hid a 1.65e-3 volume
331/// error. Now every fit says which it was: `residual` is measured on the curve
332/// that is actually returned and `exit` names why refinement stopped.
333///
334/// This is a MEASUREMENT taken at a construction, not a band. Per invariant I1
335/// of `per-entity-tolerances.md` nothing may read it as the acceptance
336/// tolerance of the very curve it describes; its consumers are the
337/// [`PcurveFitLedger`] (operation diagnostics) and the per-trim budget
338/// decision inside [`refine_pcurve`], which reads `off_surface` — a property
339/// of the INPUT, not of the returned curve — to decide whether a raise can
340/// help.
341#[derive(Clone, Copy, Debug, PartialEq)]
342pub struct PcurveFitReport {
343    /// Largest 3D distance between the returned pcurve's image on the surface
344    /// and the edge curve. On a [`PcurveFitExit::Converged`] exit whose final
345    /// round probed every span this is that round's maximum — the refinement's
346    /// own acceptance test. On every other exit, including a converged round
347    /// that skipped spans narrower than 1e-3 of the parameter range, it is a
348    /// sweep over EVERY span of the returned curve.
349    pub residual: f64,
350    /// The residual the refinement was asked to reach.
351    pub floor: f64,
352    /// Interpolation samples in the returned curve.
353    pub samples: usize,
354    /// Why refinement stopped.
355    pub exit: PcurveFitExit,
356    /// Largest distance from the edge curve to the surface at every station
357    /// the fit inverted — the part of `residual` no pcurve can remove, since a
358    /// pcurve's image lies ON the surface. Zero for an affine carrier. A value
359    /// above `floor` says the floor is unreachable for this input; a ceiling
360    /// exit is retried only while `residual` exceeds it by more than the floor,
361    /// i.e. while the returned curve is worse than its input.
362    pub off_surface: f64,
363    /// Whether the fit re-entered refinement with [`PCURVE_RAISED_SAMPLES`]
364    /// after [`MAX_PCURVE_SAMPLES`] bound with the floor unmet.
365    pub raised: bool,
366}
367
368impl PcurveFitReport {
369    /// Whether the returned curve reaches the floor it was asked for.
370    pub fn met_floor(&self) -> bool {
371        self.residual <= self.floor
372    }
373}
374
375/// Why a pcurve refinement stopped. Only [`PcurveFitExit::Converged`] means the
376/// floor was met; every other variant is a BUDGET exit, and the report's
377/// residual was measured on the returned curve rather than assumed.
378#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
379pub enum PcurveFitExit {
380    /// A probe round found every station within the floor. An affine carrier
381    /// maps exactly and reports this with a zero residual.
382    Converged,
383    /// The sample ceiling bound before the floor was met: [`MAX_PCURVE_SAMPLES`]
384    /// when the raise was declined (the residual is already explained by
385    /// [`PcurveFitReport::off_surface`]), [`PCURVE_RAISED_SAMPLES`] when it was
386    /// taken and bound too.
387    SampleCeiling,
388    /// Every refinement round was spent and the last one still inserted
389    /// samples, so the curve it built was never re-checked by the loop.
390    RoundBudget,
391    /// A round found stations over the floor but could improve none of them:
392    /// the edge point could not be inverted inside the drop band, or (in the
393    /// raised pass) the pcurve already tracks the surface's nearest point to
394    /// within the floor. Either way the edge curve itself sits off the surface
395    /// there and no pcurve can close that gap; the residual reports how far.
396    Stalled,
397    /// The last probe round found every span it PROBED within the floor, but
398    /// skipped spans narrower than 1e-3 of the parameter range — the loop's
399    /// own resolution floor — and a sweep over those found the floor unmet.
400    /// Until 2026-09-12 this exit was reported as `Converged` with the probed
401    /// spans' maximum: on `25_cube_pierce_solid` a 1539-sample fit with 1537
402    /// narrow spans read 7e-8 that way while every span read 1.9e-4.
403    SpanFloor,
404}
405
406/// A fitted pcurve with what the fit achieved.
407#[derive(Clone, Debug)]
408pub struct PcurveFit {
409    pub curve: NurbsCurve,
410    pub report: PcurveFitReport,
411}
412
413/// Per-operation tally of pcurve fits and how many missed their floor.
414///
415/// [`build_pcurve_on_surface`] and its marched twin are called from some forty
416/// sites, most of which have no diagnostics record to write into. Rather than
417/// thread one through every layer, an operation that returns
418/// [`KernelDiagnostics`] opens a [`PcurveFitScope`]; every fit made on that
419/// thread while the scope is open is tallied here, and closing the scope hands
420/// the tally back for [`PcurveFitLedger::report_into`]. This is the shape the
421/// boolean's `CONFORMANCE_REPAIRS` counter already uses. With no scope open,
422/// recording is a no-op.
423///
424/// Scopes nest: CLOSING a child folds it into its parent, so an offset shell
425/// sees the fits of the booleans it ran. A scope that is DROPPED without being
426/// closed is discarded, not folded — a boolean attempt that refused (and may be
427/// retried under perturbation) must not leave its fits on the caller's tally.
428///
429/// Thread-local by construction: a fit made on a rayon worker (the `parallel`
430/// feature's STEP body import) is not seen by a scope on the calling thread. No
431/// diagnostics-returning operation builds pcurves off-thread today.
432#[derive(Clone, Copy, Debug, Default, PartialEq)]
433pub struct PcurveFitLedger {
434    /// Fits recorded, exact affine ones included.
435    pub fits: u64,
436    /// Fits whose returned curve did not reach its floor.
437    pub unmet: u64,
438    /// Unmet fits that stopped at a sample ceiling.
439    pub sample_ceiling: u64,
440    /// Unmet fits that spent every refinement round.
441    pub round_budget: u64,
442    /// Unmet fits that could insert nothing.
443    pub stalled: u64,
444    /// Unmet fits whose remaining over-floor spans were all below the loop's
445    /// 1e-3 span floor.
446    pub span_floor: u64,
447    /// Largest residual among the unmet fits; zero when there are none.
448    pub worst_unmet_residual: f64,
449    /// Fits that re-entered refinement with [`PCURVE_RAISED_SAMPLES`], met or
450    /// not.
451    pub raised: u64,
452    /// Unmet ceiling exits that were NOT raised because the residual is
453    /// already within the floor of the edge curve's own gap from the surface.
454    pub raise_declined: u64,
455    /// Largest [`PcurveFitReport::off_surface`] over every fit.
456    pub worst_off_surface: f64,
457}
458
459impl PcurveFitLedger {
460    fn record(&mut self, report: &PcurveFitReport) {
461        self.fits += 1;
462        if report.raised {
463            self.raised += 1;
464        }
465        self.worst_off_surface = self.worst_off_surface.max(report.off_surface);
466        if report.met_floor() {
467            return;
468        }
469        self.unmet += 1;
470        match report.exit {
471            PcurveFitExit::Converged => {}
472            PcurveFitExit::SampleCeiling => {
473                self.sample_ceiling += 1;
474                if !report.raised {
475                    self.raise_declined += 1;
476                }
477            }
478            PcurveFitExit::RoundBudget => self.round_budget += 1,
479            PcurveFitExit::Stalled => self.stalled += 1,
480            PcurveFitExit::SpanFloor => self.span_floor += 1,
481        }
482        self.worst_unmet_residual = self.worst_unmet_residual.max(report.residual);
483    }
484
485    fn fold(&mut self, child: &PcurveFitLedger) {
486        self.fits += child.fits;
487        self.unmet += child.unmet;
488        self.sample_ceiling += child.sample_ceiling;
489        self.round_budget += child.round_budget;
490        self.stalled += child.stalled;
491        self.span_floor += child.span_floor;
492        self.worst_unmet_residual = self.worst_unmet_residual.max(child.worst_unmet_residual);
493        self.raised += child.raised;
494        self.raise_declined += child.raise_declined;
495        self.worst_off_surface = self.worst_off_surface.max(child.worst_off_surface);
496    }
497
498    /// Write the tally into an operation's diagnostics: the `pcurve.*`
499    /// counters and measurements always, plus one `pcurve.unmet_floor` event at
500    /// [`DiagnosticSeverity::Degraded`] when any fit missed its floor. Degraded,
501    /// not Error: the result is still the kernel's best construction and stays
502    /// shippable, but it no longer claims the accuracy its floor states.
503    pub fn report_into(&self, diagnostics: &mut KernelDiagnostics) {
504        diagnostics.count_n("pcurve.fits", self.fits);
505        diagnostics.count_n("pcurve.unmet_floor", self.unmet);
506        diagnostics.count_n("pcurve.exit.sample_ceiling", self.sample_ceiling);
507        diagnostics.count_n("pcurve.exit.round_budget", self.round_budget);
508        diagnostics.count_n("pcurve.exit.stalled", self.stalled);
509        diagnostics.count_n("pcurve.exit.span_floor", self.span_floor);
510        diagnostics.count_n("pcurve.budget_raised", self.raised);
511        diagnostics.count_n("pcurve.budget_raise_declined", self.raise_declined);
512        diagnostics.measure_max("pcurve.worst_unmet_residual", self.worst_unmet_residual);
513        diagnostics.measure_max("pcurve.worst_off_surface", self.worst_off_surface);
514        if self.unmet > 0 {
515            diagnostics.event(
516                DiagnosticSeverity::Degraded,
517                KernelStage::Refine,
518                "pcurve.unmet_floor",
519                format!(
520                    "{} of {} pcurve fits did not reach the {:.0e} floor \
521                     (sample ceiling {}, round budget {}, stalled {}, span floor {}); \
522                     worst residual {:.3e}; budget raised on {}, declined on {}; \
523                     edge curves sit off their surfaces by up to {:.3e}",
524                    self.unmet,
525                    self.fits,
526                    PCURVE_REFINEMENT_TOLERANCE,
527                    self.sample_ceiling,
528                    self.round_budget,
529                    self.stalled,
530                    self.span_floor,
531                    self.worst_unmet_residual,
532                    self.raised,
533                    self.raise_declined,
534                    self.worst_off_surface,
535                ),
536            );
537        }
538    }
539}
540
541thread_local! {
542    /// The open [`PcurveFitScope`]s on this thread, innermost last.
543    static PCURVE_FIT_SCOPES: std::cell::RefCell<Vec<PcurveFitLedger>> =
544        const { std::cell::RefCell::new(Vec::new()) };
545}
546
547/// An open tally of the pcurve fits made on this thread — see
548/// [`PcurveFitLedger`]. Open it where an operation starts, [`close`] it where
549/// the operation returns its diagnostics; a `?` that unwinds past it drops the
550/// tally.
551///
552/// [`close`]: PcurveFitScope::close
553pub struct PcurveFitScope {
554    /// Not `Send`: the scope must close on the thread that opened it.
555    _thread_bound: std::marker::PhantomData<*const ()>,
556}
557
558impl PcurveFitScope {
559    pub fn open() -> Self {
560        PCURVE_FIT_SCOPES.with(|scopes| scopes.borrow_mut().push(PcurveFitLedger::default()));
561        Self {
562            _thread_bound: std::marker::PhantomData,
563        }
564    }
565
566    /// Take the tally, folding it into the enclosing scope if there is one.
567    pub fn close(self) -> PcurveFitLedger {
568        let ledger = PCURVE_FIT_SCOPES.with(|scopes| {
569            let mut scopes = scopes.borrow_mut();
570            let ledger = scopes.pop().unwrap_or_default();
571            if let Some(parent) = scopes.last_mut() {
572                parent.fold(&ledger);
573            }
574            ledger
575        });
576        std::mem::forget(self);
577        ledger
578    }
579}
580
581impl Drop for PcurveFitScope {
582    fn drop(&mut self) {
583        PCURVE_FIT_SCOPES.with(|scopes| {
584            scopes.borrow_mut().pop();
585        });
586    }
587}
588
589fn record_fit(report: &PcurveFitReport) {
590    // Per-fit trace for the convergence probes (`trim_floor_sweep_probe` and
591    // its offset-shell sibling): one line per fit with the whole report, the
592    // way `BREP_DEBUG_PCURVE` traces an endpoint failure.
593    if std::env::var_os("BREP_DEBUG_PCURVE_FIT").is_some() {
594        eprintln!("PCURVE-FIT {report:?}");
595    }
596    PCURVE_FIT_SCOPES.with(|scopes| {
597        if let Some(open) = scopes.borrow_mut().last_mut() {
598            open.record(report);
599        }
600    });
601}
602
603/// Largest 3D distance between `pcurve`'s image and `curve` at the
604/// quarter-points of every span of `parameters` — the stations refinement
605/// probes, over EVERY span. Evaluations only; nothing is inverted. The sweep
606/// measures, it does not decide.
607fn probe_residual(
608    surface: &NurbsSurface,
609    curve: &NurbsCurve,
610    pcurve: &NurbsCurve,
611    parameters: &[f64],
612    [t0, t1]: [f64; 2],
613    [u0, u1]: [f64; 2],
614    [v0, v1]: [f64; 2],
615) -> Result<f64, String> {
616    let mut worst = 0.0_f64;
617    for span in parameters.windows(2) {
618        for local in [0.25, 0.5, 0.75] {
619            let fraction = span[0] + (span[1] - span[0]) * local;
620            let parameter = pcurve.evaluate(fraction)?;
621            let on_surface =
622                surface.evaluate(parameter.x.clamp(u0, u1), parameter.y.clamp(v0, v1))?;
623            let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
624            worst = worst.max(on_surface.sub(on_curve).length());
625        }
626    }
627    Ok(worst)
628}
629
630/// What one call to [`refine_pcurve`] achieved, beside the curve it left in
631/// place.
632struct Refinement {
633    exit: PcurveFitExit,
634    residual: f64,
635    off_surface: f64,
636    raised: bool,
637}
638
639/// Adaptive 3D-residual refinement of an interpolant, with the PER-TRIM sample
640/// budget. Shared by [`fit_pcurve_on_surface`] (global inversion) and
641/// [`fit_pcurve_on_surface_marched`] (seeded projection); `insert` is the one
642/// step that differs — given a probe station's fraction, the current pcurve's
643/// parameter there and the edge point, it returns the surface parameter to
644/// interpolate through (or `None` to decline the station) and the distance
645/// from the edge point to the surface at that station, its GAP.
646///
647/// **First pass.** Four rounds at [`MAX_PCURVE_SAMPLES`], each probing the
648/// quarter-points of every span wider than 1e-3 of the parameter range and
649/// inserting the stations over `floor`. Byte-for-byte the pre-2026-09-12
650/// loop; every exit is classified ([`PcurveFitExit`]) and the residual is
651/// measured on the RETURNED curve — over every span, unless the final round
652/// probed every span, in which case that round's maximum already is.
653///
654/// **The raise.** A [`PcurveFitExit::SampleCeiling`] exit re-enters the four
655/// rounds ONCE with [`PCURVE_RAISED_SAMPLES`], continuing from the samples it
656/// has, when the residual exceeds `off_surface` by more than the floor — the
657/// returned curve is worse than the edge curve's own distance from the
658/// surface, so more samples CAN improve it. Measured on `25_cube_pierce_solid`
659/// (2026-09-12): the edge curves there sit off their surfaces by up to 1.9e-4,
660/// four of the eight ceiling exits already read their gap to four digits, and
661/// raising those spent 900 inversions each for no change in the swept
662/// residual. The raised pass therefore inserts only where the pcurve's
663/// surface point is further than the floor from the PROJECTION of the edge
664/// point — its tracking error, which the inversion just computed: it stops
665/// when the pcurve tracks the surface's nearest point to the floor, which is
666/// the best any pcurve can do, and reports [`PcurveFitExit::Stalled`] with the
667/// deviation it actually reached when that is still over the floor. The limit
668/// of that pass as the floor tightens is the projected edge curve itself, so
669/// a volume built on it can be shown to converge; the deviation test alone
670/// cannot say that on off-surface input. Grow-once, capped, deterministic
671/// (invariant I3 of `per-entity-tolerances.md`).
672#[allow(clippy::too_many_arguments)]
673fn refine_pcurve(
674    surface: &NurbsSurface,
675    curve: &NurbsCurve,
676    [t0, t1]: [f64; 2],
677    [u0, u1]: [f64; 2],
678    [v0, v1]: [f64; 2],
679    floor: f64,
680    parameters: &mut Vec<f64>,
681    raw: &mut Vec<[f64; 2]>,
682    pcurve: &mut NurbsCurve,
683    mut off_surface: f64,
684    mut insert: impl FnMut(f64, Vec3, Vec3) -> Result<(Option<[f64; 2]>, f64), String>,
685) -> Result<Refinement, String> {
686    let mut max_samples = MAX_PCURVE_SAMPLES;
687    let mut raised = false;
688    loop {
689        // Every way out of this loop is CLASSIFIED (`PcurveFitExit`); a budget
690        // exit returns the curve it built, but says so. Falling off the end
691        // after a fourth round of inserts is the round-budget exit.
692        let mut exit = PcurveFitExit::RoundBudget;
693        let mut residual = None;
694        for _ in 0..4 {
695            if parameters.len() >= max_samples {
696                exit = PcurveFitExit::SampleCeiling;
697                break;
698            }
699            let mut inserts = Vec::new();
700            let mut round_max = 0.0_f64;
701            let mut truncated = false;
702            let mut skipped_narrow = false;
703            for index in 0..parameters.len() - 1 {
704                if parameters.len() + inserts.len() >= max_samples {
705                    truncated = true;
706                    break;
707                }
708                let start = parameters[index];
709                let end = parameters[index + 1];
710                if end - start < 1e-3 {
711                    skipped_narrow = true;
712                    continue;
713                }
714                for local_fraction in [0.25, 0.5, 0.75] {
715                    if parameters.len() + inserts.len() >= max_samples {
716                        truncated = true;
717                        break;
718                    }
719                    let fraction = start + (end - start) * local_fraction;
720                    let parameter = pcurve.evaluate(fraction)?;
721                    let on_surface =
722                        surface.evaluate(parameter.x.clamp(u0, u1), parameter.y.clamp(v0, v1))?;
723                    let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
724                    let deviation = on_surface.sub(on_curve).length();
725                    round_max = round_max.max(deviation);
726                    if deviation <= floor {
727                        continue;
728                    }
729                    let (surface_parameter, gap) = insert(fraction, parameter, on_curve)?;
730                    off_surface = off_surface.max(gap);
731                    if let Some(surface_parameter) = surface_parameter {
732                        // The raised pass stops where the pcurve TRACKS the
733                        // edge curve's projection to the floor — the best any
734                        // pcurve can do at this station. Not `deviation - gap`:
735                        // a surface point at tangential distance d from the
736                        // projection of a point g off the surface is only
737                        // d^2/(2g) further from it, so the deviation test is
738                        // quadratically blind to sideways error on off-surface
739                        // input (6e-6 of slop at g = 1.9e-4, floor 1e-7).
740                        if raised {
741                            let projected =
742                                surface.evaluate(surface_parameter[0], surface_parameter[1])?;
743                            if on_surface.sub(projected).length() <= floor {
744                                continue;
745                            }
746                        }
747                        inserts.push((index + 1, fraction, surface_parameter));
748                    }
749                }
750            }
751            if inserts.is_empty() {
752                // Nothing to insert: every probed station is within the floor
753                // (converged — unless a narrow span was skipped, in which case
754                // the sweep below decides), or the ones over it could not be
755                // improved (stalled — the edge curve is off the surface there).
756                exit = if truncated {
757                    PcurveFitExit::SampleCeiling
758                } else if round_max > floor {
759                    PcurveFitExit::Stalled
760                } else if skipped_narrow {
761                    PcurveFitExit::SpanFloor
762                } else {
763                    residual = Some(round_max);
764                    PcurveFitExit::Converged
765                };
766                break;
767            }
768            for (at, fraction, surface_parameter) in inserts.into_iter().rev() {
769                parameters.insert(at, fraction);
770                raw.insert(at, surface_parameter);
771            }
772            *pcurve = build_interpolant(surface, raw, parameters)?;
773            if truncated {
774                exit = PcurveFitExit::SampleCeiling;
775                break;
776            }
777        }
778        let residual = match residual {
779            Some(measured) => measured,
780            // A budget exit never re-checked the curve it is returning, and a
781            // converged round that skipped narrow spans never checked those:
782            // measure every span.
783            None => probe_residual(
784                surface,
785                curve,
786                pcurve,
787                parameters,
788                [t0, t1],
789                [u0, u1],
790                [v0, v1],
791            )?,
792        };
793        if exit == PcurveFitExit::SpanFloor && residual <= floor {
794            exit = PcurveFitExit::Converged;
795        }
796        // The per-trim budget decision. Deterministic, grow-once, capped (I3):
797        // the ceiling bound and the returned curve is worse than the edge
798        // curve's own gap from the surface by more than the floor, so samples
799        // can still buy accuracy. A curve already at its gap stops here —
800        // reported, not retried.
801        if exit == PcurveFitExit::SampleCeiling && !raised && residual > off_surface + floor {
802            raised = true;
803            max_samples = PCURVE_RAISED_SAMPLES;
804            continue;
805        }
806        return Ok(Refinement {
807            exit,
808            residual,
809            off_surface,
810            raised,
811        });
812    }
813}
814
815/// The exact inverse of an affine carrier's parameterization.
816///
817/// An affine patch (2x2 net, degree 1 both ways, equal weights) maps `uv` to 3D
818/// by an affine map, so the inverse — a 3D point to the `uv` of its orthogonal
819/// projection — is affine too. An affine map commutes with the rational basis:
820/// sending a curve's control points through it and keeping the weights
821/// produces the EXACT image of that curve in parameter space, not a fit of it.
822struct AffineInversion {
823    origin: Vec3,
824    du: Vec3,
825    dv: Vec3,
826    u0: f64,
827    v0: f64,
828    uu: f64,
829    uv: f64,
830    vv: f64,
831    determinant: f64,
832}
833
834impl AffineInversion {
835    /// `None` when `surface` is not affine, or when its two directions are
836    /// parallel enough that the inverse is not determined. The caller decides
837    /// what that means: a refusal on the whole-curve lane, the sampled lane on
838    /// the range lane.
839    fn of(surface: &NurbsSurface) -> Result<Option<Self>, String> {
840        if !surface.is_affine()? {
841            return Ok(None);
842        }
843        let ([u0, _], [v0, _]) = surface_domains(surface)?;
844        let (origin, du, dv) = surface.deriv1(u0, v0)?;
845        let uu = du.dot(du);
846        let uv = du.dot(dv);
847        let vv = dv.dot(dv);
848        let determinant = uu * vv - uv * uv;
849        if determinant.abs() <= 1e-18 {
850            return Ok(None);
851        }
852        Ok(Some(Self {
853            origin,
854            du,
855            dv,
856            u0,
857            v0,
858            uu,
859            uv,
860            vv,
861            determinant,
862        }))
863    }
864
865    fn uv_of(&self, point: Vec3) -> [f64; 2] {
866        let delta = point.sub(self.origin);
867        let along_u = delta.dot(self.du);
868        let along_v = delta.dot(self.dv);
869        [
870            self.u0 + (along_u * self.vv - along_v * self.uv) / self.determinant,
871            self.v0 + (along_v * self.uu - along_u * self.uv) / self.determinant,
872        ]
873    }
874
875    /// The exact image of `curve` in this carrier's parameter space, with the
876    /// curve's own degree, knots and weights.
877    fn image_of(&self, curve: &NurbsCurve) -> Result<NurbsCurve, String> {
878        let control_points = curve
879            .control_points
880            .iter()
881            .map(|control| {
882                let [u, v] = self.uv_of(control.point()?);
883                Ok(Vec4::from_point(Vec3::new(u, v, 0.0), control.w))
884            })
885            .collect::<Result<Vec<_>, String>>()?;
886        NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
887    }
888}
889
890/// Build a parameter-space curve for a 3D curve lying on a surface, and say
891/// what the fit achieved.
892///
893/// This mirrors the reference imprint implementation: affine patches map
894/// homogeneous control points exactly; general patches use sampled inversion,
895/// periodic seam unwrapping, and adaptive 3D residual refinement. The report
896/// beside the curve carries the residual the RETURNED curve reaches and why
897/// refinement stopped ([`PcurveFitReport`]); the curve itself is what
898/// [`build_pcurve_on_surface`] has always returned.
899pub fn fit_pcurve_on_surface(
900    surface: &NurbsSurface,
901    curve: &NurbsCurve,
902) -> Result<PcurveFit, String> {
903    let [t0, t1] = curve.domain()?;
904    if surface.is_affine()? {
905        let Some(inversion) = AffineInversion::of(surface)? else {
906            return Err("build_pcurve_on_surface: singular affine parameterization".into());
907        };
908        let pcurve = inversion.image_of(curve)?;
909        return Ok(PcurveFit {
910            report: PcurveFitReport {
911                residual: 0.0,
912                floor: PCURVE_REFINEMENT_TOLERANCE,
913                samples: pcurve.control_points.len(),
914                exit: PcurveFitExit::Converged,
915                off_surface: 0.0,
916                raised: false,
917            },
918            curve: pcurve,
919        });
920    }
921
922    let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
923    let drop_tolerance = 1e-4 * scale;
924    let endpoint_tolerance = 1e-3 * scale;
925    let refinement_tolerance = PCURVE_REFINEMENT_TOLERANCE;
926    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
927    let invert =
928        |fraction: f64| invert_checked(surface, curve.evaluate(t0 + (t1 - t0) * fraction)?);
929
930    let mut parameters = Vec::with_capacity(25);
931    let mut raw_surface_parameters = Vec::with_capacity(25);
932    // The edge curve's own distance from the surface at every station inverted,
933    // accepted or not: the floor below which no pcurve can bring the residual.
934    let mut off_surface = 0.0_f64;
935    for index in 0..=24 {
936        let fraction = index as f64 / 24.0;
937        let (parameter, distance) = invert(fraction)?;
938        off_surface = off_surface.max(distance);
939        if distance > drop_tolerance && index != 0 && index != 24 {
940            continue;
941        }
942        if distance > endpoint_tolerance {
943            if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
944                let p3 = curve.evaluate(t0 + (t1 - t0) * fraction).ok();
945                let p0 = curve.evaluate(t0).ok();
946                let p1 = curve.evaluate(t1).ok();
947                eprintln!(
948                    "PCURVE-FAIL idx={index} frac={fraction} dist={distance} endpt_tol={endpoint_tolerance} scale={scale}\n  curve3D@frac={p3:?}\n  curve3D@t0={p0:?} curve3D@t1={p1:?}\n  surf_domain=u[{u0},{u1}] v[{v0},{v1}] invpar={parameter:?}\n  surf@invpar={:?}",
949                    surface.evaluate(parameter[0], parameter[1]).ok()
950                );
951            }
952            return Err(format!(
953                "build_pcurve_on_surface: endpoint projection failed (distance={distance})"
954            ));
955        }
956        parameters.push(fraction);
957        raw_surface_parameters.push(parameter);
958    }
959
960    let mut pcurve = build_interpolant(surface, &raw_surface_parameters, &parameters)?;
961    let refinement = refine_pcurve(
962        surface,
963        curve,
964        [t0, t1],
965        [u0, u1],
966        [v0, v1],
967        refinement_tolerance,
968        &mut parameters,
969        &mut raw_surface_parameters,
970        &mut pcurve,
971        off_surface,
972        |fraction, _parameter, _on_curve| {
973            let (surface_parameter, distance) = invert(fraction)?;
974            Ok((
975                (distance <= drop_tolerance).then_some(surface_parameter),
976                distance,
977            ))
978        },
979    )?;
980    Ok(PcurveFit {
981        curve: pcurve,
982        report: PcurveFitReport {
983            residual: refinement.residual,
984            floor: refinement_tolerance,
985            samples: parameters.len(),
986            exit: refinement.exit,
987            off_surface: refinement.off_surface,
988            raised: refinement.raised,
989        },
990    })
991}
992
993/// [`fit_pcurve_on_surface`] for the callers that only need the curve. The
994/// report is not dropped: it is tallied on the open [`PcurveFitScope`], if any,
995/// so the operation that owns this fit can say in its diagnostics whether every
996/// trim reached the floor.
997pub fn build_pcurve_on_surface(
998    surface: &NurbsSurface,
999    curve: &NurbsCurve,
1000) -> Result<NurbsCurve, String> {
1001    let fit = fit_pcurve_on_surface(surface, curve)?;
1002    record_fit(&fit.report);
1003    Ok(fit.curve)
1004}
1005
1006/// SECTION-PCURVE SEEDED MARCH (t222: cone × ABC 00000327, non-integral genus).
1007///
1008/// `build_pcurve_on_surface` inverts every sample by GLOBAL closest point. Where
1009/// the carrier surface is COMPRESSED / self-overlapping along the row the
1010/// section rides, the global search aliases interior samples onto a DISTANT
1011/// preimage sheet, folding the pcurve out past the trim boundary and re-crossing
1012/// it. The arrangement then splits the shared section at that phantom crossing,
1013/// while the mate face (whose carrier is not folded there) keeps the section
1014/// whole — so the two operands' fragments disagree and the section strands
1015/// one-use (non-integral genus). Ground truth for t222: 00000327 face 496's
1016/// v≈0.7875 row maps u∈[0.55,0.81] into a ~0.18mm neighbourhood of the section
1017/// endpoint A; the global pcurve for piece 9 folded out to u=0.806 and back,
1018/// re-crossing trim edge 349 at v≈0.784, 0.003 below the corner vertex A.
1019///
1020/// This marches the interior inversions with each seeded from the PREVIOUS
1021/// accepted parameters (`project_point_to_surface_seeded`), so a marched sample
1022/// adopts the nearby branch instead of the momentarily-closest distant sheet.
1023/// The two ENDPOINT samples keep the deterministic global inversion (they are
1024/// the piece's shared junction vertices — same reasoning as
1025/// `repair_branch_jumps`). Additive + fail-soft: the marched chain is used ONLY
1026/// when it CLOSES onto the global far endpoint (its last interior sample is
1027/// parameter-adjacent to it, measured against the chain's own median step);
1028/// otherwise the plain global build is returned byte-for-byte. A single-preimage
1029/// carrier (the cone side of the same section) marches to the same samples the
1030/// global search already had, so it is unchanged there — this only ever alters a
1031/// carrier that actually presents multiple preimages within tolerance.
1032///
1033/// Escape hatch: `BREP_SECTION_PCURVE_MARCH=0`.
1034pub fn fit_pcurve_on_surface_marched(
1035    surface: &NurbsSurface,
1036    curve: &NurbsCurve,
1037) -> Result<PcurveFit, String> {
1038    let global = fit_pcurve_on_surface(surface, curve)?;
1039    if std::env::var("BREP_SECTION_PCURVE_MARCH").as_deref() == Ok("0") || surface.is_affine()? {
1040        return Ok(global);
1041    }
1042    let [t0, t1] = curve.domain()?;
1043    // A closed (ring) section has no distinct endpoints to anchor the march;
1044    // leave it to the existing path (closed rings are handled elsewhere).
1045    if curve.evaluate(t0)?.sub(curve.evaluate(t1)?).length() <= 1e-6 {
1046        return Ok(global);
1047    }
1048    const SAMPLES: usize = 24;
1049    let fractions: Vec<f64> = (0..=SAMPLES).map(|i| i as f64 / SAMPLES as f64).collect();
1050    let edge: Vec<Vec3> = fractions
1051        .iter()
1052        .map(|fraction| curve.evaluate(t0 + (t1 - t0) * fraction))
1053        .collect::<Result<Vec<_>, _>>()?;
1054    let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
1055    let endpoint_tolerance = 1e-3 * scale;
1056    let (first, first_distance) = invert_checked(surface, edge[0])?;
1057    let (last, last_distance) = invert_checked(surface, edge[SAMPLES])?;
1058    if first_distance > endpoint_tolerance || last_distance > endpoint_tolerance {
1059        return Ok(global);
1060    }
1061    // The edge curve's distance from the surface at every station this chain
1062    // inverts — on the MARCHED branch, which is the one being fitted.
1063    let mut off_surface = first_distance.max(last_distance);
1064    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
1065    let u_span = (u1 - u0).abs().max(EPSILON);
1066    let v_span = (v1 - v0).abs().max(EPSILON);
1067    let (closed_u, closed_v) = surface_closedness(surface)?;
1068    let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
1069        let mut du = a[0] - b[0];
1070        if closed_u {
1071            while du > 0.5 * u_span {
1072                du -= u_span;
1073            }
1074            while du < -0.5 * u_span {
1075                du += u_span;
1076            }
1077        }
1078        let mut dv = a[1] - b[1];
1079        if closed_v {
1080            while dv > 0.5 * v_span {
1081                dv -= v_span;
1082            }
1083            while dv < -0.5 * v_span {
1084                dv += v_span;
1085            }
1086        }
1087        ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
1088    };
1089    // Forward seeded march over the interior samples.
1090    let mut raw = vec![first];
1091    let mut previous = first;
1092    for index in 1..SAMPLES {
1093        let seeded =
1094            project_point_to_surface_seeded(surface, edge[index], previous[0], previous[1])?;
1095        let (_, global_distance) = invert_checked(surface, edge[index])?;
1096        // The seeded footpoint must still sit on the surface — as tight as the
1097        // fit band or the global answer already had it. If it fell off (the
1098        // seed led Newton into a valley), abandon the march and keep global.
1099        if seeded.distance > 4.0 * global_distance + endpoint_tolerance {
1100            return Ok(global);
1101        }
1102        off_surface = off_surface.max(seeded.distance);
1103        previous = [seeded.u, seeded.v];
1104        raw.push(previous);
1105    }
1106    raw.push(last);
1107    // CLOSURE GATE: the last marched interior sample must be parameter-adjacent
1108    // to the global far endpoint — the march stayed on ONE branch the whole way.
1109    // Measure the endpoint gap against the chain's OWN median step so a genuine
1110    // long edge is not rejected while a sheet-hop (which leaves a big gap to the
1111    // endpoint) is.
1112    let mut steps: Vec<f64> = (1..raw.len())
1113        .map(|i| norm_step(raw[i], raw[i - 1]))
1114        .collect();
1115    let endpoint_gap = steps.pop().unwrap_or(0.0);
1116    steps.sort_by(f64::total_cmp);
1117    let median = steps
1118        .get(steps.len() / 2)
1119        .copied()
1120        .unwrap_or(0.0)
1121        .max(EPSILON);
1122    if endpoint_gap > (4.0 * median).max(0.05) {
1123        return Ok(global);
1124    }
1125    // Build the interpolant from the marched samples, then refine — re-projecting
1126    // each insert SEEDED from the interpolant (already on the marched branch), so
1127    // a mid-refinement global inversion cannot re-alias onto the far sheet.
1128    let mut parameters = fractions;
1129    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
1130    let refinement_tolerance = PCURVE_REFINEMENT_TOLERANCE;
1131    // Same classified exits and budget ladder as `fit_pcurve_on_surface`. A
1132    // seeded insert always lands, so this loop cannot stall.
1133    let refinement = refine_pcurve(
1134        surface,
1135        curve,
1136        [t0, t1],
1137        [u0, u1],
1138        [v0, v1],
1139        refinement_tolerance,
1140        &mut parameters,
1141        &mut raw,
1142        &mut pcurve,
1143        off_surface,
1144        |_fraction, seed, on_curve| {
1145            let seeded = project_point_to_surface_seeded(surface, on_curve, seed.x, seed.y)?;
1146            Ok((Some([seeded.u, seeded.v]), seeded.distance))
1147        },
1148    )?;
1149    Ok(PcurveFit {
1150        curve: pcurve,
1151        report: PcurveFitReport {
1152            residual: refinement.residual,
1153            floor: refinement_tolerance,
1154            samples: parameters.len(),
1155            exit: refinement.exit,
1156            off_surface: refinement.off_surface,
1157            raised: refinement.raised,
1158        },
1159    })
1160}
1161
1162/// [`fit_pcurve_on_surface_marched`] for the callers that only need the curve;
1163/// the report is tallied on the open [`PcurveFitScope`] exactly as
1164/// [`build_pcurve_on_surface`] does.
1165pub fn build_pcurve_on_surface_marched(
1166    surface: &NurbsSurface,
1167    curve: &NurbsCurve,
1168) -> Result<NurbsCurve, String> {
1169    let fit = fit_pcurve_on_surface_marched(surface, curve)?;
1170    record_fit(&fit.report);
1171    Ok(fit.curve)
1172}
1173
1174/// Build a pcurve for a represented subrange of a larger edge curve.
1175///
1176/// On an affine carrier the answer is exact and is taken as such
1177/// ([`affine_pcurve_on_range`]). Otherwise this samples only the represented
1178/// interval, which is essential when off-interval control points do not lie on
1179/// the target carrier — the exact lane restricts the net by knot insertion
1180/// first, so those control points are removed rather than ignored.
1181pub fn build_pcurve_on_surface_range(
1182    surface: &NurbsSurface,
1183    curve: &NurbsCurve,
1184    edge_start: f64,
1185    edge_end: f64,
1186    forward: bool,
1187    tolerance: f64,
1188) -> Result<NurbsCurve, String> {
1189    if let Some(exact) =
1190        affine_pcurve_on_range(surface, curve, edge_start, edge_end, forward, tolerance)?
1191    {
1192        return Ok(exact);
1193    }
1194    build_pcurve_on_surface_range_dense(
1195        surface, curve, edge_start, edge_end, forward, tolerance, 64, 3, 513,
1196    )
1197}
1198
1199/// The EXACT pcurve for a coedge's subrange of `curve` on an affine carrier,
1200/// or `None` when there is no such answer and the sampled lane must run.
1201///
1202/// [`fit_pcurve_on_surface`] has always mapped a WHOLE curve's control net
1203/// exactly on an affine carrier. A coedge's trim is that same curve restricted
1204/// to `[edge_start, edge_end]` and oriented by `forward`, and both operations
1205/// are exact on a NURBS: [`NurbsCurve::split`] subdivides by knot insertion,
1206/// [`NurbsCurve::reversed`] mirrors the net, and rescaling the knots to
1207/// `[0, 1]` is affine in the edge's own parameter — which is precisely the
1208/// fraction convention the sampled lane uses, so the pcurve's correspondence
1209/// with the edge is the same one either lane produces.
1210///
1211/// Sampling where an exact answer exists leaves a real error: measured
1212/// 2026-09-17, a booleaned band rim's two planar trims sat 2.379e-4 and
1213/// 2.395e-4 off their edges, where this lane lands 1.4e-13 off.
1214///
1215/// The image is verified against the caller's own `tolerance` before it is
1216/// returned. The exact image of a curve that does NOT lie on this carrier is
1217/// its orthogonal projection, which is not that coedge's trim, so every such
1218/// case falls through to the sampled lane and keeps the refusals that lane
1219/// makes today.
1220fn affine_pcurve_on_range(
1221    surface: &NurbsSurface,
1222    curve: &NurbsCurve,
1223    edge_start: f64,
1224    edge_end: f64,
1225    forward: bool,
1226    tolerance: f64,
1227) -> Result<Option<NurbsCurve>, String> {
1228    // Opt-out for A/B bisection of this lane against the sampled one, in the
1229    // shape of the two hatches beside it (`BREP_NO_PCURVE_REPAIR`,
1230    // `BREP_SECTION_PCURVE_MARCH`). It is what showed that the boolean's
1231    // planar residuals do NOT come through here: the same binary with this set
1232    // and unset read identically to every digit on 21_band_rim.
1233    if std::env::var("BREP_AFFINE_RANGE_PCURVE").as_deref() == Ok("0") {
1234        return Ok(None);
1235    }
1236    let Some(inversion) = AffineInversion::of(surface)? else {
1237        return Ok(None);
1238    };
1239    if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
1240        // The sampled lane owns this refusal; it is not this lane's to reword.
1241        return Ok(None);
1242    }
1243    let [t0, t1] = curve.domain()?;
1244    let start = edge_start.max(t0);
1245    let end = edge_end.min(t1);
1246    // A range narrower than the knot identity tolerance cannot be split onto
1247    // its own endpoints at all.
1248    if end - start <= 2.0 * KNOT_IDENTITY_TOL {
1249        return Ok(None);
1250    }
1251    let mut restricted = curve.clone();
1252    if end < t1 - KNOT_IDENTITY_TOL {
1253        restricted = restricted.split(end)?.0;
1254    }
1255    if start > t0 + KNOT_IDENTITY_TOL {
1256        restricted = restricted.split(start)?.1;
1257    }
1258    let mut pcurve = inversion.image_of(&restricted)?;
1259    if !forward {
1260        pcurve = pcurve.reversed()?;
1261    }
1262    let first = pcurve.knots[0];
1263    let last = pcurve.knots[pcurve.knots.len() - 1];
1264    if !(last - first > 0.0) {
1265        return Ok(None);
1266    }
1267    let knots = pcurve
1268        .knots
1269        .iter()
1270        .map(|knot| (knot - first) / (last - first))
1271        .collect::<Vec<_>>();
1272    let pcurve = NurbsCurve::new(pcurve.degree, knots, pcurve.control_points.clone())?;
1273
1274    // Verify against the range the CALLER asked for, not the one the
1275    // restriction produced: `split` snaps onto a nearby knot, so a snap that
1276    // moved the correspondence reads here as a deviation. Sampling at
1277    // fractions that are not the stations any lane interpolates through keeps
1278    // this a check on the whole trim rather than on its endpoints.
1279    let scale = 1.0 + curve.evaluate((start + end) / 2.0)?.length();
1280    let bar = tolerance.max(1e-11 * scale);
1281    for index in 0..=32 {
1282        let fraction = index as f64 / 32.0;
1283        let uv = pcurve.evaluate(fraction)?;
1284        // `evaluate` CLAMPS out-of-domain uv. A planar carrier's domain is a
1285        // bounding rectangle that a booleaned trim can leave, and an affine
1286        // patch's extension is the same plane, so read it unclamped.
1287        let on_carrier = surface.evaluate_extended(uv.x, uv.y)?;
1288        let edge_fraction = if forward { fraction } else { 1.0 - fraction };
1289        let on_edge = curve.evaluate(start + (end - start) * edge_fraction)?;
1290        if on_carrier.sub(on_edge).length() > bar {
1291            return Ok(None);
1292        }
1293    }
1294    Ok(Some(pcurve))
1295}
1296
1297fn align_collapsed_endpoints(surface: &NurbsSurface, raw: &mut [[f64; 2]]) -> Result<(), String> {
1298    let anchor = surface.control_points[0][0].point()?;
1299    let mut extent = 0.0_f64;
1300    for control in surface.control_points.iter().flatten() {
1301        extent = extent.max(control.point()?.sub(anchor).length());
1302    }
1303    // This is a geometric identity check, independent of the fitting allowance
1304    // and of world position. Positive rational weights keep the whole iso-curve
1305    // inside the convex hull of its Euclidean controls. Cap the size coupling
1306    // at the linear tolerance so a large patch cannot erase a thin finite row.
1307    let pole_band = (1e-10 * (1.0 + extent)).min(LINEAR_TOLERANCE);
1308    for (endpoint, neighbor) in [(0, 1), (raw.len() - 1, raw.len() - 2)] {
1309        let point = surface.evaluate(raw[endpoint][0], raw[endpoint][1])?;
1310        for axis in 0..2 {
1311            let mut candidate = raw[endpoint];
1312            candidate[axis] = raw[neighbor][axis];
1313            if candidate == raw[endpoint]
1314                || surface
1315                    .evaluate(candidate[0], candidate[1])?
1316                    .sub(point)
1317                    .length()
1318                    > pole_band
1319            {
1320                continue;
1321            }
1322            let row = if axis == 0 {
1323                surface.iso_curve_v(raw[endpoint][1])?
1324            } else {
1325                surface.iso_curve_u(raw[endpoint][0])?
1326            };
1327            let mut collapsed = true;
1328            for control in &row.control_points {
1329                if control.point()?.sub(point).length() > pole_band {
1330                    collapsed = false;
1331                    break;
1332                }
1333            }
1334            if collapsed {
1335                raw[endpoint] = candidate;
1336            }
1337        }
1338    }
1339    Ok(())
1340}
1341
1342/// Re-seat any raw inversion sample that BRANCH-JUMPED — snapped to a distant
1343/// fold of a self-overlapping general carrier — back onto the branch traced by
1344/// its neighbours.
1345///
1346/// Each entry in `raw` is an INDEPENDENT global closest-point inversion of the
1347/// corresponding 3D edge sample. Every one is geometrically valid (on the
1348/// surface, at the edge), but they need not be CONTIGUOUS: where a rational
1349/// B-spline surface folds back over the small trimmed patch it carries, the
1350/// momentarily-closest fold flips from one sample to the next, so the raw
1351/// polygon zig-zags across the whole domain and self-crosses — and the region
1352/// its interpolant bounds no longer covers the true face (its surface flux can
1353/// exceed its own area). We anchor on the LONGEST run of mutually-continuous
1354/// samples — the fold the trimmed patch actually lies on, since a small face
1355/// lives on ONE fold and the jumped samples are the minority the global search
1356/// snapped elsewhere — then walk outward in both directions, re-seeding Newton
1357/// from the last good parameters and adopting the continuous footpoint whenever
1358/// it is geometrically as valid as the global one. Anchoring on consensus (not
1359/// blindly on sample 0, which can itself be an outlier that would drag the
1360/// whole edge onto the wrong branch and tear the loop open at its endpoint)
1361/// leaves continuous nonsingular fits unchanged. Certified collapsed endpoint
1362/// rows first adopt their neighbour's branch; a bad seed in the subsequent
1363/// jump repair cannot make a sample worse than the global search already had it.
1364fn repair_branch_jumps(
1365    surface: &NurbsSurface,
1366    edge_points: &[Vec3],
1367    raw: &mut [[f64; 2]],
1368    tolerance: f64,
1369) -> Result<(), String> {
1370    if raw.len() < 3 {
1371        return Ok(());
1372    }
1373    // Affine carriers invert linearly and exactly — no folds, nothing to chase.
1374    // Checked first so the common planar/affine face never touches the env.
1375    if surface.is_affine()? {
1376        return Ok(());
1377    }
1378    // Opt-out for A/B bisection of a STEP-import regression against this repair.
1379    if std::env::var("BREP_NO_PCURVE_REPAIR").is_ok() {
1380        return Ok(());
1381    }
1382    // A pole has no unique coordinate along its collapsed row. Continuing the
1383    // adjacent sample's branch is safe only when the ENTIRE row is collapsed;
1384    // coincident points across an ordinary seam or fold are not enough.
1385    align_collapsed_endpoints(surface, raw)?;
1386    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
1387    let u_span = (u1 - u0).abs().max(EPSILON);
1388    let v_span = (v1 - v0).abs().max(EPSILON);
1389    let (closed_u, closed_v) = surface_closedness(surface)?;
1390    // Normalised, seam-aware step between two parameter samples. Wrapping the
1391    // closed directions keeps a legitimate seam crossing SMALL, so the seam /
1392    // periodic-unwrap machinery elsewhere is never disturbed by this repair.
1393    let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
1394        let mut du = a[0] - b[0];
1395        if closed_u {
1396            while du > 0.5 * u_span {
1397                du -= u_span;
1398            }
1399            while du < -0.5 * u_span {
1400                du += u_span;
1401            }
1402        }
1403        let mut dv = a[1] - b[1];
1404        if closed_v {
1405            while dv > 0.5 * v_span {
1406                dv -= v_span;
1407            }
1408            while dv < -0.5 * v_span {
1409                dv += v_span;
1410            }
1411        }
1412        ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
1413    };
1414    // A jump crosses a large fraction of the WHOLE domain — orders above the
1415    // per-sample motion of any real (even domain-spanning) edge, which advances
1416    // ~1/N of its traversal between consecutive stations.
1417    const JUMP_THRESHOLD: f64 = 0.2;
1418    let count = raw.len();
1419
1420    // Locate the longest maximal run of consecutive continuous samples — the
1421    // branch to anchor on. If nothing jumps, this spans [0, count-1] and both
1422    // re-seat passes below are empty, leaving `raw` untouched.
1423    let (mut best_start, mut best_len, mut run_start) = (0usize, 1usize, 0usize);
1424    for index in 1..count {
1425        if norm_step(raw[index], raw[index - 1]) > JUMP_THRESHOLD {
1426            if index - run_start > best_len {
1427                best_len = index - run_start;
1428                best_start = run_start;
1429            }
1430            run_start = index;
1431        }
1432    }
1433    if count - run_start > best_len {
1434        best_len = count - run_start;
1435        best_start = run_start;
1436    }
1437    let (spine_lo, spine_hi) = (best_start, best_start + best_len - 1);
1438
1439    // Adopt the continuous footpoint only when it (a) still sits on the surface
1440    // — as tight as the fit band or the global answer — and (b) genuinely
1441    // closes the jump rather than trading it for another. Returns the parameter
1442    // to carry forward as the next seed (the repaired one, or the untouched
1443    // global when no repair applies).
1444    let reseat = |edge_point: Vec3,
1445                  previous: [f64; 2],
1446                  global: [f64; 2]|
1447     -> Result<[f64; 2], String> {
1448        let global_step = norm_step(global, previous);
1449        if global_step <= JUMP_THRESHOLD {
1450            return Ok(global);
1451        }
1452        let seeded =
1453            project_point_to_surface_seeded(surface, edge_point, previous[0], previous[1])?;
1454        let candidate = [seeded.u, seeded.v];
1455        let global_residual = surface
1456            .evaluate(global[0], global[1])?
1457            .sub(edge_point)
1458            .length();
1459        let on_surface = seeded.distance <= 4.0 * global_residual + tolerance.max(1e-12);
1460        if on_surface && norm_step(candidate, previous) < 0.5 * global_step {
1461            if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
1462                eprintln!(
1463                    "pcurve repair: jump {global_step:.4} ({global:?}) -> {:.4} ({candidate:?}) res {:.2e}->{:.2e}",
1464                    norm_step(candidate, previous),
1465                    global_residual,
1466                    seeded.distance
1467                );
1468            }
1469            Ok(candidate)
1470        } else {
1471            Ok(global)
1472        }
1473    };
1474
1475    // Walk forward off the spine's high end, then backward off its low end,
1476    // chaining each repaired sample as the next seed so continuity propagates.
1477    //
1478    // Apart from the certified collapsed rows above, the two ENDPOINT samples
1479    // (fraction 0 and 1) are NEVER moved: they are the
1480    // edge's shared loop vertices. The global inversion is deterministic, so the
1481    // two coedges meeting at a vertex land it at the SAME parameters even when
1482    // that vertex sits on a fold reachable from two branches — moving one side
1483    // to a different branch tears the loop open there (`synthesize_pole_edge`
1484    // then rejects the non-collapsed gap). A vertex's genuine fold transition
1485    // (an edge whose interior rides u≈0.08 but whose endpoint must meet its
1486    // neighbour at u≈0.95) is exactly this case and must be preserved, not
1487    // "continuity-repaired" back onto the interior branch.
1488    let mut previous = raw[spine_hi];
1489    for index in (spine_hi + 1)..count.saturating_sub(1) {
1490        raw[index] = reseat(edge_points[index], previous, raw[index])?;
1491        previous = raw[index];
1492    }
1493    let mut previous = raw[spine_lo];
1494    for index in (1..spine_lo).rev() {
1495        raw[index] = reseat(edge_points[index], previous, raw[index])?;
1496        previous = raw[index];
1497    }
1498    Ok(())
1499}
1500
1501/// Range fitter with explicit sampling knobs. The default entry above keeps
1502/// the long-standing (base 64, 3 refinement rounds, 513 cap) budget.
1503///
1504/// NOTE (2026-09-03): this comment used to say "STEP import retries failed fits
1505/// with a denser budget". No caller passes these knobs any more — that retry
1506/// was removed and the sentence outlived it. The knobs are kept because they
1507/// are the honest way to ASK whether a fit had headroom left, which is what
1508/// `examples/pcurve_residual_probe.rs` uses them for: on every ABC corpus
1509/// coedge that failed `validate`'s pcurve check, budgets up to (512, 8, 8193)
1510/// with a 1000x tighter target reproduced the coarse fit's deviation to six
1511/// decimals, because that deviation is the edge-vs-surface closest-point
1512/// residual and a curve ON the surface cannot beat it.
1513#[allow(clippy::too_many_arguments)]
1514pub fn build_pcurve_on_surface_range_dense(
1515    surface: &NurbsSurface,
1516    curve: &NurbsCurve,
1517    edge_start: f64,
1518    edge_end: f64,
1519    forward: bool,
1520    tolerance: f64,
1521    base_samples: usize,
1522    refinement_rounds: usize,
1523    parameter_cap: usize,
1524) -> Result<NurbsCurve, String> {
1525    if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
1526        return Err("build_pcurve_on_surface_range: invalid edge interval".into());
1527    }
1528    let evaluate_edge = |fraction: f64| {
1529        let edge_fraction = if forward { fraction } else { 1.0 - fraction };
1530        curve.evaluate(edge_start + (edge_end - edge_start) * edge_fraction)
1531    };
1532    build_pcurve_on_surface_stations(
1533        surface,
1534        &evaluate_edge,
1535        tolerance,
1536        base_samples,
1537        refinement_rounds,
1538        parameter_cap,
1539    )
1540}
1541
1542/// The body of [`build_pcurve_on_surface_range_dense`], parameterized by the
1543/// 3D station the pcurve must pass through at each coedge fraction instead of
1544/// by an edge curve and a range.
1545///
1546/// The station supplier is the whole difference between deriving a pcurve and
1547/// reading one. `build_pcurve_on_surface_range_dense` supplies points off the
1548/// edge's own 3D curve, so the answer is "the nearest point of the carrier",
1549/// which is ambiguous exactly where a trim is interesting — a seam branch, a
1550/// near-tangential approach, a pole. The STEP importer's supplied-pcurve lane
1551/// supplies points off the VENDOR's stated trim instead, and the ambiguity is
1552/// gone because the vendor resolved it.
1553///
1554/// `stations(fraction)` must be continuous in `fraction` over `[0, 1]` and land
1555/// on (or very near) `surface`; everything else — inversion, branch repair and
1556/// refinement — is shared with the derived lane by construction.
1557pub fn build_pcurve_on_surface_stations(
1558    surface: &NurbsSurface,
1559    stations: &dyn Fn(f64) -> Result<Vec3, String>,
1560    tolerance: f64,
1561    base_samples: usize,
1562    refinement_rounds: usize,
1563    parameter_cap: usize,
1564) -> Result<NurbsCurve, String> {
1565    let evaluate_edge = stations;
1566    let mut parameters = (0..=base_samples)
1567        .map(|index| index as f64 / base_samples as f64)
1568        .collect::<Vec<_>>();
1569    // The 3D edge point behind each raw inversion sample, kept parallel so the
1570    // continuity repair can re-seed a jumped station from its own footpoint.
1571    let mut edge_points = parameters
1572        .iter()
1573        .map(|fraction| evaluate_edge(*fraction))
1574        .collect::<Result<Vec<_>, _>>()?;
1575    let mut raw = edge_points
1576        .iter()
1577        .map(|point| invert_checked(surface, *point).map(|value| value.0))
1578        .collect::<Result<Vec<_>, _>>()?;
1579    repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
1580    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
1581    for _ in 0..refinement_rounds {
1582        let mut inserts = Vec::new();
1583        for index in 0..parameters.len() - 1 {
1584            if parameters.len() + inserts.len() >= parameter_cap {
1585                break;
1586            }
1587            for local in [0.25, 0.5, 0.75] {
1588                let fraction =
1589                    parameters[index] + (parameters[index + 1] - parameters[index]) * local;
1590                let uv = pcurve.evaluate(fraction)?;
1591                let represented = surface.evaluate(uv.x, uv.y)?;
1592                let edge_point = evaluate_edge(fraction)?;
1593                if represented.sub(edge_point).length() <= tolerance {
1594                    continue;
1595                }
1596                inserts.push((
1597                    index + 1,
1598                    fraction,
1599                    edge_point,
1600                    invert_checked(surface, edge_point)?.0,
1601                ));
1602            }
1603        }
1604        if inserts.is_empty() {
1605            break;
1606        }
1607        for (index, fraction, edge_point, value) in inserts.into_iter().rev() {
1608            parameters.insert(index, fraction);
1609            edge_points.insert(index, edge_point);
1610            raw.insert(index, value);
1611        }
1612        // Inserts are independent global inversions too — re-run the repair so
1613        // a fold-flip introduced mid-refinement cannot poison the next round's
1614        // deviation interpolant.
1615        repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
1616        pcurve = build_interpolant(surface, &raw, &parameters)?;
1617    }
1618    Ok(pcurve)
1619}
1620