Skip to main content

brep_kernel/geometry/
pcurve.rs

1use crate::{
2    interpolate_curve, project_point_to_surface, project_point_to_surface_seeded, AnalyticSurface,
3    KnotVector, NurbsCurve, NurbsSurface, Vec3, Vec4,
4};
5
6const EPSILON: f64 = 1e-12;
7const LINEAR_TOLERANCE: f64 = 1e-7;
8
9fn surface_domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), String> {
10    Ok((
11        KnotVector::new(surface.knots_u.clone(), surface.degree_u)?.domain(),
12        KnotVector::new(surface.knots_v.clone(), surface.degree_v)?.domain(),
13    ))
14}
15
16fn surface_closedness(surface: &NurbsSurface) -> Result<(bool, bool), String> {
17    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
18    let mut closed_u = true;
19    let mut closed_v = true;
20    for fraction in [0.19, 0.52, 0.87] {
21        let v = v0 + (v1 - v0) * fraction;
22        if surface
23            .evaluate(u0, v)?
24            .sub(surface.evaluate(u1, v)?)
25            .length()
26            > LINEAR_TOLERANCE * 10.0
27        {
28            closed_u = false;
29        }
30        let u = u0 + (u1 - u0) * fraction;
31        if surface
32            .evaluate(u, v0)?
33            .sub(surface.evaluate(u, v1)?)
34            .length()
35            > LINEAR_TOLERANCE * 10.0
36        {
37            closed_v = false;
38        }
39    }
40    Ok((closed_u, closed_v))
41}
42
43fn invert_checked(surface: &NurbsSurface, point: Vec3) -> Result<([f64; 2], f64), String> {
44    if surface.is_affine()? {
45        let ([u0, _], [v0, _]) = surface_domains(surface)?;
46        let (origin, du, dv) = surface.deriv1(u0, v0)?;
47        let delta = point.sub(origin);
48        let uu = du.dot(du);
49        let uv = du.dot(dv);
50        let vv = dv.dot(dv);
51        let along_u = delta.dot(du);
52        let along_v = delta.dot(dv);
53        let determinant = uu * vv - uv * uv;
54        if determinant.abs() > EPSILON {
55            return Ok((
56                [
57                    u0 + (along_u * vv - along_v * uv) / determinant,
58                    v0 + (along_v * uu - along_u * uv) / determinant,
59                ],
60                0.0,
61            ));
62        }
63    }
64    let projection = project_point_to_surface(surface, point)?;
65    Ok(([projection.u, projection.v], projection.distance))
66}
67
68fn unwrap_periodic(values: &mut [f64], minimum: f64, maximum: f64) {
69    let period = maximum - minimum;
70    for index in 1..values.len() {
71        while values[index] - values[index - 1] > period / 2.0 {
72            values[index] -= period;
73        }
74        while values[index] - values[index - 1] < -period / 2.0 {
75            values[index] += period;
76        }
77    }
78    if values.len() > 1 {
79        while values[0] - values[1] > period / 2.0 {
80            values[0] -= period;
81        }
82        while values[0] - values[1] < -period / 2.0 {
83            values[0] += period;
84        }
85    }
86    // Recenter the whole (now continuous) sequence into the domain by the
87    // whole-period shift that leaves the LEAST parameter outside [minimum,
88    // maximum]. A single middle/mean sample is NOT representative of a curve
89    // that bulges out to touch — or straddle — the periodic seam: when that
90    // sample is a boundary-kissing point (the at-seam projection unwraps a hair
91    // past the boundary), a single-sample test misfires and shoves the entire
92    // curve a full period out of range (ABC helmet 00000011/12 side channels:
93    // a touching edge's parameter run snapped a whole period off its true
94    // interior, tearing the loop open in parameter space).
95    if values.len() > 1 {
96        let excursion = |shift: f64| -> f64 {
97            values
98                .iter()
99                .map(|value| {
100                    let v = value + shift;
101                    (minimum - v).max(0.0) + (v - maximum).max(0.0)
102                })
103                .sum::<f64>()
104        };
105        let mean = values.iter().copied().sum::<f64>() / values.len() as f64;
106        let center = 0.5 * (minimum + maximum);
107        let base_k = ((center - mean) / period).round() as i64;
108        let mut best_shift = 0.0;
109        let mut best_excursion = f64::INFINITY;
110        for k in (base_k - 1)..=(base_k + 1) {
111            let shift = k as f64 * period;
112            let value = excursion(shift);
113            if value < best_excursion {
114                best_excursion = value;
115                best_shift = shift;
116            }
117        }
118        if best_shift != 0.0 {
119            for value in values.iter_mut() {
120                *value += best_shift;
121            }
122        }
123    }
124}
125
126fn build_interpolant(
127    surface: &NurbsSurface,
128    raw_parameters: &[[f64; 2]],
129    curve_parameters: &[f64],
130) -> Result<NurbsCurve, String> {
131    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
132    let (closed_u, closed_v) = surface_closedness(surface)?;
133    let mut parameters = raw_parameters.to_vec();
134    // Longitude is undefined at a sphere pole: the surface's u tangent
135    // vanishes there, and closest-point inversion is free to return any u.
136    // Letting that arbitrary value participate in periodic unwrapping can move
137    // the first real meridian by a whole period (e.g. 0.75 -> -0.25). Analytic
138    // carriers are subsequently clamped, collapsing that meridian onto u=0.
139    //
140    // Keep this deliberately sphere- and singularity-gated. Split the u values
141    // into maximal non-pole runs, unwrap each run independently, and leave the
142    // pole samples untouched. The existing interpolant degree stays unchanged;
143    // lowering it here would also change endpoint-tangent decisions made later
144    // while stitching the face loop.
145    let sphere_u_singular = if matches!(surface.analytic(), Some(AnalyticSurface::Sphere { .. })) {
146        let speeds = parameters
147            .iter()
148            .map(|parameter| {
149                surface
150                    .deriv1(parameter[0], parameter[1])
151                    .map(|(_, su, _)| su.length())
152            })
153            .collect::<Result<Vec<_>, _>>()?;
154        let reference = speeds.iter().copied().fold(0.0_f64, f64::max);
155        speeds
156            .into_iter()
157            // Uniform edge stations need not hit the pole exactly (ABC 5360
158            // bottoms out at v=5.5e-4). In this small conditioning band,
159            // longitude is already numerically arbitrary and must not connect
160            // the two meridian runs across the pole.
161            .map(|speed| speed <= reference * 1e-2)
162            .collect::<Vec<_>>()
163    } else {
164        vec![false; parameters.len()]
165    };
166    // Do not perturb ordinary pole-touching trims. The special path is needed
167    // only when a pole band is bracketed by nonsingular samples and its raw
168    // longitude changes by at least half a period: a true through-pole branch
169    // reset whose two meridian runs must be unwrapped independently.
170    // Prefix/suffix pole samples on normal sphere caps retain the legacy
171    // interpolation byte-for-byte. Include the exactly-half-period case so
172    // floating-point noise cannot decide which entire run is lifted (ABC 5603).
173    let has_interior_pole_crossing = sphere_u_singular
174        .iter()
175        .position(|singular| !singular)
176        .zip(sphere_u_singular.iter().rposition(|singular| !singular))
177        .is_some_and(|(first, last)| sphere_u_singular[first..=last].iter().any(|value| *value));
178    let period = (u1 - u0).abs();
179    let interior_pole_branch_reset = closed_u
180        && has_interior_pole_crossing
181        && parameters.windows(2).zip(sphere_u_singular.windows(2)).any(
182            |(parameter_pair, singular_pair)| {
183                (singular_pair[0] || singular_pair[1])
184                    && (parameter_pair[1][0] - parameter_pair[0][0]).abs()
185                        >= 0.5 * period - 1e-12 * period.max(1.0)
186            },
187        );
188    if closed_u {
189        let mut values = parameters.iter().map(|value| value[0]).collect::<Vec<_>>();
190        if interior_pole_branch_reset {
191            let mut start = 0;
192            while start < values.len() {
193                while start < values.len() && sphere_u_singular[start] {
194                    start += 1;
195                }
196                let mut end = start;
197                while end < values.len() && !sphere_u_singular[end] {
198                    end += 1;
199                }
200                unwrap_periodic(&mut values[start..end], u0, u1);
201                start = end;
202            }
203        } else {
204            unwrap_periodic(&mut values, u0, u1);
205        }
206        for (parameter, value) in parameters.iter_mut().zip(values) {
207            parameter[0] = value;
208        }
209    }
210    if closed_v {
211        let mut values = parameters.iter().map(|value| value[1]).collect::<Vec<_>>();
212        unwrap_periodic(&mut values, v0, v1);
213        for (parameter, value) in parameters.iter_mut().zip(values) {
214            parameter[1] = value;
215        }
216    }
217    // A closed direction WRAPS: an edge that straddles the seam has no single
218    // in-domain parameter run, so keep the unwrapped (possibly slightly
219    // out-of-domain) values and let the periodic evaluator wrap them — clamping
220    // them onto the seam boundary would collapse the straddling geometry (and,
221    // via the interpolant, spike neighbouring stations). Restrict this to
222    // GENERAL (B-spline) carriers: the analytic cylinders/cones/tori keep their
223    // exact, in-domain seam handling (split rims, biperiodic bands), which the
224    // straddle path is not meant to replace. Open directions must stay in-domain
225    // regardless (their extension is a tangent-plane ruling, not a wrap).
226    let wrap = surface.analytic().is_none();
227    let points = parameters
228        .into_iter()
229        .map(|parameter| {
230            let u = if closed_u && wrap {
231                parameter[0]
232            } else {
233                parameter[0].clamp(u0, u1)
234            };
235            let v = if closed_v && wrap {
236                parameter[1]
237            } else {
238                parameter[1].clamp(v0, v1)
239            };
240            Vec3::new(u, v, 0.0)
241        })
242        .collect::<Vec<_>>();
243    interpolate_curve(&points, 3usize.min(points.len() - 1), curve_parameters)
244}
245
246/// Build a parameter-space curve for a 3D curve lying on a surface.
247///
248/// This mirrors the reference imprint implementation: affine patches map
249/// homogeneous control points exactly; general patches use sampled inversion,
250/// periodic seam unwrapping, and adaptive 3D residual refinement.
251pub fn build_pcurve_on_surface(
252    surface: &NurbsSurface,
253    curve: &NurbsCurve,
254) -> Result<NurbsCurve, String> {
255    let [t0, t1] = curve.domain()?;
256    if surface.is_affine()? {
257        let ([u0, _], [v0, _]) = surface_domains(surface)?;
258        let (origin, du, dv) = surface.deriv1(u0, v0)?;
259        let uu = du.dot(du);
260        let uv = du.dot(dv);
261        let vv = dv.dot(dv);
262        let determinant = uu * vv - uv * uv;
263        if determinant.abs() <= 1e-18 {
264            return Err("build_pcurve_on_surface: singular affine parameterization".into());
265        }
266        let control_points = curve
267            .control_points
268            .iter()
269            .map(|control| {
270                let delta = control.point()?.sub(origin);
271                let along_u = delta.dot(du);
272                let along_v = delta.dot(dv);
273                Ok(Vec4::from_point(
274                    Vec3::new(
275                        u0 + (along_u * vv - along_v * uv) / determinant,
276                        v0 + (along_v * uu - along_u * uv) / determinant,
277                        0.0,
278                    ),
279                    control.w,
280                ))
281            })
282            .collect::<Result<Vec<_>, String>>()?;
283        return NurbsCurve::new(curve.degree, curve.knots.clone(), control_points);
284    }
285
286    let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
287    let drop_tolerance = 1e-4 * scale;
288    let endpoint_tolerance = 1e-3 * scale;
289    let refinement_tolerance = 1e-3f64.min(1e-4 * scale);
290    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
291    let invert =
292        |fraction: f64| invert_checked(surface, curve.evaluate(t0 + (t1 - t0) * fraction)?);
293
294    let mut parameters = Vec::with_capacity(25);
295    let mut raw_surface_parameters = Vec::with_capacity(25);
296    for index in 0..=24 {
297        let fraction = index as f64 / 24.0;
298        let (parameter, distance) = invert(fraction)?;
299        if distance > drop_tolerance && index != 0 && index != 24 {
300            continue;
301        }
302        if distance > endpoint_tolerance {
303            if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
304                let p3 = curve.evaluate(t0 + (t1 - t0) * fraction).ok();
305                let p0 = curve.evaluate(t0).ok();
306                let p1 = curve.evaluate(t1).ok();
307                eprintln!(
308                    "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={:?}",
309                    surface.evaluate(parameter[0], parameter[1]).ok()
310                );
311            }
312            return Err(format!(
313                "build_pcurve_on_surface: endpoint projection failed (distance={distance})"
314            ));
315        }
316        parameters.push(fraction);
317        raw_surface_parameters.push(parameter);
318    }
319
320    let mut pcurve = build_interpolant(surface, &raw_surface_parameters, &parameters)?;
321    const MAX_SAMPLES: usize = 160;
322    for _ in 0..4 {
323        if parameters.len() >= MAX_SAMPLES {
324            break;
325        }
326        let mut inserts = Vec::new();
327        for index in 0..parameters.len() - 1 {
328            if parameters.len() + inserts.len() >= MAX_SAMPLES {
329                break;
330            }
331            let start = parameters[index];
332            let end = parameters[index + 1];
333            if end - start < 1e-3 {
334                continue;
335            }
336            for local_fraction in [0.25, 0.5, 0.75] {
337                if parameters.len() + inserts.len() >= MAX_SAMPLES {
338                    break;
339                }
340                let fraction = start + (end - start) * local_fraction;
341                let parameter = pcurve.evaluate(fraction)?;
342                let on_surface =
343                    surface.evaluate(parameter.x.clamp(u0, u1), parameter.y.clamp(v0, v1))?;
344                let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
345                if on_surface.sub(on_curve).length() <= refinement_tolerance {
346                    continue;
347                }
348                let (surface_parameter, distance) = invert(fraction)?;
349                if distance <= drop_tolerance {
350                    inserts.push((index + 1, fraction, surface_parameter));
351                }
352            }
353        }
354        if inserts.is_empty() {
355            break;
356        }
357        for (at, fraction, surface_parameter) in inserts.into_iter().rev() {
358            parameters.insert(at, fraction);
359            raw_surface_parameters.insert(at, surface_parameter);
360        }
361        pcurve = build_interpolant(surface, &raw_surface_parameters, &parameters)?;
362    }
363    Ok(pcurve)
364}
365
366/// SECTION-PCURVE SEEDED MARCH (t222: cone × ABC 00000327, non-integral genus).
367///
368/// `build_pcurve_on_surface` inverts every sample by GLOBAL closest point. Where
369/// the carrier surface is COMPRESSED / self-overlapping along the row the
370/// section rides, the global search aliases interior samples onto a DISTANT
371/// preimage sheet, folding the pcurve out past the trim boundary and re-crossing
372/// it. The arrangement then splits the shared section at that phantom crossing,
373/// while the mate face (whose carrier is not folded there) keeps the section
374/// whole — so the two operands' fragments disagree and the section strands
375/// one-use (non-integral genus). Ground truth for t222: 00000327 face 496's
376/// v≈0.7875 row maps u∈[0.55,0.81] into a ~0.18mm neighbourhood of the section
377/// endpoint A; the global pcurve for piece 9 folded out to u=0.806 and back,
378/// re-crossing trim edge 349 at v≈0.784, 0.003 below the corner vertex A.
379///
380/// This marches the interior inversions with each seeded from the PREVIOUS
381/// accepted parameters (`project_point_to_surface_seeded`), so a marched sample
382/// adopts the nearby branch instead of the momentarily-closest distant sheet.
383/// The two ENDPOINT samples keep the deterministic global inversion (they are
384/// the piece's shared junction vertices — same reasoning as
385/// `repair_branch_jumps`). Additive + fail-soft: the marched chain is used ONLY
386/// when it CLOSES onto the global far endpoint (its last interior sample is
387/// parameter-adjacent to it, measured against the chain's own median step);
388/// otherwise the plain global build is returned byte-for-byte. A single-preimage
389/// carrier (the cone side of the same section) marches to the same samples the
390/// global search already had, so it is unchanged there — this only ever alters a
391/// carrier that actually presents multiple preimages within tolerance.
392///
393/// Escape hatch: `BREP_SECTION_PCURVE_MARCH=0`.
394pub fn build_pcurve_on_surface_marched(
395    surface: &NurbsSurface,
396    curve: &NurbsCurve,
397) -> Result<NurbsCurve, String> {
398    let global = build_pcurve_on_surface(surface, curve)?;
399    if std::env::var("BREP_SECTION_PCURVE_MARCH").as_deref() == Ok("0") || surface.is_affine()? {
400        return Ok(global);
401    }
402    let [t0, t1] = curve.domain()?;
403    // A closed (ring) section has no distinct endpoints to anchor the march;
404    // leave it to the existing path (closed rings are handled elsewhere).
405    if curve.evaluate(t0)?.sub(curve.evaluate(t1)?).length() <= 1e-6 {
406        return Ok(global);
407    }
408    const SAMPLES: usize = 24;
409    let fractions: Vec<f64> = (0..=SAMPLES).map(|i| i as f64 / SAMPLES as f64).collect();
410    let edge: Vec<Vec3> = fractions
411        .iter()
412        .map(|fraction| curve.evaluate(t0 + (t1 - t0) * fraction))
413        .collect::<Result<Vec<_>, _>>()?;
414    let scale = 1.0 + curve.evaluate((t0 + t1) / 2.0)?.length();
415    let endpoint_tolerance = 1e-3 * scale;
416    let (first, first_distance) = invert_checked(surface, edge[0])?;
417    let (last, last_distance) = invert_checked(surface, edge[SAMPLES])?;
418    if first_distance > endpoint_tolerance || last_distance > endpoint_tolerance {
419        return Ok(global);
420    }
421    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
422    let u_span = (u1 - u0).abs().max(EPSILON);
423    let v_span = (v1 - v0).abs().max(EPSILON);
424    let (closed_u, closed_v) = surface_closedness(surface)?;
425    let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
426        let mut du = a[0] - b[0];
427        if closed_u {
428            while du > 0.5 * u_span {
429                du -= u_span;
430            }
431            while du < -0.5 * u_span {
432                du += u_span;
433            }
434        }
435        let mut dv = a[1] - b[1];
436        if closed_v {
437            while dv > 0.5 * v_span {
438                dv -= v_span;
439            }
440            while dv < -0.5 * v_span {
441                dv += v_span;
442            }
443        }
444        ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
445    };
446    // Forward seeded march over the interior samples.
447    let mut raw = vec![first];
448    let mut previous = first;
449    for index in 1..SAMPLES {
450        let seeded =
451            project_point_to_surface_seeded(surface, edge[index], previous[0], previous[1])?;
452        let (_, global_distance) = invert_checked(surface, edge[index])?;
453        // The seeded footpoint must still sit on the surface — as tight as the
454        // fit band or the global answer already had it. If it fell off (the
455        // seed led Newton into a valley), abandon the march and keep global.
456        if seeded.distance > 4.0 * global_distance + endpoint_tolerance {
457            return Ok(global);
458        }
459        previous = [seeded.u, seeded.v];
460        raw.push(previous);
461    }
462    raw.push(last);
463    // CLOSURE GATE: the last marched interior sample must be parameter-adjacent
464    // to the global far endpoint — the march stayed on ONE branch the whole way.
465    // Measure the endpoint gap against the chain's OWN median step so a genuine
466    // long edge is not rejected while a sheet-hop (which leaves a big gap to the
467    // endpoint) is.
468    let mut steps: Vec<f64> = (1..raw.len())
469        .map(|i| norm_step(raw[i], raw[i - 1]))
470        .collect();
471    let endpoint_gap = steps.pop().unwrap_or(0.0);
472    steps.sort_by(f64::total_cmp);
473    let median = steps
474        .get(steps.len() / 2)
475        .copied()
476        .unwrap_or(0.0)
477        .max(EPSILON);
478    if endpoint_gap > (4.0 * median).max(0.05) {
479        return Ok(global);
480    }
481    // Build the interpolant from the marched samples, then refine — re-projecting
482    // each insert SEEDED from the interpolant (already on the marched branch), so
483    // a mid-refinement global inversion cannot re-alias onto the far sheet.
484    let mut parameters = fractions;
485    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
486    let refinement_tolerance = 1e-3f64.min(1e-4 * scale);
487    const MAX_SAMPLES: usize = 160;
488    for _ in 0..4 {
489        if parameters.len() >= MAX_SAMPLES {
490            break;
491        }
492        let mut inserts = Vec::new();
493        for index in 0..parameters.len() - 1 {
494            if parameters.len() + inserts.len() >= MAX_SAMPLES {
495                break;
496            }
497            if parameters[index + 1] - parameters[index] < 1e-3 {
498                continue;
499            }
500            for local in [0.25, 0.5, 0.75] {
501                if parameters.len() + inserts.len() >= MAX_SAMPLES {
502                    break;
503                }
504                let fraction =
505                    parameters[index] + (parameters[index + 1] - parameters[index]) * local;
506                let seed = pcurve.evaluate(fraction)?;
507                let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
508                let on_surface = surface.evaluate(seed.x.clamp(u0, u1), seed.y.clamp(v0, v1))?;
509                if on_surface.sub(on_curve).length() <= refinement_tolerance {
510                    continue;
511                }
512                let seeded = project_point_to_surface_seeded(surface, on_curve, seed.x, seed.y)?;
513                inserts.push((index + 1, fraction, [seeded.u, seeded.v]));
514            }
515        }
516        if inserts.is_empty() {
517            break;
518        }
519        for (at, fraction, uv) in inserts.into_iter().rev() {
520            parameters.insert(at, fraction);
521            raw.insert(at, uv);
522        }
523        pcurve = build_interpolant(surface, &raw, &parameters)?;
524    }
525    Ok(pcurve)
526}
527
528/// Build a pcurve for a represented subrange of a larger edge curve.  Unlike
529/// trimming the curve's homogeneous control net, this samples only the
530/// represented interval, which is essential when off-interval control points
531/// do not lie on the target carrier.
532pub fn build_pcurve_on_surface_range(
533    surface: &NurbsSurface,
534    curve: &NurbsCurve,
535    edge_start: f64,
536    edge_end: f64,
537    forward: bool,
538    tolerance: f64,
539) -> Result<NurbsCurve, String> {
540    build_pcurve_on_surface_range_dense(
541        surface, curve, edge_start, edge_end, forward, tolerance, 64, 3, 513,
542    )
543}
544
545fn align_collapsed_endpoints(surface: &NurbsSurface, raw: &mut [[f64; 2]]) -> Result<(), String> {
546    let anchor = surface.control_points[0][0].point()?;
547    let mut extent = 0.0_f64;
548    for control in surface.control_points.iter().flatten() {
549        extent = extent.max(control.point()?.sub(anchor).length());
550    }
551    // This is a geometric identity check, independent of the fitting allowance
552    // and of world position. Positive rational weights keep the whole iso-curve
553    // inside the convex hull of its Euclidean controls. Cap the size coupling
554    // at the linear tolerance so a large patch cannot erase a thin finite row.
555    let pole_band = (1e-10 * (1.0 + extent)).min(LINEAR_TOLERANCE);
556    for (endpoint, neighbor) in [(0, 1), (raw.len() - 1, raw.len() - 2)] {
557        let point = surface.evaluate(raw[endpoint][0], raw[endpoint][1])?;
558        for axis in 0..2 {
559            let mut candidate = raw[endpoint];
560            candidate[axis] = raw[neighbor][axis];
561            if candidate == raw[endpoint]
562                || surface
563                    .evaluate(candidate[0], candidate[1])?
564                    .sub(point)
565                    .length()
566                    > pole_band
567            {
568                continue;
569            }
570            let row = if axis == 0 {
571                surface.iso_curve_v(raw[endpoint][1])?
572            } else {
573                surface.iso_curve_u(raw[endpoint][0])?
574            };
575            let mut collapsed = true;
576            for control in &row.control_points {
577                if control.point()?.sub(point).length() > pole_band {
578                    collapsed = false;
579                    break;
580                }
581            }
582            if collapsed {
583                raw[endpoint] = candidate;
584            }
585        }
586    }
587    Ok(())
588}
589
590/// Re-seat any raw inversion sample that BRANCH-JUMPED — snapped to a distant
591/// fold of a self-overlapping general carrier — back onto the branch traced by
592/// its neighbours.
593///
594/// Each entry in `raw` is an INDEPENDENT global closest-point inversion of the
595/// corresponding 3D edge sample. Every one is geometrically valid (on the
596/// surface, at the edge), but they need not be CONTIGUOUS: where a rational
597/// B-spline surface folds back over the small trimmed patch it carries, the
598/// momentarily-closest fold flips from one sample to the next, so the raw
599/// polygon zig-zags across the whole domain and self-crosses — and the region
600/// its interpolant bounds no longer covers the true face (its surface flux can
601/// exceed its own area). We anchor on the LONGEST run of mutually-continuous
602/// samples — the fold the trimmed patch actually lies on, since a small face
603/// lives on ONE fold and the jumped samples are the minority the global search
604/// snapped elsewhere — then walk outward in both directions, re-seeding Newton
605/// from the last good parameters and adopting the continuous footpoint whenever
606/// it is geometrically as valid as the global one. Anchoring on consensus (not
607/// blindly on sample 0, which can itself be an outlier that would drag the
608/// whole edge onto the wrong branch and tear the loop open at its endpoint)
609/// leaves continuous nonsingular fits unchanged. Certified collapsed endpoint
610/// rows first adopt their neighbour's branch; a bad seed in the subsequent
611/// jump repair cannot make a sample worse than the global search already had it.
612fn repair_branch_jumps(
613    surface: &NurbsSurface,
614    edge_points: &[Vec3],
615    raw: &mut [[f64; 2]],
616    tolerance: f64,
617) -> Result<(), String> {
618    if raw.len() < 3 {
619        return Ok(());
620    }
621    // Affine carriers invert linearly and exactly — no folds, nothing to chase.
622    // Checked first so the common planar/affine face never touches the env.
623    if surface.is_affine()? {
624        return Ok(());
625    }
626    // Opt-out for A/B bisection of a STEP-import regression against this repair.
627    if std::env::var("BREP_NO_PCURVE_REPAIR").is_ok() {
628        return Ok(());
629    }
630    // A pole has no unique coordinate along its collapsed row. Continuing the
631    // adjacent sample's branch is safe only when the ENTIRE row is collapsed;
632    // coincident points across an ordinary seam or fold are not enough.
633    align_collapsed_endpoints(surface, raw)?;
634    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
635    let u_span = (u1 - u0).abs().max(EPSILON);
636    let v_span = (v1 - v0).abs().max(EPSILON);
637    let (closed_u, closed_v) = surface_closedness(surface)?;
638    // Normalised, seam-aware step between two parameter samples. Wrapping the
639    // closed directions keeps a legitimate seam crossing SMALL, so the seam /
640    // periodic-unwrap machinery elsewhere is never disturbed by this repair.
641    let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
642        let mut du = a[0] - b[0];
643        if closed_u {
644            while du > 0.5 * u_span {
645                du -= u_span;
646            }
647            while du < -0.5 * u_span {
648                du += u_span;
649            }
650        }
651        let mut dv = a[1] - b[1];
652        if closed_v {
653            while dv > 0.5 * v_span {
654                dv -= v_span;
655            }
656            while dv < -0.5 * v_span {
657                dv += v_span;
658            }
659        }
660        ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
661    };
662    // A jump crosses a large fraction of the WHOLE domain — orders above the
663    // per-sample motion of any real (even domain-spanning) edge, which advances
664    // ~1/N of its traversal between consecutive stations.
665    const JUMP_THRESHOLD: f64 = 0.2;
666    let count = raw.len();
667
668    // Locate the longest maximal run of consecutive continuous samples — the
669    // branch to anchor on. If nothing jumps, this spans [0, count-1] and both
670    // re-seat passes below are empty, leaving `raw` untouched.
671    let (mut best_start, mut best_len, mut run_start) = (0usize, 1usize, 0usize);
672    for index in 1..count {
673        if norm_step(raw[index], raw[index - 1]) > JUMP_THRESHOLD {
674            if index - run_start > best_len {
675                best_len = index - run_start;
676                best_start = run_start;
677            }
678            run_start = index;
679        }
680    }
681    if count - run_start > best_len {
682        best_len = count - run_start;
683        best_start = run_start;
684    }
685    let (spine_lo, spine_hi) = (best_start, best_start + best_len - 1);
686
687    // Adopt the continuous footpoint only when it (a) still sits on the surface
688    // — as tight as the fit band or the global answer — and (b) genuinely
689    // closes the jump rather than trading it for another. Returns the parameter
690    // to carry forward as the next seed (the repaired one, or the untouched
691    // global when no repair applies).
692    let reseat = |edge_point: Vec3,
693                  previous: [f64; 2],
694                  global: [f64; 2]|
695     -> Result<[f64; 2], String> {
696        let global_step = norm_step(global, previous);
697        if global_step <= JUMP_THRESHOLD {
698            return Ok(global);
699        }
700        let seeded =
701            project_point_to_surface_seeded(surface, edge_point, previous[0], previous[1])?;
702        let candidate = [seeded.u, seeded.v];
703        let global_residual = surface
704            .evaluate(global[0], global[1])?
705            .sub(edge_point)
706            .length();
707        let on_surface = seeded.distance <= 4.0 * global_residual + tolerance.max(1e-12);
708        if on_surface && norm_step(candidate, previous) < 0.5 * global_step {
709            if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
710                eprintln!(
711                    "pcurve repair: jump {global_step:.4} ({global:?}) -> {:.4} ({candidate:?}) res {:.2e}->{:.2e}",
712                    norm_step(candidate, previous),
713                    global_residual,
714                    seeded.distance
715                );
716            }
717            Ok(candidate)
718        } else {
719            Ok(global)
720        }
721    };
722
723    // Walk forward off the spine's high end, then backward off its low end,
724    // chaining each repaired sample as the next seed so continuity propagates.
725    //
726    // Apart from the certified collapsed rows above, the two ENDPOINT samples
727    // (fraction 0 and 1) are NEVER moved: they are the
728    // edge's shared loop vertices. The global inversion is deterministic, so the
729    // two coedges meeting at a vertex land it at the SAME parameters even when
730    // that vertex sits on a fold reachable from two branches — moving one side
731    // to a different branch tears the loop open there (`synthesize_pole_edge`
732    // then rejects the non-collapsed gap). A vertex's genuine fold transition
733    // (an edge whose interior rides u≈0.08 but whose endpoint must meet its
734    // neighbour at u≈0.95) is exactly this case and must be preserved, not
735    // "continuity-repaired" back onto the interior branch.
736    let mut previous = raw[spine_hi];
737    for index in (spine_hi + 1)..count.saturating_sub(1) {
738        raw[index] = reseat(edge_points[index], previous, raw[index])?;
739        previous = raw[index];
740    }
741    let mut previous = raw[spine_lo];
742    for index in (1..spine_lo).rev() {
743        raw[index] = reseat(edge_points[index], previous, raw[index])?;
744        previous = raw[index];
745    }
746    Ok(())
747}
748
749/// Range fitter with explicit sampling knobs. The default entry above keeps
750/// the long-standing (base 64, 3 refinement rounds, 513 cap) budget.
751///
752/// NOTE (2026-09-03): this comment used to say "STEP import retries failed fits
753/// with a denser budget". No caller passes these knobs any more — that retry
754/// was removed and the sentence outlived it. The knobs are kept because they
755/// are the honest way to ASK whether a fit had headroom left, which is what
756/// `examples/pcurve_residual_probe.rs` uses them for: on every ABC corpus
757/// coedge that failed `validate`'s pcurve check, budgets up to (512, 8, 8193)
758/// with a 1000x tighter target reproduced the coarse fit's deviation to six
759/// decimals, because that deviation is the edge-vs-surface closest-point
760/// residual and a curve ON the surface cannot beat it.
761#[allow(clippy::too_many_arguments)]
762pub fn build_pcurve_on_surface_range_dense(
763    surface: &NurbsSurface,
764    curve: &NurbsCurve,
765    edge_start: f64,
766    edge_end: f64,
767    forward: bool,
768    tolerance: f64,
769    base_samples: usize,
770    refinement_rounds: usize,
771    parameter_cap: usize,
772) -> Result<NurbsCurve, String> {
773    if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
774        return Err("build_pcurve_on_surface_range: invalid edge interval".into());
775    }
776    let evaluate_edge = |fraction: f64| {
777        let edge_fraction = if forward { fraction } else { 1.0 - fraction };
778        curve.evaluate(edge_start + (edge_end - edge_start) * edge_fraction)
779    };
780    let mut parameters = (0..=base_samples)
781        .map(|index| index as f64 / base_samples as f64)
782        .collect::<Vec<_>>();
783    // The 3D edge point behind each raw inversion sample, kept parallel so the
784    // continuity repair can re-seed a jumped station from its own footpoint.
785    let mut edge_points = parameters
786        .iter()
787        .map(|fraction| evaluate_edge(*fraction))
788        .collect::<Result<Vec<_>, _>>()?;
789    let mut raw = edge_points
790        .iter()
791        .map(|point| invert_checked(surface, *point).map(|value| value.0))
792        .collect::<Result<Vec<_>, _>>()?;
793    repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
794    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
795    for _ in 0..refinement_rounds {
796        let mut inserts = Vec::new();
797        for index in 0..parameters.len() - 1 {
798            if parameters.len() + inserts.len() >= parameter_cap {
799                break;
800            }
801            for local in [0.25, 0.5, 0.75] {
802                let fraction =
803                    parameters[index] + (parameters[index + 1] - parameters[index]) * local;
804                let uv = pcurve.evaluate(fraction)?;
805                let represented = surface.evaluate(uv.x, uv.y)?;
806                let edge_point = evaluate_edge(fraction)?;
807                if represented.sub(edge_point).length() <= tolerance {
808                    continue;
809                }
810                inserts.push((
811                    index + 1,
812                    fraction,
813                    edge_point,
814                    invert_checked(surface, edge_point)?.0,
815                ));
816            }
817        }
818        if inserts.is_empty() {
819            break;
820        }
821        for (index, fraction, edge_point, value) in inserts.into_iter().rev() {
822            parameters.insert(index, fraction);
823            edge_points.insert(index, edge_point);
824            raw.insert(index, value);
825        }
826        // Inserts are independent global inversions too — re-run the repair so
827        // a fold-flip introduced mid-refinement cannot poison the next round's
828        // deviation interpolant.
829        repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
830        pcurve = build_interpolant(surface, &raw, &parameters)?;
831    }
832    Ok(pcurve)
833}
834
835// BREP private tests: cf55fa85fe7032ca