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
17/// The carrier's closed directions, as [`NurbsSurface::closed_directions`]
18/// reads them (cached on the surface, seam tolerance 1e-6, and a sliver patch
19/// whose rows coincide without the surface travelling between them is OPEN).
20/// Until 2026-09-26 this was a second reading with its own tolerance; the
21/// unwrap in [`build_interpolant`] and the wrap in `evaluate_extended` must
22/// agree on what a seam is, or a trim across a sliver reads a period wide.
23fn surface_closedness(surface: &NurbsSurface) -> Result<(bool, bool), String> {
24 surface.closed_directions()
25}
26
27fn invert_checked(surface: &NurbsSurface, point: Vec3) -> Result<([f64; 2], f64), String> {
28 if surface.is_affine()? {
29 let ([u0, _], [v0, _]) = surface_domains(surface)?;
30 let (origin, du, dv) = surface.deriv1(u0, v0)?;
31 let delta = point.sub(origin);
32 let uu = du.dot(du);
33 let uv = du.dot(dv);
34 let vv = dv.dot(dv);
35 let along_u = delta.dot(du);
36 let along_v = delta.dot(dv);
37 let determinant = uu * vv - uv * uv;
38 if determinant.abs() > EPSILON {
39 let parameter = [
40 u0 + (along_u * vv - along_v * uv) / determinant,
41 v0 + (along_v * uu - along_u * uv) / determinant,
42 ];
43 // The standoff is a MEASUREMENT here as on every other carrier:
44 // until 2026-09-26 this returned 0.0 for "affine, not measured",
45 // and a curve 1e-2 off its plane read as lying on it.
46 let foot = origin.add(du.scale(parameter[0] - u0)).add(dv.scale(parameter[1] - v0));
47 return Ok((parameter, point.sub(foot).length()));
48 }
49 }
50 let projection = project_point_to_surface(surface, point)?;
51 Ok(([projection.u, projection.v], projection.distance))
52}
53
54/// Whether an end station at a cone's apex takes its nearest regular
55/// station's u (`true`), as in `build_interpolant`, or the projector's
56/// arbitrary u there as before 2026-09-27. `BREP_PCURVE_CONE_APEX_U=0`
57/// restores the projector's u, for a same-binary A/B.
58fn cone_apex_u() -> bool {
59 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
60 *ON.get_or_init(|| std::env::var_os("BREP_PCURVE_CONE_APEX_U").is_none_or(|value| value != "0"))
61}
62
63/// Whether a round with more candidates than the cap has room for keeps its
64/// WORST probes (`true`), or the first in span order as before 2026-09-27.
65/// `BREP_PCURVE_WORST_FIRST=0` restores span order, for a same-binary A/B.
66fn worst_first() -> bool {
67 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
68 *ON.get_or_init(|| std::env::var_os("BREP_PCURVE_WORST_FIRST").is_none_or(|value| value != "0"))
69}
70
71fn seeded_inserts() -> bool {
72 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
73 *ON.get_or_init(|| std::env::var_os("BREP_PCURVE_SEEDED_INSERTS").is_none_or(|value| value != "0"))
74}
75
76/// [`invert_checked`] for a station inserted between two whose feet are known:
77/// Newton from the pcurve's own `(u, v)` at that fraction, which already lies
78/// between the neighbours' feet on their branch, and the global search only
79/// when that local foot stands off further than the neighbours do (plus the
80/// refinement floor) — a local minimum the curve does not sit at. The global
81/// search was 30 s of a 54 s `abc_00000011` import (732k calls, 41 µs each,
82/// measured 2026-09-26). Planes and analytic carriers keep their closed forms.
83/// `BREP_PCURVE_SEEDED_INSERTS=0` restores the global search at every insert,
84/// for a same-binary A/B.
85fn invert_near(
86 surface: &NurbsSurface,
87 point: Vec3,
88 seed: [f64; 2],
89 neighbour_gap: f64,
90) -> Result<([f64; 2], f64), String> {
91 if !seeded_inserts() || surface.is_affine()? || surface.analytic().is_some() {
92 return invert_checked(surface, point);
93 }
94 let seeded = project_point_to_surface_seeded(surface, point, seed[0], seed[1])?;
95 if seeded.distance <= neighbour_gap + PCURVE_REFINEMENT_TOLERANCE {
96 return Ok(([seeded.u, seeded.v], seeded.distance));
97 }
98 invert_checked(surface, point)
99}
100
101fn unwrap_periodic(values: &mut [f64], minimum: f64, maximum: f64) {
102 let period = maximum - minimum;
103 for index in 1..values.len() {
104 while values[index] - values[index - 1] > period / 2.0 {
105 values[index] -= period;
106 }
107 while values[index] - values[index - 1] < -period / 2.0 {
108 values[index] += period;
109 }
110 }
111 if values.len() > 1 {
112 while values[0] - values[1] > period / 2.0 {
113 values[0] -= period;
114 }
115 while values[0] - values[1] < -period / 2.0 {
116 values[0] += period;
117 }
118 }
119 // Recenter the whole (now continuous) sequence into the domain by the
120 // whole-period shift that leaves the LEAST parameter outside [minimum,
121 // maximum]. A single middle/mean sample is NOT representative of a curve
122 // that bulges out to touch — or straddle — the periodic seam: when that
123 // sample is a boundary-kissing point (the at-seam projection unwraps a hair
124 // past the boundary), a single-sample test misfires and shoves the entire
125 // curve a full period out of range (ABC helmet 00000011/12 side channels:
126 // a touching edge's parameter run snapped a whole period off its true
127 // interior, tearing the loop open in parameter space).
128 if values.len() > 1 {
129 let excursion = |shift: f64| -> f64 {
130 values
131 .iter()
132 .map(|value| {
133 let v = value + shift;
134 (minimum - v).max(0.0) + (v - maximum).max(0.0)
135 })
136 .sum::<f64>()
137 };
138 let mean = values.iter().copied().sum::<f64>() / values.len() as f64;
139 let center = 0.5 * (minimum + maximum);
140 let base_k = ((center - mean) / period).round() as i64;
141 let mut best_shift = 0.0;
142 let mut best_excursion = f64::INFINITY;
143 for k in (base_k - 1)..=(base_k + 1) {
144 let shift = k as f64 * period;
145 let value = excursion(shift);
146 if value < best_excursion {
147 best_excursion = value;
148 best_shift = shift;
149 }
150 }
151 if best_shift != 0.0 {
152 for value in values.iter_mut() {
153 *value += best_shift;
154 }
155 }
156 }
157}
158
159fn build_interpolant(
160 surface: &NurbsSurface,
161 raw_parameters: &[[f64; 2]],
162 curve_parameters: &[f64],
163) -> Result<NurbsCurve, String> {
164 let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
165 let (closed_u, closed_v) = surface_closedness(surface)?;
166 let mut parameters = raw_parameters.to_vec();
167 // A cone's apex has no longitude either: every u there is the same point,
168 // and the projector answers one arbitrarily (0, which unwraps to 1). A trim
169 // that ENDS at the apex then turns sideways in its last span —
170 // `abc 00008080` face #163 edge 476, a generatrix at u = 0.80255 whose
171 // apex station read u = 1.0 (2026-09-27). An apex station at either end of
172 // the run takes the u of its nearest regular station, so a generatrix's
173 // pcurve is the straight u = const it is. Interior apex crossings are left
174 // to the branch logic below. `BREP_PCURVE_CONE_APEX_U=0` switches this off,
175 // for a same-binary A/B.
176 if cone_apex_u() && matches!(surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })) && parameters.len() >= 2 {
177 let speeds = parameters
178 .iter()
179 .map(|parameter| surface.deriv1(parameter[0], parameter[1]).map(|(_, su, _)| su.length()))
180 .collect::<Result<Vec<_>, _>>()?;
181 let reference = speeds.iter().copied().fold(0.0_f64, f64::max);
182 let singular = |index: usize| speeds[index] <= reference * 1e-2;
183 if let (Some(first), Some(last)) = (
184 (0..parameters.len()).find(|&index| !singular(index)),
185 (0..parameters.len()).rfind(|&index| !singular(index)),
186 ) {
187 for index in 0..first {
188 parameters[index][0] = parameters[first][0];
189 }
190 for index in last + 1..parameters.len() {
191 parameters[index][0] = parameters[last][0];
192 }
193 }
194 }
195 // Longitude is undefined at a sphere pole: the surface's u tangent
196 // vanishes there, and closest-point inversion is free to return any u.
197 // Letting that arbitrary value participate in periodic unwrapping can move
198 // the first real meridian by a whole period (e.g. 0.75 -> -0.25). Analytic
199 // carriers are subsequently clamped, collapsing that meridian onto u=0.
200 //
201 // Keep this deliberately sphere- and singularity-gated. Split the u values
202 // into maximal non-pole runs, unwrap each run independently, and leave the
203 // pole samples untouched. The existing interpolant degree stays unchanged;
204 // lowering it here would also change endpoint-tangent decisions made later
205 // while stitching the face loop.
206 let sphere_u_singular = if matches!(surface.analytic(), Some(AnalyticSurface::Sphere { .. })) {
207 let speeds = parameters
208 .iter()
209 .map(|parameter| {
210 surface
211 .deriv1(parameter[0], parameter[1])
212 .map(|(_, su, _)| su.length())
213 })
214 .collect::<Result<Vec<_>, _>>()?;
215 let reference = speeds.iter().copied().fold(0.0_f64, f64::max);
216 speeds
217 .into_iter()
218 // Uniform edge stations need not hit the pole exactly (ABC 5360
219 // bottoms out at v=5.5e-4). In this small conditioning band,
220 // longitude is already numerically arbitrary and must not connect
221 // the two meridian runs across the pole.
222 .map(|speed| speed <= reference * 1e-2)
223 .collect::<Vec<_>>()
224 } else {
225 vec![false; parameters.len()]
226 };
227 // Do not perturb ordinary pole-touching trims. The special path is needed
228 // only when a pole band is bracketed by nonsingular samples and its raw
229 // longitude changes by at least half a period: a true through-pole branch
230 // reset whose two meridian runs must be unwrapped independently.
231 // Prefix/suffix pole samples on normal sphere caps retain the legacy
232 // interpolation byte-for-byte. Include the exactly-half-period case so
233 // floating-point noise cannot decide which entire run is lifted (ABC 5603).
234 let has_interior_pole_crossing = sphere_u_singular
235 .iter()
236 .position(|singular| !singular)
237 .zip(sphere_u_singular.iter().rposition(|singular| !singular))
238 .is_some_and(|(first, last)| sphere_u_singular[first..=last].iter().any(|value| *value));
239 let period = (u1 - u0).abs();
240 let interior_pole_branch_reset = closed_u
241 && has_interior_pole_crossing
242 && parameters.windows(2).zip(sphere_u_singular.windows(2)).any(
243 |(parameter_pair, singular_pair)| {
244 (singular_pair[0] || singular_pair[1])
245 && (parameter_pair[1][0] - parameter_pair[0][0]).abs()
246 >= 0.5 * period - 1e-12 * period.max(1.0)
247 },
248 );
249 if closed_u {
250 let mut values = parameters.iter().map(|value| value[0]).collect::<Vec<_>>();
251 if interior_pole_branch_reset {
252 let mut start = 0;
253 while start < values.len() {
254 while start < values.len() && sphere_u_singular[start] {
255 start += 1;
256 }
257 let mut end = start;
258 while end < values.len() && !sphere_u_singular[end] {
259 end += 1;
260 }
261 unwrap_periodic(&mut values[start..end], u0, u1);
262 start = end;
263 }
264 } else {
265 unwrap_periodic(&mut values, u0, u1);
266 }
267 for (parameter, value) in parameters.iter_mut().zip(values) {
268 parameter[0] = value;
269 }
270 }
271 if closed_v {
272 let mut values = parameters.iter().map(|value| value[1]).collect::<Vec<_>>();
273 unwrap_periodic(&mut values, v0, v1);
274 for (parameter, value) in parameters.iter_mut().zip(values) {
275 parameter[1] = value;
276 }
277 }
278 // A closed direction WRAPS: an edge that straddles the seam has no single
279 // in-domain parameter run, so keep the unwrapped (possibly slightly
280 // out-of-domain) values and let the periodic evaluator wrap them — clamping
281 // them onto the seam boundary would collapse the straddling geometry (and,
282 // via the interpolant, spike neighbouring stations). This applies to
283 // analytic carriers too: a refitted cylinder can have stepped rims that
284 // cross its seam. evaluate_extended wraps every closed direction.
285 // Open directions must stay in-domain; their extension is a ruling.
286 let points = parameters
287 .into_iter()
288 .map(|parameter| {
289 let u = if closed_u {
290 parameter[0]
291 } else {
292 parameter[0].clamp(u0, u1)
293 };
294 let v = if closed_v {
295 parameter[1]
296 } else {
297 parameter[1].clamp(v0, v1)
298 };
299 Vec3::new(u, v, 0.0)
300 })
301 .collect::<Vec<_>>();
302 interpolate_curve(&points, 3usize.min(points.len() - 1), curve_parameters)
303}
304
305/// The 3D residual a pcurve's image must reach before `build_pcurve_on_surface`
306/// accepts it, and the sample ceiling it refines up to.
307///
308/// A trimmed face's boundary is only ever as accurate as these two numbers, and
309/// every mass property is an integral over that boundary — so this is the floor
310/// under area, volume and centroid for every curved trim in the kernel.
311///
312/// These were `min(1e-3, 1e-4 * scale)` and 160, where `scale` is
313/// `1 + ||curve midpoint||` — the DISTANCE FROM THE WORLD ORIGIN of the curve's
314/// midpoint, not the part's size. That expression pinned to an absolute 1e-3
315/// once the midpoint sat more than about nine units from the origin, so on an
316/// ordinary part the bar was 1e-3 and tracked nothing.
317///
318/// Measured on `offset-shell-box-bore-dished-face`, whose dished wall is a
319/// closed-form identity at 2468.431782123 (three independent routes agree). The
320/// floor is one of TWO errors there; the other is the refitted sphere carrier
321/// that `sphere_offset_surface` now builds in closed form, and before both moved
322/// they partly CANCELLED — which is why either alone reads as a regression:
323///
324/// ```text
325/// floor Box_O.S2 abs err relative gate total worst case
326/// 1e-3 2468.442270355 +1.049e-02 4.25e-06 (old)
327/// 1e-6 2468.431785429 +3.306e-06 1.34e-09 181.4 s 31.2 s
328/// 1e-7 2468.431782170 +4.746e-08 1.92e-11 218.5 s 32.1 s
329/// 1e-9 2468.431782103 -2.037e-08 -8.25e-12 305.5 s 46.8 s
330/// ```
331///
332/// 1e-7 is chosen: 70x the accuracy of 1e-6 for +20% gate time, where 1e-9 costs
333/// a further +40% for a 2.3x residual no oracle in this corpus could resolve.
334/// All four cells are one session on one binary, so the times are comparable to
335/// each other. Every case carrying a closed-form oracle moves TOWARD it and none
336/// away (`PushFaceTest` 339x, `PushFaceTest2` 275x); no case changes class at
337/// any floor.
338///
339/// A flat ABSOLUTE is what was measured, not what was designed. This codebase
340/// otherwise keys identity bands to part size (`KernelTolerances::heal_band`,
341/// the `1e-11*(1+scale)` blend family), and on a 3000 mm part 1e-7 is 3e-11
342/// relative — refinement will exhaust `MAX_PCURVE_SAMPLES` and return whatever
343/// it reached. Until 2026-09-12 it did so SILENTLY; now the fit's
344/// [`PcurveFitReport`] carries the residual actually achieved and a
345/// [`PcurveFitExit::SampleCeiling`] exit, and the owning operation's
346/// diagnostics count it (`pcurve.unmet_floor`). That is far better than the
347/// 1e-3 it replaces but it is not a size-aware bar; deriving one from the
348/// surface's own extent is separate, unmeasured work. Being absolute does remove this gate's dependence on
349/// distance from the origin as a side effect — a real translation-VARIANCE
350/// defect, the same anti-pattern `tolerance::merge_scale` records being removed
351/// elsewhere. `drop_tolerance` and `endpoint_tolerance` below still carry it.
352pub const PCURVE_REFINEMENT_TOLERANCE: f64 = 1e-7;
353
354/// Sample ceiling for a fit's FIRST refinement pass. 160 could not reach
355/// [`PCURVE_REFINEMENT_TOLERANCE`] on a curved trim of any length; 600 is the
356/// value every cell of the table above was measured at.
357///
358/// It BINDS. On `boolean_fuzz_corpus/25_cube_pierce_solid` the fitter stopped
359/// here 1.65e-3 of volume short of the converged answer (2000 and 4000 samples
360/// agree exactly). Raising it globally to 2000 was costed and rejected: +23%
361/// case-gate time, concentrated in three offset/fillet cases, and one case
362/// (`inbox-20260910-offset-shell-collapsed-fillet`) driven 26x outside its
363/// volume band. So it is not raised here: a fit that stops at this ceiling
364/// with a residual still worse than its edge curve's own distance from the
365/// surface re-enters refinement with [`PCURVE_RAISED_SAMPLES`]
366/// ([`refine_pcurve`]); one whose residual is already that gap stops here and
367/// says so ([`PcurveFitReport::off_surface`]), because no sample count can
368/// close it.
369const MAX_PCURVE_SAMPLES: usize = 600;
370
371/// Sample ceiling for the ONE raised pass a fit may take past
372/// [`MAX_PCURVE_SAMPLES`]. 2000 is the ceiling the convergence sweep measured
373/// on `25_cube_pierce_solid`: its 2000 and 4000 cells agree to every printed
374/// digit because refinement there stops itself near 1540 samples, so the cap
375/// is not what binds.
376const PCURVE_RAISED_SAMPLES: usize = 2000;
377
378/// What a pcurve fit ACHIEVED, returned beside the curve so a ceiling exit can
379/// never be mistaken for a met floor.
380///
381/// [`fit_pcurve_on_surface`] asks for [`PCURVE_REFINEMENT_TOLERANCE`] and stops
382/// at [`MAX_PCURVE_SAMPLES`] — or, where that ceiling bound and the floor is
383/// reachable, at [`PCURVE_RAISED_SAMPLES`]. Until 2026-09-12 a trim that ran
384/// out of samples came back exactly like one that met the floor; on
385/// `boolean_fuzz_corpus/25_cube_pierce_solid` that silence hid a 1.65e-3 volume
386/// error. Now every fit says which it was: `residual` is measured on the curve
387/// that is actually returned and `exit` names why refinement stopped.
388///
389/// This is a MEASUREMENT taken at a construction, not a band.
390#[derive(Clone, Copy, Debug, PartialEq)]
391pub struct PcurveFitReport {
392 /// Largest 3D distance between the returned pcurve's image on the surface
393 /// and the edge curve. On a [`PcurveFitExit::Converged`] exit whose final
394 /// round probed every span this is that round's maximum — the refinement's
395 /// own acceptance test. On every other exit, including a converged round
396 /// that skipped spans narrower than 1e-3 of the parameter range, it is a
397 /// sweep over EVERY span of the returned curve.
398 pub residual: f64,
399 /// The residual the refinement was asked to reach.
400 pub floor: f64,
401 /// Interpolation samples in the returned curve.
402 pub samples: usize,
403 /// Why refinement stopped.
404 pub exit: PcurveFitExit,
405 /// Largest distance from the edge curve to the surface at every station
406 /// the fit inverted — the part of `residual` no pcurve can remove, since a
407 /// pcurve's image lies ON the surface. Zero for an affine carrier. A value
408 /// above `floor` says the floor is unreachable for this input; a ceiling
409 /// exit is retried only while `residual` exceeds it by more than the floor,
410 /// i.e. while the returned curve is worse than its input.
411 pub off_surface: f64,
412 /// Whether the fit re-entered refinement with [`PCURVE_RAISED_SAMPLES`]
413 /// after [`MAX_PCURVE_SAMPLES`] bound with the floor unmet.
414 pub raised: bool,
415}
416
417impl PcurveFitReport {
418 /// Whether the returned curve reaches the floor it was asked for.
419 pub fn met_floor(&self) -> bool {
420 self.residual <= self.floor
421 }
422
423 /// Whether the returned curve is on the ACCEPTANCE BAR: the floor it was
424 /// asked for, plus the stations' own standoff from the carrier.
425 ///
426 /// A pcurve's image lies on the carrier, so at a station standing `g` off
427 /// it no image can come closer than `g`; a fit whose residual is within
428 /// the floor of `off_surface` is as good as the input allows, and one over
429 /// it is worse than its input — the same condition [`refine_pcurve`] uses
430 /// to decide that a raise can still buy accuracy. This is the bar every
431 /// station builder refuses over ([`build_pcurve_on_surface_stations`]).
432 /// It is blind to sideways error quadratically (a station tracked `d`
433 /// aside of the projection of a point `g` off the surface reads only
434 /// `d²/(2g)` further away), which is stated rather than hidden: on the
435 /// import corpus the stations that stand off their carriers are the
436 /// FILE's curves, and the bar there is the file's own number.
437 pub fn on_bar(&self) -> bool {
438 self.residual <= self.floor + self.off_surface
439 }
440}
441
442/// Why a pcurve refinement stopped. Only [`PcurveFitExit::Converged`] means the
443/// floor was met; every other variant is a BUDGET exit, and the report's
444/// residual was measured on the returned curve rather than assumed.
445#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
446pub enum PcurveFitExit {
447 /// A probe round found every station within the floor. An affine carrier
448 /// maps exactly and reports this with a zero residual.
449 Converged,
450 /// The sample ceiling bound before the floor was met: [`MAX_PCURVE_SAMPLES`]
451 /// when the raise was declined (the residual is already explained by
452 /// [`PcurveFitReport::off_surface`]), [`PCURVE_RAISED_SAMPLES`] when it was
453 /// taken and bound too.
454 SampleCeiling,
455 /// Every refinement round was spent and the last one still inserted
456 /// samples, so the curve it built was never re-checked by the loop.
457 RoundBudget,
458 /// A round found stations over the floor but could improve none of them:
459 /// the edge point could not be inverted inside the drop band, or (in the
460 /// raised pass) the pcurve already tracks the surface's nearest point to
461 /// within the floor. Either way the edge curve itself sits off the surface
462 /// there and no pcurve can close that gap; the residual reports how far.
463 Stalled,
464 /// The last probe round found every span it PROBED within the floor, but
465 /// skipped spans narrower than the loop's own resolution floor — 1e-3 of
466 /// the parameter range in [`refine_pcurve`], [`SPAN_FLOOR`] (1e-6) in
467 /// [`fit_pcurve_on_surface_stations`] — and a sweep over those found the
468 /// floor unmet.
469 /// Until 2026-09-12 this exit was reported as `Converged` with the probed
470 /// spans' maximum: on `25_cube_pierce_solid` a 1539-sample fit with 1537
471 /// narrow spans read 7e-8 that way while every span read 1.9e-4.
472 SpanFloor,
473}
474
475/// A fitted pcurve with what the fit achieved.
476#[derive(Clone, Debug)]
477pub struct PcurveFit {
478 pub curve: NurbsCurve,
479 pub report: PcurveFitReport,
480}
481
482/// Per-operation tally of pcurve fits and how many missed their floor.
483///
484/// [`build_pcurve_on_surface`] and its marched twin are called from some forty
485/// sites, most of which have no diagnostics record to write into. Rather than
486/// thread one through every layer, an operation that returns
487/// [`KernelDiagnostics`] opens a [`PcurveFitScope`]; every fit made on that
488/// thread while the scope is open is tallied here, and closing the scope hands
489/// the tally back for [`PcurveFitLedger::report_into`]. This is the shape the
490/// boolean's `CONFORMANCE_REPAIRS` counter already uses. With no scope open,
491/// recording is a no-op.
492///
493/// Scopes nest: CLOSING a child folds it into its parent, so an offset shell
494/// sees the fits of the booleans it ran. A scope that is DROPPED without being
495/// closed is discarded, not folded — a boolean attempt that refused (and may be
496/// retried under perturbation) must not leave its fits on the caller's tally.
497///
498/// Thread-local by construction: a fit made on a rayon worker (the `parallel`
499/// feature's STEP body import) is not seen by a scope on the calling thread. No
500/// diagnostics-returning operation builds pcurves off-thread today.
501#[derive(Clone, Copy, Debug, Default, PartialEq)]
502pub struct PcurveFitLedger {
503 /// Fits recorded, exact affine ones included.
504 pub fits: u64,
505 /// Fits whose returned curve did not reach its floor.
506 pub unmet: u64,
507 /// Unmet fits that stopped at a sample ceiling.
508 pub sample_ceiling: u64,
509 /// Unmet fits that spent every refinement round.
510 pub round_budget: u64,
511 /// Unmet fits that could insert nothing.
512 pub stalled: u64,
513 /// Unmet fits whose remaining over-floor spans were all below the loop's
514 /// 1e-3 span floor.
515 pub span_floor: u64,
516 /// Largest residual among the unmet fits; zero when there are none.
517 pub worst_unmet_residual: f64,
518 /// Fits that re-entered refinement with [`PCURVE_RAISED_SAMPLES`], met or
519 /// not.
520 pub raised: u64,
521 /// Unmet ceiling exits that were NOT raised because the residual is
522 /// already within the floor of the edge curve's own gap from the surface.
523 pub raise_declined: u64,
524 /// Largest [`PcurveFitReport::off_surface`] over every fit.
525 pub worst_off_surface: f64,
526}
527
528impl PcurveFitLedger {
529 fn record(&mut self, report: &PcurveFitReport) {
530 self.fits += 1;
531 if report.raised {
532 self.raised += 1;
533 }
534 self.worst_off_surface = self.worst_off_surface.max(report.off_surface);
535 if report.met_floor() {
536 return;
537 }
538 self.unmet += 1;
539 match report.exit {
540 PcurveFitExit::Converged => {}
541 PcurveFitExit::SampleCeiling => {
542 self.sample_ceiling += 1;
543 if !report.raised {
544 self.raise_declined += 1;
545 }
546 }
547 PcurveFitExit::RoundBudget => self.round_budget += 1,
548 PcurveFitExit::Stalled => self.stalled += 1,
549 PcurveFitExit::SpanFloor => self.span_floor += 1,
550 }
551 self.worst_unmet_residual = self.worst_unmet_residual.max(report.residual);
552 }
553
554 fn fold(&mut self, child: &PcurveFitLedger) {
555 self.fits += child.fits;
556 self.unmet += child.unmet;
557 self.sample_ceiling += child.sample_ceiling;
558 self.round_budget += child.round_budget;
559 self.stalled += child.stalled;
560 self.span_floor += child.span_floor;
561 self.worst_unmet_residual = self.worst_unmet_residual.max(child.worst_unmet_residual);
562 self.raised += child.raised;
563 self.raise_declined += child.raise_declined;
564 self.worst_off_surface = self.worst_off_surface.max(child.worst_off_surface);
565 }
566
567 /// Write the tally into an operation's diagnostics: the `pcurve.*`
568 /// counters and measurements always, plus one `pcurve.unmet_floor` event at
569 /// [`DiagnosticSeverity::Degraded`] when any fit missed its floor. Degraded,
570 /// not Error: the result is still the kernel's best construction and stays
571 /// shippable, but it no longer claims the accuracy its floor states.
572 pub fn report_into(&self, diagnostics: &mut KernelDiagnostics) {
573 diagnostics.count_n("pcurve.fits", self.fits);
574 diagnostics.count_n("pcurve.unmet_floor", self.unmet);
575 diagnostics.count_n("pcurve.exit.sample_ceiling", self.sample_ceiling);
576 diagnostics.count_n("pcurve.exit.round_budget", self.round_budget);
577 diagnostics.count_n("pcurve.exit.stalled", self.stalled);
578 diagnostics.count_n("pcurve.exit.span_floor", self.span_floor);
579 diagnostics.count_n("pcurve.budget_raised", self.raised);
580 diagnostics.count_n("pcurve.budget_raise_declined", self.raise_declined);
581 diagnostics.measure_max("pcurve.worst_unmet_residual", self.worst_unmet_residual);
582 diagnostics.measure_max("pcurve.worst_off_surface", self.worst_off_surface);
583 if self.unmet > 0 {
584 diagnostics.event(
585 DiagnosticSeverity::Degraded,
586 KernelStage::Refine,
587 "pcurve.unmet_floor",
588 format!(
589 "{} of {} pcurve fits did not reach the {:.0e} floor \
590 (sample ceiling {}, round budget {}, stalled {}, span floor {}); \
591 worst residual {:.3e}; budget raised on {}, declined on {}; \
592 edge curves sit off their surfaces by up to {:.3e}",
593 self.unmet,
594 self.fits,
595 PCURVE_REFINEMENT_TOLERANCE,
596 self.sample_ceiling,
597 self.round_budget,
598 self.stalled,
599 self.span_floor,
600 self.worst_unmet_residual,
601 self.raised,
602 self.raise_declined,
603 self.worst_off_surface,
604 ),
605 );
606 }
607 }
608}
609
610thread_local! {
611 /// The open [`PcurveFitScope`]s on this thread, innermost last.
612 static PCURVE_FIT_SCOPES: std::cell::RefCell<Vec<PcurveFitLedger>> =
613 const { std::cell::RefCell::new(Vec::new()) };
614}
615
616/// An open tally of the pcurve fits made on this thread — see
617/// [`PcurveFitLedger`]. Open it where an operation starts, [`close`] it where
618/// the operation returns its diagnostics; a `?` that unwinds past it drops the
619/// tally.
620///
621/// [`close`]: PcurveFitScope::close
622pub struct PcurveFitScope {
623 /// Not `Send`: the scope must close on the thread that opened it.
624 _thread_bound: std::marker::PhantomData<*const ()>,
625}
626
627impl PcurveFitScope {
628 pub fn open() -> Self {
629 PCURVE_FIT_SCOPES.with(|scopes| scopes.borrow_mut().push(PcurveFitLedger::default()));
630 Self {
631 _thread_bound: std::marker::PhantomData,
632 }
633 }
634
635 /// Take the tally, folding it into the enclosing scope if there is one.
636 pub fn close(self) -> PcurveFitLedger {
637 let ledger = PCURVE_FIT_SCOPES.with(|scopes| {
638 let mut scopes = scopes.borrow_mut();
639 let ledger = scopes.pop().unwrap_or_default();
640 if let Some(parent) = scopes.last_mut() {
641 parent.fold(&ledger);
642 }
643 ledger
644 });
645 std::mem::forget(self);
646 ledger
647 }
648}
649
650impl Drop for PcurveFitScope {
651 fn drop(&mut self) {
652 PCURVE_FIT_SCOPES.with(|scopes| {
653 scopes.borrow_mut().pop();
654 });
655 }
656}
657
658fn record_fit(report: &PcurveFitReport) {
659 // Per-fit trace for the convergence probes (`trim_floor_sweep_probe` and
660 // its offset-shell sibling): one line per fit with the whole report, the
661 // way `BREP_DEBUG_PCURVE` traces an endpoint failure.
662 if std::env::var_os("BREP_DEBUG_PCURVE_FIT").is_some() {
663 eprintln!("PCURVE-FIT {report:?}");
664 }
665 PCURVE_FIT_SCOPES.with(|scopes| {
666 if let Some(open) = scopes.borrow_mut().last_mut() {
667 open.record(report);
668 }
669 });
670}
671
672/// Largest 3D distance between `pcurve`'s image and `curve` at the
673/// quarter-points of every span of `parameters` — the stations refinement
674/// probes, over EVERY span. Evaluations only; nothing is inverted. The sweep
675/// measures, it does not decide.
676fn probe_residual(
677 surface: &NurbsSurface,
678 curve: &NurbsCurve,
679 pcurve: &NurbsCurve,
680 parameters: &[f64],
681 [t0, t1]: [f64; 2],
682 [u0, u1]: [f64; 2],
683 [v0, v1]: [f64; 2],
684) -> Result<f64, String> {
685 let mut worst = 0.0_f64;
686 for span in parameters.windows(2) {
687 for local in [0.25, 0.5, 0.75] {
688 let fraction = span[0] + (span[1] - span[0]) * local;
689 let parameter = pcurve.evaluate(fraction)?;
690 let on_surface =
691 surface.evaluate(parameter.x.clamp(u0, u1), parameter.y.clamp(v0, v1))?;
692 let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
693 worst = worst.max(on_surface.sub(on_curve).length());
694 }
695 }
696 Ok(worst)
697}
698
699/// What one call to [`refine_pcurve`] achieved, beside the curve it left in
700/// place.
701struct Refinement {
702 exit: PcurveFitExit,
703 residual: f64,
704 off_surface: f64,
705 raised: bool,
706}
707
708/// Adaptive 3D-residual refinement of an interpolant, with the PER-TRIM sample
709/// budget. Shared by [`fit_pcurve_on_surface`] (global inversion) and
710/// [`fit_pcurve_on_surface_marched`] (seeded projection); `insert` is the one
711/// step that differs — given a probe station's fraction, the current pcurve's
712/// parameter there and the edge point, it returns the surface parameter to
713/// interpolate through (or `None` to decline the station) and the distance
714/// from the edge point to the surface at that station, its GAP.
715///
716/// **First pass.** Four rounds at [`MAX_PCURVE_SAMPLES`], each probing the
717/// quarter-points of every span wider than 1e-3 of the parameter range and
718/// inserting the stations over `floor`. Byte-for-byte the pre-2026-09-12
719/// loop; every exit is classified ([`PcurveFitExit`]) and the residual is
720/// measured on the RETURNED curve — over every span, unless the final round
721/// probed every span, in which case that round's maximum already is.
722///
723/// **The raise.** A [`PcurveFitExit::SampleCeiling`] exit re-enters the four
724/// rounds ONCE with [`PCURVE_RAISED_SAMPLES`], continuing from the samples it
725/// has, when the residual exceeds `off_surface` by more than the floor — the
726/// returned curve is worse than the edge curve's own distance from the
727/// surface, so more samples CAN improve it. Measured on `25_cube_pierce_solid`
728/// (2026-09-12): the edge curves there sit off their surfaces by up to 1.9e-4,
729/// four of the eight ceiling exits already read their gap to four digits, and
730/// raising those spent 900 inversions each for no change in the swept
731/// residual. The raised pass therefore inserts only where the pcurve's surface
732/// point is further than the floor from the PROJECTION of the edge point — its
733/// tracking error, which the inversion just computed: it stops when the pcurve
734/// tracks the surface's nearest point to the floor, which is the best any
735/// pcurve can do, and reports [`PcurveFitExit::Stalled`] with the deviation it
736/// actually reached when that is still over the floor. The limit of that pass
737/// as the floor tightens is the projected edge curve itself, so a volume built
738/// on it can be shown to converge; the deviation test alone cannot say that on
739/// off-surface input. Grow-once, capped, deterministic.
740#[allow(clippy::too_many_arguments)]
741fn refine_pcurve(
742 surface: &NurbsSurface,
743 curve: &NurbsCurve,
744 domain: [f64; 2],
745 u_domain: [f64; 2],
746 v_domain: [f64; 2],
747 floor: f64,
748 parameters: &mut Vec<f64>,
749 raw: &mut Vec<[f64; 2]>,
750 pcurve: &mut NurbsCurve,
751 off_surface: f64,
752 mut insert: impl FnMut(f64, Vec3, Vec3) -> Result<(Option<[f64; 2]>, f64), String>,
753) -> Result<Refinement, String> {
754 let mut best = refine_pcurve_pass(surface, curve, domain, u_domain, v_domain,
755 floor, parameters, raw, pcurve, off_surface, false, 1.0, &mut insert)?;
756 if best.off_surface > floor { return Ok(best); }
757 // Each target starts from the same baseline station set: retaining the
758 // coarser target's inserts can exhaust the cap before the finer target
759 // places its own error-directed stations. Keep the best converged rung.
760 let baseline_parameters = parameters.clone();
761 let baseline_raw = raw.clone();
762 let baseline_curve = pcurve.clone();
763 let baseline_off_surface = best.off_surface;
764 for fraction in [0.1, 0.01] {
765 if best.residual <= floor * fraction { continue; }
766 let mut refined_parameters = baseline_parameters.clone();
767 let mut refined_raw = baseline_raw.clone();
768 let mut refined_curve = baseline_curve.clone();
769 let candidate = refine_pcurve_pass(surface, curve, domain, u_domain, v_domain,
770 floor, &mut refined_parameters, &mut refined_raw, &mut refined_curve,
771 baseline_off_surface, true, fraction, &mut insert);
772 match candidate {
773 Ok(candidate) if candidate.exit == PcurveFitExit::Converged
774 && candidate.residual <= floor && candidate.residual < best.residual => {
775 *parameters = refined_parameters;
776 *raw = refined_raw;
777 *pcurve = refined_curve;
778 best = candidate;
779 }
780 _ => break,
781 }
782 }
783 Ok(best)
784}
785
786fn refine_pcurve_pass(
787 surface: &NurbsSurface,
788 curve: &NurbsCurve,
789 [t0, t1]: [f64; 2],
790 [u0, u1]: [f64; 2],
791 [v0, v1]: [f64; 2],
792 floor: f64,
793 parameters: &mut Vec<f64>,
794 raw: &mut Vec<[f64; 2]>,
795 pcurve: &mut NurbsCurve,
796 mut off_surface: f64,
797 track_projection: bool,
798 tracking_fraction: f64,
799 mut insert: impl FnMut(f64, Vec3, Vec3) -> Result<(Option<[f64; 2]>, f64), String>,
800) -> Result<Refinement, String> {
801 let tracking_floor = if track_projection { floor * tracking_fraction } else { floor };
802 let mut max_samples = MAX_PCURVE_SAMPLES;
803 let mut raised = false;
804 loop {
805 // Every way out of this loop is CLASSIFIED (`PcurveFitExit`); a budget
806 // exit returns the curve it built, but says so. Falling off the end
807 // after a fourth round of inserts is the round-budget exit.
808 let mut exit = PcurveFitExit::RoundBudget;
809 let mut residual = None;
810 for _ in 0..4 {
811 if parameters.len() >= max_samples {
812 exit = PcurveFitExit::SampleCeiling;
813 break;
814 }
815 let mut inserts = Vec::new();
816 let mut round_max = 0.0_f64;
817 let mut truncated = false;
818 let mut skipped_narrow = false;
819 for index in 0..parameters.len() - 1 {
820 if parameters.len() + inserts.len() >= max_samples {
821 truncated = true;
822 break;
823 }
824 let start = parameters[index];
825 let end = parameters[index + 1];
826 if end - start < if track_projection { SPAN_FLOOR } else { 1e-3 } {
827 skipped_narrow = true;
828 continue;
829 }
830 for local_fraction in [0.25, 0.5, 0.75] {
831 if parameters.len() + inserts.len() >= max_samples {
832 truncated = true;
833 break;
834 }
835 let fraction = start + (end - start) * local_fraction;
836 let parameter = pcurve.evaluate(fraction)?;
837 let on_surface =
838 surface.evaluate(parameter.x.clamp(u0, u1), parameter.y.clamp(v0, v1))?;
839 let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
840 let deviation = on_surface.sub(on_curve).length();
841 round_max = round_max.max(deviation);
842 if deviation <= tracking_floor {
843 continue;
844 }
845 let (surface_parameter, gap) = insert(fraction, parameter, on_curve)?;
846 off_surface = off_surface.max(gap);
847 if let Some(surface_parameter) = surface_parameter {
848 // The raised pass stops where the pcurve TRACKS the
849 // edge curve's projection to the floor — the best any
850 // pcurve can do at this station. Not `deviation - gap`:
851 // a surface point at tangential distance d from the
852 // projection of a point g off the surface is only
853 // d^2/(2g) further from it, so the deviation test is
854 // quadratically blind to sideways error on off-surface
855 // input (6e-6 of slop at g = 1.9e-4, floor 1e-7).
856 if raised || track_projection {
857 let projected =
858 surface.evaluate(surface_parameter[0], surface_parameter[1])?;
859 if on_surface.sub(projected).length() <= tracking_floor {
860 continue;
861 }
862 }
863 inserts.push((index + 1, fraction, surface_parameter));
864 }
865 }
866 }
867 if inserts.is_empty() {
868 // Nothing to insert: every probed station is within the floor
869 // (converged — unless a narrow span was skipped, in which case
870 // the sweep below decides), or the ones over it could not be
871 // improved (stalled — the edge curve is off the surface there).
872 exit = if truncated {
873 PcurveFitExit::SampleCeiling
874 } else if round_max > floor {
875 PcurveFitExit::Stalled
876 } else if skipped_narrow {
877 PcurveFitExit::SpanFloor
878 } else {
879 residual = Some(round_max);
880 PcurveFitExit::Converged
881 };
882 break;
883 }
884 for (at, fraction, surface_parameter) in inserts.into_iter().rev() {
885 parameters.insert(at, fraction);
886 raw.insert(at, surface_parameter);
887 }
888 *pcurve = build_interpolant(surface, raw, parameters)?;
889 if truncated {
890 exit = PcurveFitExit::SampleCeiling;
891 break;
892 }
893 }
894 let residual = match residual {
895 Some(measured) => measured,
896 // A budget exit never re-checked the curve it is returning, and a
897 // converged round that skipped narrow spans never checked those:
898 // measure every span.
899 None => probe_residual(
900 surface,
901 curve,
902 pcurve,
903 parameters,
904 [t0, t1],
905 [u0, u1],
906 [v0, v1],
907 )?,
908 };
909 if exit == PcurveFitExit::SpanFloor && residual <= floor {
910 exit = PcurveFitExit::Converged;
911 }
912 // The per-trim budget decision. Deterministic, grow-once, capped (I3):
913 // the ceiling bound and the returned curve is worse than the edge
914 // curve's own gap from the surface by more than the floor, so samples
915 // can still buy accuracy. A curve already at its gap stops here —
916 // reported, not retried.
917 if exit == PcurveFitExit::SampleCeiling && !raised && residual > off_surface + floor {
918 raised = true;
919 max_samples = PCURVE_RAISED_SAMPLES;
920 continue;
921 }
922 return Ok(Refinement {
923 exit,
924 residual,
925 off_surface,
926 raised,
927 });
928 }
929}
930
931/// The exact inverse of an affine carrier's parameterization.
932///
933/// An affine patch (2x2 net, degree 1 both ways, equal weights) maps `uv` to 3D
934/// by an affine map, so the inverse — a 3D point to the `uv` of its orthogonal
935/// projection — is affine too. An affine map commutes with the rational basis:
936/// sending a curve's control points through it and keeping the weights
937/// produces the EXACT image of that curve in parameter space, not a fit of it.
938struct AffineInversion {
939 origin: Vec3,
940 du: Vec3,
941 dv: Vec3,
942 u0: f64,
943 v0: f64,
944 uu: f64,
945 uv: f64,
946 vv: f64,
947 determinant: f64,
948}
949
950impl AffineInversion {
951 /// `None` when `surface` is not affine, or when its two directions are
952 /// parallel enough that the inverse is not determined. The caller decides
953 /// what that means: a refusal on the whole-curve lane, the sampled lane on
954 /// the range lane.
955 fn of(surface: &NurbsSurface) -> Result<Option<Self>, String> {
956 if !surface.is_affine()? {
957 return Ok(None);
958 }
959 let ([u0, _], [v0, _]) = surface_domains(surface)?;
960 let (origin, du, dv) = surface.deriv1(u0, v0)?;
961 let uu = du.dot(du);
962 let uv = du.dot(dv);
963 let vv = dv.dot(dv);
964 let determinant = uu * vv - uv * uv;
965 if determinant.abs() <= 1e-18 {
966 return Ok(None);
967 }
968 Ok(Some(Self {
969 origin,
970 du,
971 dv,
972 u0,
973 v0,
974 uu,
975 uv,
976 vv,
977 determinant,
978 }))
979 }
980
981 fn uv_of(&self, point: Vec3) -> [f64; 2] {
982 let delta = point.sub(self.origin);
983 let along_u = delta.dot(self.du);
984 let along_v = delta.dot(self.dv);
985 [
986 self.u0 + (along_u * self.vv - along_v * self.uv) / self.determinant,
987 self.v0 + (along_v * self.uu - along_u * self.uv) / self.determinant,
988 ]
989 }
990
991 /// The exact image of `curve` in this carrier's parameter space, with the
992 /// curve's own degree, knots and weights.
993 fn image_of(&self, curve: &NurbsCurve) -> Result<NurbsCurve, String> {
994 let control_points = curve
995 .control_points
996 .iter()
997 .map(|control| {
998 let [u, v] = self.uv_of(control.point()?);
999 Ok(Vec4::from_point(Vec3::new(u, v, 0.0), control.w))
1000 })
1001 .collect::<Result<Vec<_>, String>>()?;
1002 NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
1003 }
1004}
1005
1006/// Build a parameter-space curve for a 3D curve lying on a surface, and say
1007/// what the fit achieved.
1008///
1009/// This mirrors the reference imprint implementation: affine patches map
1010/// homogeneous control points exactly; general patches use sampled inversion,
1011/// periodic seam unwrapping, and adaptive 3D residual refinement. The report
1012/// beside the curve carries the residual the RETURNED curve reaches and why
1013/// refinement stopped ([`PcurveFitReport`]); the curve itself is what
1014/// [`build_pcurve_on_surface`] has always returned.
1015pub fn fit_pcurve_on_surface(
1016 surface: &NurbsSurface,
1017 curve: &NurbsCurve,
1018) -> Result<PcurveFit, String> {
1019 let [t0, t1] = curve.domain()?;
1020 if surface.is_affine()? {
1021 let Some(inversion) = AffineInversion::of(surface)? else {
1022 return Err("build_pcurve_on_surface: singular affine parameterization".into());
1023 };
1024 let pcurve = inversion.image_of(curve)?;
1025 return Ok(PcurveFit {
1026 report: PcurveFitReport {
1027 residual: 0.0,
1028 floor: PCURVE_REFINEMENT_TOLERANCE,
1029 samples: pcurve.control_points.len(),
1030 exit: PcurveFitExit::Converged,
1031 off_surface: 0.0,
1032 raised: false,
1033 },
1034 curve: pcurve,
1035 });
1036 }
1037
1038 let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
1039 let drop_tolerance = 1e-4 * scale;
1040 let endpoint_tolerance = 1e-3 * scale;
1041 let refinement_tolerance = PCURVE_REFINEMENT_TOLERANCE;
1042 let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
1043 let invert =
1044 |fraction: f64| invert_checked(surface, curve.evaluate(t0 + (t1 - t0) * fraction)?);
1045
1046 let mut parameters = Vec::with_capacity(25);
1047 let mut raw_surface_parameters = Vec::with_capacity(25);
1048 // The edge curve's own distance from the surface at every station inverted,
1049 // accepted or not: the floor below which no pcurve can bring the residual.
1050 let mut off_surface = 0.0_f64;
1051 for index in 0..=24 {
1052 let fraction = index as f64 / 24.0;
1053 let (parameter, distance) = invert(fraction)?;
1054 off_surface = off_surface.max(distance);
1055 if distance > drop_tolerance && index != 0 && index != 24 {
1056 continue;
1057 }
1058 if distance > endpoint_tolerance {
1059 if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
1060 let p3 = curve.evaluate(t0 + (t1 - t0) * fraction).ok();
1061 let p0 = curve.evaluate(t0).ok();
1062 let p1 = curve.evaluate(t1).ok();
1063 eprintln!(
1064 "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={:?}",
1065 surface.evaluate(parameter[0], parameter[1]).ok()
1066 );
1067 }
1068 return Err(format!(
1069 "build_pcurve_on_surface: endpoint projection failed (distance={distance})"
1070 ));
1071 }
1072 parameters.push(fraction);
1073 raw_surface_parameters.push(parameter);
1074 }
1075
1076 let mut pcurve = build_interpolant(surface, &raw_surface_parameters, ¶meters)?;
1077 let refinement = refine_pcurve(
1078 surface,
1079 curve,
1080 [t0, t1],
1081 [u0, u1],
1082 [v0, v1],
1083 refinement_tolerance,
1084 &mut parameters,
1085 &mut raw_surface_parameters,
1086 &mut pcurve,
1087 off_surface,
1088 |fraction, _parameter, _on_curve| {
1089 let (surface_parameter, distance) = invert(fraction)?;
1090 Ok((
1091 (distance <= drop_tolerance).then_some(surface_parameter),
1092 distance,
1093 ))
1094 },
1095 )?;
1096 Ok(PcurveFit {
1097 curve: pcurve,
1098 report: PcurveFitReport {
1099 residual: refinement.residual,
1100 floor: refinement_tolerance,
1101 samples: parameters.len(),
1102 exit: refinement.exit,
1103 off_surface: refinement.off_surface,
1104 raised: refinement.raised,
1105 },
1106 })
1107}
1108
1109/// [`fit_pcurve_on_surface`] for the callers that only need the curve. The
1110/// report is not dropped: it is tallied on the open [`PcurveFitScope`], if any,
1111/// so the operation that owns this fit can say in its diagnostics whether every
1112/// trim reached the floor.
1113pub fn build_pcurve_on_surface(
1114 surface: &NurbsSurface,
1115 curve: &NurbsCurve,
1116) -> Result<NurbsCurve, String> {
1117 let fit = fit_pcurve_on_surface(surface, curve)?;
1118 record_fit(&fit.report);
1119 Ok(fit.curve)
1120}
1121
1122/// SECTION-PCURVE SEEDED MARCH (t222: cone × ABC 00000327, non-integral genus).
1123///
1124/// `build_pcurve_on_surface` inverts every sample by GLOBAL closest point. Where
1125/// the carrier surface is COMPRESSED / self-overlapping along the row the
1126/// section rides, the global search aliases interior samples onto a DISTANT
1127/// preimage sheet, folding the pcurve out past the trim boundary and re-crossing
1128/// it. The arrangement then splits the shared section at that phantom crossing,
1129/// while the mate face (whose carrier is not folded there) keeps the section
1130/// whole — so the two operands' fragments disagree and the section strands
1131/// one-use (non-integral genus). Ground truth for t222: 00000327 face 496's
1132/// v≈0.7875 row maps u∈[0.55,0.81] into a ~0.18mm neighbourhood of the section
1133/// endpoint A; the global pcurve for piece 9 folded out to u=0.806 and back,
1134/// re-crossing trim edge 349 at v≈0.784, 0.003 below the corner vertex A.
1135///
1136/// This marches the interior inversions with each seeded from the PREVIOUS
1137/// accepted parameters, so a marched sample
1138/// adopts the nearby branch instead of the momentarily-closest distant sheet.
1139/// The two ENDPOINT samples keep the deterministic global inversion (they are
1140/// the piece's shared junction vertices — same reasoning as
1141/// `repair_branch_jumps`). Additive + fail-soft: the marched chain is used ONLY
1142/// when it CLOSES onto the global far endpoint (its last interior sample is
1143/// parameter-adjacent to it, measured against the chain's own median step);
1144/// otherwise the plain global build is returned byte-for-byte. A single-preimage
1145/// carrier (the cone side of the same section) marches to the same samples the
1146/// global search already had. Use the analytic-aware seeded projector for both
1147/// the march and inserted stations: raw Newton stops at 1e-7 even on an exact
1148/// cylinder, introducing avoidable trim error. General folded carriers retain
1149/// their local seeded Newton path; analytic carriers take their closed form.
1150///
1151/// Escape hatch: `BREP_SECTION_PCURVE_MARCH=0`.
1152pub fn fit_pcurve_on_surface_marched(
1153 surface: &NurbsSurface,
1154 curve: &NurbsCurve,
1155) -> Result<PcurveFit, String> {
1156 let global = fit_pcurve_on_surface(surface, curve)?;
1157 if std::env::var("BREP_SECTION_PCURVE_MARCH").as_deref() == Ok("0") || surface.is_affine()? {
1158 return Ok(global);
1159 }
1160 let [t0, t1] = curve.domain()?;
1161 // A closed (ring) section has no distinct endpoints to anchor the march;
1162 // leave it to the existing path (closed rings are handled elsewhere).
1163 if curve.evaluate(t0)?.sub(curve.evaluate(t1)?).length() <= 1e-6 {
1164 return Ok(global);
1165 }
1166 const SAMPLES: usize = 24;
1167 let fractions: Vec<f64> = (0..=SAMPLES).map(|i| i as f64 / SAMPLES as f64).collect();
1168 let edge: Vec<Vec3> = fractions
1169 .iter()
1170 .map(|fraction| curve.evaluate(t0 + (t1 - t0) * fraction))
1171 .collect::<Result<Vec<_>, _>>()?;
1172 let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
1173 let endpoint_tolerance = 1e-3 * scale;
1174 let (first, first_distance) = invert_checked(surface, edge[0])?;
1175 let (last, last_distance) = invert_checked(surface, edge[SAMPLES])?;
1176 if first_distance > endpoint_tolerance || last_distance > endpoint_tolerance {
1177 return Ok(global);
1178 }
1179 // The edge curve's distance from the surface at every station this chain
1180 // inverts — on the MARCHED branch, which is the one being fitted.
1181 let mut off_surface = first_distance.max(last_distance);
1182 let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
1183 let u_span = (u1 - u0).abs().max(EPSILON);
1184 let v_span = (v1 - v0).abs().max(EPSILON);
1185 let (closed_u, closed_v) = surface_closedness(surface)?;
1186 let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
1187 let mut du = a[0] - b[0];
1188 if closed_u {
1189 while du > 0.5 * u_span {
1190 du -= u_span;
1191 }
1192 while du < -0.5 * u_span {
1193 du += u_span;
1194 }
1195 }
1196 let mut dv = a[1] - b[1];
1197 if closed_v {
1198 while dv > 0.5 * v_span {
1199 dv -= v_span;
1200 }
1201 while dv < -0.5 * v_span {
1202 dv += v_span;
1203 }
1204 }
1205 ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
1206 };
1207 // Forward seeded march over the interior samples.
1208 let mut raw = vec![first];
1209 let mut previous = first;
1210 for index in 1..SAMPLES {
1211 let seeded =
1212 crate::projection::project_point_to_surface_from_seed(surface, edge[index], previous)?;
1213 let (_, global_distance) = invert_checked(surface, edge[index])?;
1214 // The seeded footpoint must still sit on the surface — as tight as the
1215 // fit band or the global answer already had it. If it fell off (the
1216 // seed led Newton into a valley), abandon the march and keep global.
1217 if seeded.distance > 4.0 * global_distance + endpoint_tolerance {
1218 return Ok(global);
1219 }
1220 off_surface = off_surface.max(seeded.distance);
1221 previous = [seeded.u, seeded.v];
1222 raw.push(previous);
1223 }
1224 raw.push(last);
1225 // CLOSURE GATE: the last marched interior sample must be parameter-adjacent
1226 // to the global far endpoint — the march stayed on ONE branch the whole way.
1227 // Measure the endpoint gap against the chain's OWN median step so a genuine
1228 // long edge is not rejected while a sheet-hop (which leaves a big gap to the
1229 // endpoint) is.
1230 let mut steps: Vec<f64> = (1..raw.len())
1231 .map(|i| norm_step(raw[i], raw[i - 1]))
1232 .collect();
1233 let endpoint_gap = steps.pop().unwrap_or(0.0);
1234 steps.sort_by(f64::total_cmp);
1235 let median = steps
1236 .get(steps.len() / 2)
1237 .copied()
1238 .unwrap_or(0.0)
1239 .max(EPSILON);
1240 if endpoint_gap > (4.0 * median).max(0.05) {
1241 return Ok(global);
1242 }
1243 // Build the interpolant from the marched samples, then refine — re-projecting
1244 // each insert SEEDED from the interpolant (already on the marched branch), so
1245 // a mid-refinement global inversion cannot re-alias onto the far sheet.
1246 let mut parameters = fractions;
1247 let mut pcurve = build_interpolant(surface, &raw, ¶meters)?;
1248 let refinement_tolerance = PCURVE_REFINEMENT_TOLERANCE;
1249 // Same classified exits and budget ladder as `fit_pcurve_on_surface`. A
1250 // seeded insert always lands, so this loop cannot stall.
1251 let refinement = refine_pcurve(
1252 surface,
1253 curve,
1254 [t0, t1],
1255 [u0, u1],
1256 [v0, v1],
1257 refinement_tolerance,
1258 &mut parameters,
1259 &mut raw,
1260 &mut pcurve,
1261 off_surface,
1262 |_fraction, seed, on_curve| {
1263 let seeded = crate::projection::project_point_to_surface_from_seed(surface, on_curve, [seed.x, seed.y])?;
1264 Ok((Some([seeded.u, seeded.v]), seeded.distance))
1265 },
1266 )?;
1267 Ok(PcurveFit {
1268 curve: pcurve,
1269 report: PcurveFitReport {
1270 residual: refinement.residual,
1271 floor: refinement_tolerance,
1272 samples: parameters.len(),
1273 exit: refinement.exit,
1274 off_surface: refinement.off_surface,
1275 raised: refinement.raised,
1276 },
1277 })
1278}
1279
1280/// [`fit_pcurve_on_surface_marched`] for the callers that only need the curve;
1281/// the report is tallied on the open [`PcurveFitScope`] exactly as
1282/// [`build_pcurve_on_surface`] does.
1283pub fn build_pcurve_on_surface_marched(
1284 surface: &NurbsSurface,
1285 curve: &NurbsCurve,
1286) -> Result<NurbsCurve, String> {
1287 let fit = fit_pcurve_on_surface_marched(surface, curve)?;
1288 record_fit(&fit.report);
1289 Ok(fit.curve)
1290}
1291
1292/// Build a pcurve for a represented subrange of a larger edge curve.
1293///
1294/// On an affine carrier the answer is exact and is taken as such
1295/// ([`affine_pcurve_on_range`]). Otherwise this samples only the represented
1296/// interval, which is essential when off-interval control points do not lie on
1297/// the target carrier — the exact lane restricts the net by knot insertion
1298/// first, so those control points are removed rather than ignored.
1299pub fn fit_pcurve_on_surface_range(
1300 surface: &NurbsSurface,
1301 curve: &NurbsCurve,
1302 edge_start: f64,
1303 edge_end: f64,
1304 forward: bool,
1305 tolerance: f64,
1306) -> Result<PcurveFit, String> {
1307 if let Some(exact) =
1308 affine_pcurve_on_range(surface, curve, edge_start, edge_end, forward, tolerance)?
1309 {
1310 return Ok(exact);
1311 }
1312 fit_pcurve_on_surface_range_dense(
1313 surface, curve, edge_start, edge_end, forward, tolerance, 64, 3, 513,
1314 )
1315}
1316
1317/// [`fit_pcurve_on_surface_range`] for the callers that only need the curve:
1318/// the fit is ACCEPTED or REFUSED on its bar ([`accept_fit`]), never returned
1319/// off it.
1320pub fn build_pcurve_on_surface_range(
1321 surface: &NurbsSurface,
1322 curve: &NurbsCurve,
1323 edge_start: f64,
1324 edge_end: f64,
1325 forward: bool,
1326 tolerance: f64,
1327) -> Result<NurbsCurve, String> {
1328 accept_fit(fit_pcurve_on_surface_range(
1329 surface, curve, edge_start, edge_end, forward, tolerance,
1330 )?)
1331}
1332
1333/// `BREP_DEBUG_STATION_FIT`: trace every station fit round by round, and the
1334/// stations around the worst span of a fit off its bar. Read once per process.
1335fn station_fit_tracing() -> bool {
1336 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1337 *ON.get_or_init(|| std::env::var_os("BREP_DEBUG_STATION_FIT").is_some())
1338}
1339
1340/// Narrowest span, as a fraction of the parameter range, that
1341/// [`fit_pcurve_on_surface_stations`] subdivides. `refine_pcurve` floors at
1342/// 1e-3 from 25 base samples; this fitter starts from 64 and quarters, so a
1343/// floor of 1e-3 bound on its first pass and refused 67 of the 57-document
1344/// corpus's bodies (measured 2026-09-26). 1e-6 admits every halving a real
1345/// trim needs and stops the runaway that fits stations 4e-7 apart.
1346const SPAN_FLOOR: f64 = 1e-6;
1347
1348/// Rounds the raised pass of [`fit_pcurve_on_surface_stations`] may spend:
1349/// enough halvings for any span the sample cap can resolve.
1350const RAISED_ROUNDS: usize = 40;
1351
1352/// The refusal every station builder returns for a fit off its bar.
1353pub const PCURVE_OFF_BAR: &str = "pcurve fit refused: the trim misses its stations by";
1354
1355/// The one acceptance decision behind [`build_pcurve_on_surface_range`],
1356/// [`build_pcurve_on_surface_range_dense`] and
1357/// [`build_pcurve_on_surface_stations`]: a fit on its bar
1358/// ([`PcurveFitReport::on_bar`]) is the curve, a fit off it is a refusal that
1359/// names the residual, the bar, what was asked, how far the stations stand off
1360/// the carrier and why refinement stopped.
1361///
1362/// Until 2026-09-26 these builders returned whatever their last round built
1363/// without measuring it (the 2026-09-18 census: 4564 of 54556 imported pcurves
1364/// over the ask they were built to, 2072 of them at a station the refinement
1365/// itself had tested and given up on). Nothing here widens a band: the bar is
1366/// the caller's own ask plus what the input makes unreachable.
1367fn accept_fit(fit: PcurveFit) -> Result<NurbsCurve, String> {
1368 let report = fit.report;
1369 if report.on_bar() {
1370 return Ok(fit.curve);
1371 }
1372 Err(off_bar_message(&report))
1373}
1374
1375fn off_bar_message(report: &PcurveFitReport) -> String {
1376 format!(
1377 "{PCURVE_OFF_BAR} {:.3e} out of sample against a bar of {:.3e} (asked {:.1e}, the \
1378 stations stand {:.3e} off the carrier; {:?} at {} samples{})",
1379 report.residual,
1380 report.floor + report.off_surface,
1381 report.floor,
1382 report.off_surface,
1383 report.exit,
1384 report.samples,
1385 if report.raised { ", after the raise" } else { "" },
1386 )
1387}
1388
1389/// The class slug of a pcurve fit refused off its bar
1390/// ([`crate::RefusalClass::NonConvergence`]): minted by
1391/// [`build_pcurve_on_surface_range_classed`] and read by
1392/// [`is_pcurve_off_bar`], so a dispatcher decides on the class, never on the
1393/// message text.
1394pub(crate) const PCURVE_OFF_BAR_WHAT: &str = "pcurve_fit_off_bar";
1395
1396/// Whether `refusal` is a pcurve fit that could not reach its bar.
1397pub(crate) fn is_pcurve_off_bar(refusal: &crate::KernelRefusal) -> bool {
1398 matches!(&refusal.class, crate::RefusalClass::NonConvergence { what } if what == PCURVE_OFF_BAR_WHAT)
1399}
1400
1401/// [`build_pcurve_on_surface_range`] with a typed refusal: a fit off its bar
1402/// is `NonConvergence { what: PCURVE_OFF_BAR_WHAT }` at `stage`, with the same
1403/// message; any other failure of the fit is `Internal` under `what`.
1404pub(crate) fn build_pcurve_on_surface_range_classed(
1405 surface: &NurbsSurface,
1406 curve: &NurbsCurve,
1407 edge_start: f64,
1408 edge_end: f64,
1409 forward: bool,
1410 tolerance: f64,
1411 stage: KernelStage,
1412 what: &'static str,
1413) -> Result<NurbsCurve, crate::KernelRefusal> {
1414 use crate::OrRefuse;
1415 let fit = fit_pcurve_on_surface_range(surface, curve, edge_start, edge_end, forward, tolerance)
1416 .or_refuse(stage, what)?;
1417 accept_fit_classed(fit, stage)
1418}
1419
1420/// [`accept_fit`] with the refusal typed: `NonConvergence { what:
1421/// PCURVE_OFF_BAR_WHAT }` at `stage`, the message unchanged.
1422pub(crate) fn accept_fit_classed(fit: PcurveFit, stage: KernelStage) -> Result<NurbsCurve, crate::KernelRefusal> {
1423 if fit.report.on_bar() {
1424 return Ok(fit.curve);
1425 }
1426 Err(crate::KernelRefusal {
1427 class: crate::RefusalClass::NonConvergence { what: PCURVE_OFF_BAR_WHAT.into() },
1428 stage,
1429 message: off_bar_message(&fit.report),
1430 })
1431}
1432
1433/// The EXACT pcurve for a coedge's subrange of `curve` on an affine carrier,
1434/// or `None` when there is no such answer and the sampled lane must run.
1435///
1436/// [`fit_pcurve_on_surface`] has always mapped a WHOLE curve's control net
1437/// exactly on an affine carrier. A coedge's trim is that same curve restricted
1438/// to `[edge_start, edge_end]` and oriented by `forward`, and both operations
1439/// are exact on a NURBS: [`NurbsCurve::split`] subdivides by knot insertion,
1440/// [`NurbsCurve::reversed`] mirrors the net, and rescaling the knots to
1441/// `[0, 1]` is affine in the edge's own parameter — which is precisely the
1442/// fraction convention the sampled lane uses, so the pcurve's correspondence
1443/// with the edge is the same one either lane produces.
1444///
1445/// Sampling where an exact answer exists leaves a real error: measured
1446/// 2026-09-17, a booleaned band rim's two planar trims sat 2.379e-4 and
1447/// 2.395e-4 off their edges, where this lane lands 1.4e-13 off.
1448///
1449/// The image is verified against the caller's own `tolerance` before it is
1450/// returned. The exact image of a curve that does NOT lie on this carrier is
1451/// its orthogonal projection, which is not that coedge's trim, so every such
1452/// case falls through to the sampled lane and keeps the refusals that lane
1453/// makes today.
1454fn affine_pcurve_on_range(
1455 surface: &NurbsSurface,
1456 curve: &NurbsCurve,
1457 edge_start: f64,
1458 edge_end: f64,
1459 forward: bool,
1460 tolerance: f64,
1461) -> Result<Option<PcurveFit>, String> {
1462 // Opt-out for A/B bisection of this lane against the sampled one, in the
1463 // shape of the two hatches beside it (`BREP_NO_PCURVE_REPAIR`,
1464 // `BREP_SECTION_PCURVE_MARCH`). It is what showed that the boolean's
1465 // planar residuals do NOT come through here: the same binary with this set
1466 // and unset read identically to every digit on 21_band_rim.
1467 if std::env::var("BREP_AFFINE_RANGE_PCURVE").as_deref() == Ok("0") {
1468 return Ok(None);
1469 }
1470 let Some(inversion) = AffineInversion::of(surface)? else {
1471 return Ok(None);
1472 };
1473 if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
1474 // The sampled lane owns this refusal; it is not this lane's to reword.
1475 return Ok(None);
1476 }
1477 let [t0, t1] = curve.domain()?;
1478 let start = edge_start.max(t0);
1479 let end = edge_end.min(t1);
1480 // A range narrower than the knot identity tolerance cannot be split onto
1481 // its own endpoints at all.
1482 if end - start <= 2.0 * KNOT_IDENTITY_TOL {
1483 return Ok(None);
1484 }
1485 let mut restricted = curve.clone();
1486 if end < t1 - KNOT_IDENTITY_TOL {
1487 restricted = restricted.split(end)?.0;
1488 }
1489 if start > t0 + KNOT_IDENTITY_TOL {
1490 restricted = restricted.split(start)?.1;
1491 }
1492 let mut pcurve = inversion.image_of(&restricted)?;
1493 if !forward {
1494 pcurve = pcurve.reversed()?;
1495 }
1496 let first = pcurve.knots[0];
1497 let last = pcurve.knots[pcurve.knots.len() - 1];
1498 if !(last - first > 0.0) {
1499 return Ok(None);
1500 }
1501 let knots = pcurve
1502 .knots
1503 .iter()
1504 .map(|knot| (knot - first) / (last - first))
1505 .collect::<Vec<_>>();
1506 let pcurve = NurbsCurve::new(pcurve.degree, knots, pcurve.control_points.clone())?;
1507
1508 // Verify against the range the CALLER asked for, not the one the
1509 // restriction produced: `split` snaps onto a nearby knot, so a snap that
1510 // moved the correspondence reads here as a deviation. Sampling at
1511 // fractions that are not the stations any lane interpolates through keeps
1512 // this a check on the whole trim rather than on its endpoints.
1513 let scale = 1.0 + curve.evaluate((start + end) / 2.0)?.length();
1514 let bar = tolerance.max(1e-11 * scale);
1515 let mut residual = 0.0_f64;
1516 for index in 0..=32 {
1517 let fraction = index as f64 / 32.0;
1518 let uv = pcurve.evaluate(fraction)?;
1519 // `evaluate` CLAMPS out-of-domain uv. A planar carrier's domain is a
1520 // bounding rectangle that a booleaned trim can leave, and an affine
1521 // patch's extension is the same plane, so read it unclamped.
1522 let on_carrier = surface.evaluate_extended(uv.x, uv.y)?;
1523 let edge_fraction = if forward { fraction } else { 1.0 - fraction };
1524 let on_edge = curve.evaluate(start + (end - start) * edge_fraction)?;
1525 residual = residual.max(on_carrier.sub(on_edge).length());
1526 if residual > bar {
1527 return Ok(None);
1528 }
1529 }
1530 let samples = pcurve.control_points.len();
1531 Ok(Some(PcurveFit {
1532 curve: pcurve,
1533 report: PcurveFitReport {
1534 residual,
1535 // The floor this lane VERIFIED against: the caller's ask, or the
1536 // floating-point floor of an exact map on a part this size. An
1537 // exact image is on its bar by construction; a caller asking for
1538 // less than the map's own precision is not refused for it.
1539 floor: bar,
1540 samples,
1541 exit: PcurveFitExit::Converged,
1542 off_surface: 0.0,
1543 raised: false,
1544 },
1545 }))
1546}
1547
1548fn align_collapsed_endpoints(surface: &NurbsSurface, raw: &mut [[f64; 2]]) -> Result<(), String> {
1549 let anchor = surface.control_points[0][0].point()?;
1550 let mut extent = 0.0_f64;
1551 for control in surface.control_points.iter().flatten() {
1552 extent = extent.max(control.point()?.sub(anchor).length());
1553 }
1554 // This is a geometric identity check, independent of the fitting allowance
1555 // and of world position. Positive rational weights keep the whole iso-curve
1556 // inside the convex hull of its Euclidean controls. Cap the size coupling
1557 // at the linear tolerance so a large patch cannot erase a thin finite row.
1558 let pole_band = (1e-10 * (1.0 + extent)).min(LINEAR_TOLERANCE);
1559 for (endpoint, neighbor) in [(0, 1), (raw.len() - 1, raw.len() - 2)] {
1560 let point = surface.evaluate(raw[endpoint][0], raw[endpoint][1])?;
1561 for axis in 0..2 {
1562 let mut candidate = raw[endpoint];
1563 candidate[axis] = raw[neighbor][axis];
1564 if candidate == raw[endpoint]
1565 || surface
1566 .evaluate(candidate[0], candidate[1])?
1567 .sub(point)
1568 .length()
1569 > pole_band
1570 {
1571 continue;
1572 }
1573 let row = if axis == 0 {
1574 surface.iso_curve_v(raw[endpoint][1])?
1575 } else {
1576 surface.iso_curve_u(raw[endpoint][0])?
1577 };
1578 let mut collapsed = true;
1579 for control in &row.control_points {
1580 if control.point()?.sub(point).length() > pole_band {
1581 collapsed = false;
1582 break;
1583 }
1584 }
1585 if collapsed {
1586 raw[endpoint] = candidate;
1587 }
1588 }
1589 }
1590 Ok(())
1591}
1592
1593/// Re-seat any raw inversion sample that BRANCH-JUMPED — snapped to a distant
1594/// fold of a self-overlapping general carrier — back onto the branch traced by
1595/// its neighbours.
1596///
1597/// Each entry in `raw` is an INDEPENDENT global closest-point inversion of the
1598/// corresponding 3D edge sample. Every one is geometrically valid (on the
1599/// surface, at the edge), but they need not be CONTIGUOUS: where a rational
1600/// B-spline surface folds back over the small trimmed patch it carries, the
1601/// momentarily-closest fold flips from one sample to the next, so the raw
1602/// polygon zig-zags across the whole domain and self-crosses — and the region
1603/// its interpolant bounds no longer covers the true face (its surface flux can
1604/// exceed its own area). We anchor on the LONGEST run of mutually-continuous
1605/// samples — the fold the trimmed patch actually lies on, since a small face
1606/// lives on ONE fold and the jumped samples are the minority the global search
1607/// snapped elsewhere — then walk outward in both directions, re-seeding Newton
1608/// from the last good parameters and adopting the continuous footpoint whenever
1609/// it is geometrically as valid as the global one. Anchoring on consensus (not
1610/// blindly on sample 0, which can itself be an outlier that would drag the
1611/// whole edge onto the wrong branch and tear the loop open at its endpoint)
1612/// leaves continuous nonsingular fits unchanged. Certified collapsed endpoint
1613/// rows first adopt their neighbour's branch; a bad seed in the subsequent
1614/// jump repair cannot make a sample worse than the global search already had it.
1615fn repair_branch_jumps(
1616 surface: &NurbsSurface,
1617 edge_points: &[Vec3],
1618 raw: &mut [[f64; 2]],
1619 tolerance: f64,
1620) -> Result<(), String> {
1621 if raw.len() < 3 {
1622 return Ok(());
1623 }
1624 // Affine carriers invert linearly and exactly — no folds, nothing to chase.
1625 // Checked first so the common planar/affine face never touches the env.
1626 if surface.is_affine()? {
1627 return Ok(());
1628 }
1629 // Opt-out for A/B bisection of a STEP-import regression against this repair.
1630 if std::env::var("BREP_NO_PCURVE_REPAIR").is_ok() {
1631 return Ok(());
1632 }
1633 // A pole has no unique coordinate along its collapsed row. Continuing the
1634 // adjacent sample's branch is safe only when the ENTIRE row is collapsed;
1635 // coincident points across an ordinary seam or fold are not enough.
1636 align_collapsed_endpoints(surface, raw)?;
1637 let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
1638 let u_span = (u1 - u0).abs().max(EPSILON);
1639 let v_span = (v1 - v0).abs().max(EPSILON);
1640 let (closed_u, closed_v) = surface_closedness(surface)?;
1641 // Normalised, seam-aware step between two parameter samples. Wrapping the
1642 // closed directions keeps a legitimate seam crossing SMALL, so the seam /
1643 // periodic-unwrap machinery elsewhere is never disturbed by this repair.
1644 let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
1645 let mut du = a[0] - b[0];
1646 if closed_u {
1647 while du > 0.5 * u_span {
1648 du -= u_span;
1649 }
1650 while du < -0.5 * u_span {
1651 du += u_span;
1652 }
1653 }
1654 let mut dv = a[1] - b[1];
1655 if closed_v {
1656 while dv > 0.5 * v_span {
1657 dv -= v_span;
1658 }
1659 while dv < -0.5 * v_span {
1660 dv += v_span;
1661 }
1662 }
1663 ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
1664 };
1665 // A jump crosses a large fraction of the WHOLE domain — orders above the
1666 // per-sample motion of any real (even domain-spanning) edge, which advances
1667 // ~1/N of its traversal between consecutive stations.
1668 const JUMP_THRESHOLD: f64 = 0.2;
1669 let count = raw.len();
1670
1671 // Locate the longest maximal run of consecutive continuous samples — the
1672 // branch to anchor on. If nothing jumps, this spans [0, count-1] and both
1673 // re-seat passes below are empty, leaving `raw` untouched.
1674 let (mut best_start, mut best_len, mut run_start) = (0usize, 1usize, 0usize);
1675 for index in 1..count {
1676 if norm_step(raw[index], raw[index - 1]) > JUMP_THRESHOLD {
1677 if index - run_start > best_len {
1678 best_len = index - run_start;
1679 best_start = run_start;
1680 }
1681 run_start = index;
1682 }
1683 }
1684 if count - run_start > best_len {
1685 best_len = count - run_start;
1686 best_start = run_start;
1687 }
1688 let (spine_lo, spine_hi) = (best_start, best_start + best_len - 1);
1689
1690 // Adopt the continuous footpoint only when it (a) still sits on the surface
1691 // — as tight as the fit band or the global answer — and (b) genuinely
1692 // closes the jump rather than trading it for another. Returns the parameter
1693 // to carry forward as the next seed (the repaired one, or the untouched
1694 // global when no repair applies).
1695 let reseat = |edge_point: Vec3,
1696 previous: [f64; 2],
1697 global: [f64; 2]|
1698 -> Result<[f64; 2], String> {
1699 let global_step = norm_step(global, previous);
1700 if global_step <= JUMP_THRESHOLD {
1701 return Ok(global);
1702 }
1703 let seeded =
1704 project_point_to_surface_seeded(surface, edge_point, previous[0], previous[1])?;
1705 let candidate = [seeded.u, seeded.v];
1706 let global_residual = surface
1707 .evaluate(global[0], global[1])?
1708 .sub(edge_point)
1709 .length();
1710 let on_surface = seeded.distance <= 4.0 * global_residual + tolerance.max(1e-12);
1711 if on_surface && norm_step(candidate, previous) < 0.5 * global_step {
1712 if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
1713 eprintln!(
1714 "pcurve repair: jump {global_step:.4} ({global:?}) -> {:.4} ({candidate:?}) res {:.2e}->{:.2e}",
1715 norm_step(candidate, previous),
1716 global_residual,
1717 seeded.distance
1718 );
1719 }
1720 Ok(candidate)
1721 } else {
1722 Ok(global)
1723 }
1724 };
1725
1726 // Walk forward off the spine's high end, then backward off its low end,
1727 // chaining each repaired sample as the next seed so continuity propagates.
1728 //
1729 // Apart from the certified collapsed rows above, the two ENDPOINT samples
1730 // (fraction 0 and 1) are NEVER moved: they are the
1731 // edge's shared loop vertices. The global inversion is deterministic, so the
1732 // two coedges meeting at a vertex land it at the SAME parameters even when
1733 // that vertex sits on a fold reachable from two branches — moving one side
1734 // to a different branch tears the loop open there (`synthesize_pole_edge`
1735 // then rejects the non-collapsed gap). A vertex's genuine fold transition
1736 // (an edge whose interior rides u≈0.08 but whose endpoint must meet its
1737 // neighbour at u≈0.95) is exactly this case and must be preserved, not
1738 // "continuity-repaired" back onto the interior branch.
1739 let mut previous = raw[spine_hi];
1740 for index in (spine_hi + 1)..count.saturating_sub(1) {
1741 raw[index] = reseat(edge_points[index], previous, raw[index])?;
1742 previous = raw[index];
1743 }
1744 let mut previous = raw[spine_lo];
1745 for index in (1..spine_lo).rev() {
1746 raw[index] = reseat(edge_points[index], previous, raw[index])?;
1747 previous = raw[index];
1748 }
1749 Ok(())
1750}
1751
1752/// Range fitter with explicit sampling knobs, reporting what the fit achieved.
1753/// [`fit_pcurve_on_surface_range`] keeps the long-standing (base 64, 3
1754/// refinement rounds, 513 cap) budget; a fit that binds at that cap with the
1755/// bar unmet grows once to [`PCURVE_RAISED_SAMPLES`]
1756/// ([`fit_pcurve_on_surface_stations`]).
1757///
1758/// NOTE (2026-09-03): this comment used to say "STEP import retries failed fits
1759/// with a denser budget". No caller passes these knobs any more — that retry
1760/// was removed and the sentence outlived it. The knobs are kept because they
1761/// are the honest way to ASK whether a fit had headroom left, which is what
1762/// `examples/pcurve_residual_probe.rs` uses them for: on every ABC corpus
1763/// coedge that failed `validate`'s pcurve check, budgets up to (512, 8, 8193)
1764/// with a 1000x tighter target reproduced the coarse fit's deviation to six
1765/// decimals, because that deviation is the edge-vs-surface closest-point
1766/// residual and a curve ON the surface cannot beat it.
1767#[allow(clippy::too_many_arguments)]
1768pub fn fit_pcurve_on_surface_range_dense(
1769 surface: &NurbsSurface,
1770 curve: &NurbsCurve,
1771 edge_start: f64,
1772 edge_end: f64,
1773 forward: bool,
1774 tolerance: f64,
1775 base_samples: usize,
1776 refinement_rounds: usize,
1777 parameter_cap: usize,
1778) -> Result<PcurveFit, String> {
1779 if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
1780 return Err("build_pcurve_on_surface_range: invalid edge interval".into());
1781 }
1782 let evaluate_edge = |fraction: f64| {
1783 let edge_fraction = if forward { fraction } else { 1.0 - fraction };
1784 curve.evaluate(edge_start + (edge_end - edge_start) * edge_fraction)
1785 };
1786 let breaks = range_breaks(curve, edge_start, edge_end, forward);
1787 fit_pcurve_on_surface_stations(
1788 surface,
1789 &evaluate_edge,
1790 &breaks,
1791 tolerance,
1792 base_samples,
1793 refinement_rounds,
1794 parameter_cap,
1795 )
1796}
1797
1798/// [`fit_pcurve_on_surface_range_dense`] for the callers that only need the
1799/// curve, accepted or refused on its bar ([`accept_fit`]).
1800#[allow(clippy::too_many_arguments)]
1801pub fn build_pcurve_on_surface_range_dense(
1802 surface: &NurbsSurface,
1803 curve: &NurbsCurve,
1804 edge_start: f64,
1805 edge_end: f64,
1806 forward: bool,
1807 tolerance: f64,
1808 base_samples: usize,
1809 refinement_rounds: usize,
1810 parameter_cap: usize,
1811) -> Result<NurbsCurve, String> {
1812 accept_fit(fit_pcurve_on_surface_range_dense(
1813 surface,
1814 curve,
1815 edge_start,
1816 edge_end,
1817 forward,
1818 tolerance,
1819 base_samples,
1820 refinement_rounds,
1821 parameter_cap,
1822 )?)
1823}
1824
1825/// The fractions of `[edge_start, edge_end]`, walked in the coedge's sense, at
1826/// which `curve` is not C2: interior knots of multiplicity at least its
1827/// degree. A rational-quadratic arc past a quarter turn is two segments joined
1828/// at a double knot, and an interpolant through uniform samples across that
1829/// joint cannot represent the second-derivative jump: on the 2026-09-18 import
1830/// census 1511 of the 2979 arc trims blamed on the fitter had their worst
1831/// miss at fraction 0.49–0.51, the mid double knot, exactly as the fillet's
1832/// corner patch had measured (`blend/track_fit.rs::curve_breaks`, which this
1833/// mirrors for a range).
1834pub(crate) fn range_breaks(
1835 curve: &NurbsCurve,
1836 edge_start: f64,
1837 edge_end: f64,
1838 forward: bool,
1839) -> Vec<f64> {
1840 let mut fractions = Vec::new();
1841 let span = edge_end - edge_start;
1842 if !(span.abs() > 0.0) {
1843 return fractions;
1844 }
1845 let (low, high) = (edge_start.min(edge_end), edge_start.max(edge_end));
1846 let mut index = 0;
1847 while index < curve.knots.len() {
1848 let value = curve.knots[index];
1849 let run = curve.knots[index..]
1850 .iter()
1851 .take_while(|knot| **knot == value)
1852 .count();
1853 if value > low && value < high && run >= curve.degree.max(1) {
1854 let along = (value - edge_start) / span;
1855 fractions.push(if forward { along } else { 1.0 - along });
1856 }
1857 index += run;
1858 }
1859 fractions.sort_by(f64::total_cmp);
1860 fractions
1861}
1862
1863/// The body of [`fit_pcurve_on_surface_range_dense`], parameterized by the
1864/// 3D station the pcurve must pass through at each coedge fraction instead of
1865/// by an edge curve and a range.
1866///
1867/// The station supplier is the whole difference between deriving a pcurve and
1868/// reading one. `fit_pcurve_on_surface_range_dense` supplies points off the
1869/// edge's own 3D curve, so the answer is "the nearest point of the carrier",
1870/// which is ambiguous exactly where a trim is interesting — a seam branch, a
1871/// near-tangential approach, a pole. The STEP importer's supplied-pcurve lane
1872/// supplies points off the VENDOR's stated trim instead, and the ambiguity is
1873/// gone because the vendor resolved it.
1874///
1875/// `stations(fraction)` must be continuous in `fraction` over `[0, 1]` and land
1876/// on (or very near) `surface`; `breaks` are the fractions at which it is not
1877/// C2 ([`range_breaks`]), each of which becomes a station so no kink sits
1878/// between two nodes. Everything else — inversion, branch repair, refinement
1879/// and the measurement — is shared with the derived lane by construction.
1880///
1881/// The SURFACE's knot-line crossings are deliberately not stations here,
1882/// although `blend/track_fit.rs` makes them so. Measured 2026-09-26 over the
1883/// 57-document import corpus: with each crossing bisected on the fraction and
1884/// projected seeded from the left station, 34 bodies refused (19 with a foot
1885/// that misses its global distance rejected) against 15 without the pass —
1886/// every extra refusal a fit stalled around a crossing station, on
1887/// `AssemblyExample-Assembly` and `abc_00000014` among others. The raised
1888/// pass resolves the O(h²) tail across a crossing on its own.
1889///
1890/// # What the fit is measured against, and when it stops
1891///
1892/// The stations' own standoff from the carrier is read FIRST, from the base
1893/// inversions, and the refinement target is the ask plus that standoff
1894/// ([`PcurveFitReport::on_bar`]): a station standing `g` off the surface
1895/// cannot be imaged closer than `g`, so chasing it to the ask alone spends the
1896/// whole budget for nothing (the 2026-09-18 census: 2052 imported pcurves at
1897/// the 513 cap, 1958 of them on a curve that is not on its carrier).
1898///
1899/// Every probe and the final measurement read the carrier through
1900/// `evaluate_extended`: a pcurve riding a periodic seam legitimately carries
1901/// parameters past the domain, and the clamped `evaluate` this loop used until
1902/// 2026-09-26 read every such station as a gross miss — an insert per round
1903/// until the cap, on a trim that was right.
1904///
1905/// The RETURNED curve is measured out of sample — at the quarter points of
1906/// every span between its nodes, where an interpolant is exact at the nodes
1907/// and extremal between — unless its last round probed every span and found
1908/// nothing to insert, in which case that round's maximum already is that
1909/// measurement. A fit that binds at `parameter_cap` or spends its rounds with
1910/// the bar still unmet grows ONCE to [`PCURVE_RAISED_SAMPLES`]
1911/// (deterministic). The report says which exit it took and what it reached;
1912/// the `build_*` wrappers refuse over the bar, this function never does.
1913#[allow(clippy::too_many_arguments)]
1914pub fn fit_pcurve_on_surface_stations(
1915 surface: &NurbsSurface,
1916 stations: &dyn Fn(f64) -> Result<Vec3, String>,
1917 breaks: &[f64],
1918 tolerance: f64,
1919 base_samples: usize,
1920 refinement_rounds: usize,
1921 parameter_cap: usize,
1922) -> Result<PcurveFit, String> {
1923 let evaluate_edge = stations;
1924 let base_samples = base_samples.max(1);
1925 // No fit reaches below the refinement floor: the projector places a foot
1926 // to about `LINEAR_TOLERANCE` (1e-7), so a caller asking for 1e-9 asks for
1927 // more than any station can deliver. Chased to such an ask the fit spends
1928 // its cap and reads its own noise — the direct-edit heals asked 1e-9 on
1929 // stations 9.9e-8 off their carrier and were refused by 8.5e-9
1930 // (2026-09-26). The floor is the ask's floor; the report says what was
1931 // reached against it.
1932 let tolerance = tolerance.max(PCURVE_REFINEMENT_TOLERANCE);
1933 let mut parameters = (0..=base_samples)
1934 .map(|index| index as f64 / base_samples as f64)
1935 .collect::<Vec<_>>();
1936 // A break within the span floor of a station IS that station: two nodes
1937 // closer than that make the interpolant swing between them, and over a
1938 // stretch that short the kink moves the fit by nothing a bar can read.
1939 // `abc_00000011` face #549's curve carries a triple knot 4e-7 before its
1940 // end; as a station it put the cubic 1.594e-2 off between it and the last
1941 // one (measured 2026-09-26), where the file's curve sits 1e-7 off.
1942 for &fraction in breaks {
1943 if !(fraction > 0.0 && fraction < 1.0) {
1944 continue;
1945 }
1946 let at = parameters.partition_point(|parameter| *parameter < fraction);
1947 let near_left = at > 0 && fraction - parameters[at - 1] <= SPAN_FLOOR;
1948 let near_right = at < parameters.len() && parameters[at] - fraction <= SPAN_FLOOR;
1949 if near_left || near_right {
1950 continue;
1951 }
1952 parameters.insert(at, fraction);
1953 }
1954 // The 3D edge point behind each raw inversion sample, kept parallel so the
1955 // continuity repair can re-seed a jumped station from its own footpoint.
1956 let mut edge_points = parameters
1957 .iter()
1958 .map(|fraction| evaluate_edge(*fraction))
1959 .collect::<Result<Vec<_>, _>>()?;
1960 // The stations' standoff from the carrier: the part of every residual no
1961 // pcurve can remove, known before a single probe is spent.
1962 let mut off_surface = 0.0_f64;
1963 let mut raw = Vec::with_capacity(edge_points.len());
1964 let mut gaps = Vec::with_capacity(edge_points.len());
1965 for point in &edge_points {
1966 let (parameter, gap) = invert_checked(surface, *point)?;
1967 off_surface = off_surface.max(gap);
1968 raw.push(parameter);
1969 gaps.push(gap);
1970 }
1971 repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
1972 let mut pcurve = build_interpolant(surface, &raw, ¶meters)?;
1973
1974 let mut cap = parameter_cap.max(parameters.len());
1975 let mut rounds = refinement_rounds;
1976 let mut raised = false;
1977 loop {
1978 let mut exit = PcurveFitExit::RoundBudget;
1979 let mut converged_max = None;
1980 for _ in 0..rounds {
1981 if parameters.len() >= cap {
1982 exit = PcurveFitExit::SampleCeiling;
1983 break;
1984 }
1985 // The target is the ASK, bare: a station standing off the carrier
1986 // is chased to the foot it projects to, as the fitter has always
1987 // done. A target of ask + standoff (one landing, 2026-09-26) let
1988 // such a fit stop at its base stations with the interpolant
1989 // wandering between them, folded a molex wall's mesh and put a
1990 // trim of abc_00000023 2.2e-5 off its own parameter; the bar
1991 // below still reads what it reached.
1992 let target = tolerance;
1993 let mut candidates = Vec::new();
1994 let mut round_max = 0.0_f64;
1995 let mut truncated = false;
1996 let mut skipped_narrow = false;
1997 let worst_first = worst_first();
1998 'spans: for index in 0..parameters.len() - 1 {
1999 // The loop's own resolution floor: a span narrower than this
2000 // is not subdivided. Without it the
2001 // raised pass inserted into a vanishing span at a trim's end
2002 // until the cap — `abc_00000011` face #549's last stations sat
2003 // 4e-7 apart with their feet clamped at the patch edge, and the
2004 // cubic through them swung 1.594e-2 off between them (measured
2005 // 2026-09-26). A miss that survives only inside such a span is
2006 // reported as [`PcurveFitExit::SpanFloor`].
2007 if parameters[index + 1] - parameters[index] < SPAN_FLOOR {
2008 skipped_narrow = true;
2009 continue;
2010 }
2011 for local in [0.25, 0.5, 0.75] {
2012 if !worst_first && parameters.len() + candidates.len() >= cap {
2013 truncated = true;
2014 break 'spans;
2015 }
2016 let fraction =
2017 parameters[index] + (parameters[index + 1] - parameters[index]) * local;
2018 let uv = pcurve.evaluate(fraction)?;
2019 let represented = surface.evaluate_extended(uv.x, uv.y)?;
2020 let edge_point = evaluate_edge(fraction)?;
2021 let deviation = represented.sub(edge_point).length();
2022 round_max = round_max.max(deviation);
2023 if deviation <= target {
2024 continue;
2025 }
2026 candidates.push((index, fraction, edge_point, uv, deviation));
2027 }
2028 }
2029 // A round with more candidates than the cap has room for spends
2030 // that room on its WORST probes, kept in curve order, rather than
2031 // on the first spans in order: truncating in span order left the
2032 // late spans of an off-carrier ring at their base spacing and its
2033 // image 1.523e-6 off its feet; worst first reaches 5.84e-8 on the
2034 // same 513 samples. An even stride along the curve reached 2.87e-7
2035 // there but diluted the inserts a local miss needs and lost fits
2036 // over the corpus (a 1.3e-5 trim of `abc_00000011` fell back to its
2037 // 4.4e-4 size-ask fit), measured 2026-09-27.
2038 let room = cap.saturating_sub(parameters.len());
2039 if candidates.len() > room {
2040 truncated = true;
2041 let mut order: Vec<usize> = (0..candidates.len()).collect();
2042 order.sort_by(|&a, &b| candidates[b].4.total_cmp(&candidates[a].4).then(a.cmp(&b)));
2043 order.truncate(room);
2044 order.sort_unstable();
2045 candidates = order.into_iter().map(|index| candidates[index]).collect();
2046 }
2047 let mut inserts = Vec::with_capacity(candidates.len());
2048 for (index, fraction, edge_point, uv, _) in candidates {
2049 let (parameter, gap) = invert_near(
2050 surface,
2051 edge_point,
2052 [uv.x, uv.y],
2053 gaps[index].max(gaps[index + 1]),
2054 )?;
2055 inserts.push((index + 1, fraction, edge_point, parameter, gap));
2056 }
2057 if station_fit_tracing() {
2058 eprintln!(
2059 "STATION-FIT round: samples={} target={target:.3e} round_max={round_max:.3e} inserts={} truncated={truncated}",
2060 parameters.len(),
2061 inserts.len()
2062 );
2063 }
2064 if inserts.is_empty() {
2065 exit = if truncated {
2066 PcurveFitExit::SampleCeiling
2067 } else if skipped_narrow {
2068 // Every probed span is within the target; the narrow ones
2069 // were not probed, and the sweep below measures them.
2070 PcurveFitExit::SpanFloor
2071 } else {
2072 converged_max = Some(round_max);
2073 PcurveFitExit::Converged
2074 };
2075 break;
2076 }
2077 for (index, fraction, edge_point, parameter, gap) in inserts.into_iter().rev() {
2078 parameters.insert(index, fraction);
2079 edge_points.insert(index, edge_point);
2080 raw.insert(index, parameter);
2081 gaps.insert(index, gap);
2082 off_surface = off_surface.max(gap);
2083 }
2084 // Inserts are independent global inversions too — re-run the repair
2085 // so a fold-flip introduced mid-refinement cannot poison the next
2086 // round's deviation interpolant.
2087 repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
2088 pcurve = build_interpolant(surface, &raw, ¶meters)?;
2089 if truncated {
2090 exit = PcurveFitExit::SampleCeiling;
2091 break;
2092 }
2093 }
2094 let residual = match converged_max {
2095 Some(measured) => measured,
2096 None => {
2097 station_residual(surface, evaluate_edge, &pcurve, ¶meters, tolerance, &mut off_surface)?
2098 }
2099 };
2100 if station_fit_tracing() {
2101 eprintln!(
2102 "STATION-FIT pass raised={raised} exit={exit:?} samples={} residual={residual:.3e} off_surface={off_surface:.3e}",
2103 parameters.len()
2104 );
2105 if residual > tolerance + off_surface && (exit == PcurveFitExit::Converged || raised) {
2106 // Where the miss sits, and the uv track around it: the trace a
2107 // refusal needs to be attributed to a fold, a seam or the file.
2108 let (mut worst, mut at) = (0.0f64, 0usize);
2109 for (index, span) in parameters.windows(2).enumerate() {
2110 let fraction = 0.5 * (span[0] + span[1]);
2111 let uv = pcurve.evaluate(fraction)?;
2112 let deviation = surface.evaluate_extended(uv.x, uv.y)?.sub(evaluate_edge(fraction)?).length();
2113 if deviation > worst {
2114 worst = deviation;
2115 at = index;
2116 }
2117 }
2118 let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
2119 let (closed_u, closed_v) = surface_closedness(surface)?;
2120 eprintln!(
2121 "STATION-FIT OFF BAR: worst {worst:.3e} between stations {at} and {}; surface degree {}x{} net {}x{} domain u[{u0},{u1}] v[{v0},{v1}] closed ({closed_u},{closed_v}) analytic {:?}",
2122 at + 1,
2123 surface.degree_u,
2124 surface.degree_v,
2125 surface.control_points.len(),
2126 surface.control_points[0].len(),
2127 surface.analytic().map(|_| "yes").unwrap_or("no")
2128 );
2129 {
2130 let fraction = 0.5 * (parameters[at] + parameters[at + 1]);
2131 let uv = pcurve.evaluate(fraction)?;
2132 let image = surface.evaluate_extended(uv.x, uv.y)?;
2133 let station = evaluate_edge(fraction)?;
2134 let foot = project_point_to_surface(surface, station)?;
2135 let seeded = project_point_to_surface_seeded(surface, station, raw[at][0], raw[at][1])?;
2136 eprintln!(
2137 " at the worst midpoint: deviation {:.4e}, station gap {:.4e} (seeded {:.4e}), image-to-foot {:.4e} (to seeded foot {:.4e}), foot uv ({:.7}, {:.7}) seeded ({:.7}, {:.7}) image uv ({:.7}, {:.7})",
2138 image.sub(station).length(), foot.distance, seeded.distance, image.sub(foot.point).length(), image.sub(seeded.point).length(), foot.u, foot.v, seeded.u, seeded.v, uv.x, uv.y
2139 );
2140 }
2141 let lo = at.saturating_sub(3);
2142 let hi = (at + 4).min(parameters.len() - 1);
2143 for index in lo..=hi {
2144 eprintln!(
2145 " station {index}: fraction {:.6} uv ({:.6}, {:.6}) point ({:.4}, {:.4}, {:.4})",
2146 parameters[index], raw[index][0], raw[index][1], edge_points[index].x, edge_points[index].y, edge_points[index].z
2147 );
2148 }
2149 }
2150 }
2151 if exit == PcurveFitExit::SpanFloor && residual <= tolerance + off_surface {
2152 exit = PcurveFitExit::Converged;
2153 }
2154 if exit != PcurveFitExit::Converged
2155 && !raised
2156 && residual > tolerance + off_surface
2157 && cap < PCURVE_RAISED_SAMPLES
2158 {
2159 raised = true;
2160 cap = PCURVE_RAISED_SAMPLES;
2161 // The raised pass is bounded by SAMPLES, not rounds: a kink
2162 // converges O(h²), a round per halving, and the three rounds of
2163 // the first pass are what left the 2026-09-18 census's fits over
2164 // their ask with budget to spare (RoundBudget at 158–400 of 2000
2165 // samples, measured 2026-09-26). Forty rounds cover every halving
2166 // the cap admits; a fit that is not converging stalls out first.
2167 rounds = RAISED_ROUNDS;
2168 continue;
2169 }
2170 return Ok(PcurveFit {
2171 curve: pcurve,
2172 report: PcurveFitReport {
2173 residual,
2174 floor: tolerance,
2175 samples: parameters.len(),
2176 exit,
2177 off_surface,
2178 raised,
2179 },
2180 });
2181 }
2182}
2183
2184/// [`fit_pcurve_on_surface_stations`] for the callers that only need the
2185/// curve, accepted or refused on its bar ([`accept_fit`]).
2186pub fn build_pcurve_on_surface_stations(
2187 surface: &NurbsSurface,
2188 stations: &dyn Fn(f64) -> Result<Vec3, String>,
2189 breaks: &[f64],
2190 tolerance: f64,
2191 base_samples: usize,
2192 refinement_rounds: usize,
2193 parameter_cap: usize,
2194) -> Result<NurbsCurve, String> {
2195 accept_fit(fit_pcurve_on_surface_stations(
2196 surface,
2197 stations,
2198 breaks,
2199 tolerance,
2200 base_samples,
2201 refinement_rounds,
2202 parameter_cap,
2203 )?)
2204}
2205
2206/// The largest 3D distance between `pcurve`'s image and its stations at the
2207/// quarter points of every span of `parameters` — out of sample by
2208/// construction, since the interpolant is exact at its nodes. Unclamped
2209/// evaluations; a station read over the running bar is inverted so that
2210/// `off_surface` covers the standoff AT the stations the residual is taken at,
2211/// not only at the nodes (a curve lifted off its plane by a bump stands
2212/// further off between two nodes than at either).
2213fn station_residual(
2214 surface: &NurbsSurface,
2215 stations: &dyn Fn(f64) -> Result<Vec3, String>,
2216 pcurve: &NurbsCurve,
2217 parameters: &[f64],
2218 tolerance: f64,
2219 off_surface: &mut f64,
2220) -> Result<f64, String> {
2221 let mut worst = 0.0_f64;
2222 for span in parameters.windows(2) {
2223 for local in [0.25, 0.5, 0.75] {
2224 let fraction = span[0] + (span[1] - span[0]) * local;
2225 let uv = pcurve.evaluate(fraction)?;
2226 let represented = surface.evaluate_extended(uv.x, uv.y)?;
2227 let station = stations(fraction)?;
2228 let deviation = represented.sub(station).length();
2229 worst = worst.max(deviation);
2230 if deviation > tolerance + *off_surface {
2231 *off_surface = off_surface.max(invert_checked(surface, station)?.1);
2232 }
2233 }
2234 }
2235 Ok(worst)
2236}
2237