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