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()).map(|i| norm_step(raw[i], raw[i - 1])).collect();
469    let endpoint_gap = steps.pop().unwrap_or(0.0);
470    steps.sort_by(f64::total_cmp);
471    let median = steps.get(steps.len() / 2).copied().unwrap_or(0.0).max(EPSILON);
472    if endpoint_gap > (4.0 * median).max(0.05) {
473        return Ok(global);
474    }
475    // Build the interpolant from the marched samples, then refine — re-projecting
476    // each insert SEEDED from the interpolant (already on the marched branch), so
477    // a mid-refinement global inversion cannot re-alias onto the far sheet.
478    let mut parameters = fractions;
479    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
480    let refinement_tolerance = 1e-3f64.min(1e-4 * scale);
481    const MAX_SAMPLES: usize = 160;
482    for _ in 0..4 {
483        if parameters.len() >= MAX_SAMPLES {
484            break;
485        }
486        let mut inserts = Vec::new();
487        for index in 0..parameters.len() - 1 {
488            if parameters.len() + inserts.len() >= MAX_SAMPLES {
489                break;
490            }
491            if parameters[index + 1] - parameters[index] < 1e-3 {
492                continue;
493            }
494            for local in [0.25, 0.5, 0.75] {
495                if parameters.len() + inserts.len() >= MAX_SAMPLES {
496                    break;
497                }
498                let fraction =
499                    parameters[index] + (parameters[index + 1] - parameters[index]) * local;
500                let seed = pcurve.evaluate(fraction)?;
501                let on_curve = curve.evaluate(t0 + (t1 - t0) * fraction)?;
502                let on_surface = surface.evaluate(seed.x.clamp(u0, u1), seed.y.clamp(v0, v1))?;
503                if on_surface.sub(on_curve).length() <= refinement_tolerance {
504                    continue;
505                }
506                let seeded = project_point_to_surface_seeded(surface, on_curve, seed.x, seed.y)?;
507                inserts.push((index + 1, fraction, [seeded.u, seeded.v]));
508            }
509        }
510        if inserts.is_empty() {
511            break;
512        }
513        for (at, fraction, uv) in inserts.into_iter().rev() {
514            parameters.insert(at, fraction);
515            raw.insert(at, uv);
516        }
517        pcurve = build_interpolant(surface, &raw, &parameters)?;
518    }
519    Ok(pcurve)
520}
521
522/// Build a pcurve for a represented subrange of a larger edge curve.  Unlike
523/// trimming the curve's homogeneous control net, this samples only the
524/// represented interval, which is essential when off-interval control points
525/// do not lie on the target carrier.
526pub fn build_pcurve_on_surface_range(
527    surface: &NurbsSurface,
528    curve: &NurbsCurve,
529    edge_start: f64,
530    edge_end: f64,
531    forward: bool,
532    tolerance: f64,
533) -> Result<NurbsCurve, String> {
534    build_pcurve_on_surface_range_dense(
535        surface, curve, edge_start, edge_end, forward, tolerance, 64, 3, 513,
536    )
537}
538
539/// Re-seat any raw inversion sample that BRANCH-JUMPED — snapped to a distant
540/// fold of a self-overlapping general carrier — back onto the branch traced by
541/// its neighbours.
542///
543/// Each entry in `raw` is an INDEPENDENT global closest-point inversion of the
544/// corresponding 3D edge sample. Every one is geometrically valid (on the
545/// surface, at the edge), but they need not be CONTIGUOUS: where a rational
546/// B-spline surface folds back over the small trimmed patch it carries, the
547/// momentarily-closest fold flips from one sample to the next, so the raw
548/// polygon zig-zags across the whole domain and self-crosses — and the region
549/// its interpolant bounds no longer covers the true face (its surface flux can
550/// exceed its own area). We anchor on the LONGEST run of mutually-continuous
551/// samples — the fold the trimmed patch actually lies on, since a small face
552/// lives on ONE fold and the jumped samples are the minority the global search
553/// snapped elsewhere — then walk outward in both directions, re-seeding Newton
554/// from the last good parameters and adopting the continuous footpoint whenever
555/// it is geometrically as valid as the global one. Anchoring on consensus (not
556/// blindly on sample 0, which can itself be an outlier that would drag the
557/// whole edge onto the wrong branch and tear the loop open at its endpoint)
558/// keeps a well-behaved fit — which has no jumps, so the run spans everything —
559/// returned byte-for-byte unchanged, and a bad seed can never make a sample
560/// worse than the global search already had it.
561fn repair_branch_jumps(
562    surface: &NurbsSurface,
563    edge_points: &[Vec3],
564    raw: &mut [[f64; 2]],
565    tolerance: f64,
566) -> Result<(), String> {
567    if raw.len() < 3 {
568        return Ok(());
569    }
570    // Affine carriers invert linearly and exactly — no folds, nothing to chase.
571    // Checked first so the common planar/affine face never touches the env.
572    if surface.is_affine()? {
573        return Ok(());
574    }
575    // Opt-out for A/B bisection of a STEP-import regression against this repair.
576    if std::env::var("BREP_NO_PCURVE_REPAIR").is_ok() {
577        return Ok(());
578    }
579    let ([u0, u1], [v0, v1]) = surface_domains(surface)?;
580    let u_span = (u1 - u0).abs().max(EPSILON);
581    let v_span = (v1 - v0).abs().max(EPSILON);
582    let (closed_u, closed_v) = surface_closedness(surface)?;
583    // Normalised, seam-aware step between two parameter samples. Wrapping the
584    // closed directions keeps a legitimate seam crossing SMALL, so the seam /
585    // periodic-unwrap machinery elsewhere is never disturbed by this repair.
586    let norm_step = |a: [f64; 2], b: [f64; 2]| -> f64 {
587        let mut du = a[0] - b[0];
588        if closed_u {
589            while du > 0.5 * u_span {
590                du -= u_span;
591            }
592            while du < -0.5 * u_span {
593                du += u_span;
594            }
595        }
596        let mut dv = a[1] - b[1];
597        if closed_v {
598            while dv > 0.5 * v_span {
599                dv -= v_span;
600            }
601            while dv < -0.5 * v_span {
602                dv += v_span;
603            }
604        }
605        ((du / u_span).powi(2) + (dv / v_span).powi(2)).sqrt()
606    };
607    // A jump crosses a large fraction of the WHOLE domain — orders above the
608    // per-sample motion of any real (even domain-spanning) edge, which advances
609    // ~1/N of its traversal between consecutive stations.
610    const JUMP_THRESHOLD: f64 = 0.2;
611    let count = raw.len();
612
613    // Locate the longest maximal run of consecutive continuous samples — the
614    // branch to anchor on. If nothing jumps, this spans [0, count-1] and both
615    // re-seat passes below are empty, leaving `raw` untouched.
616    let (mut best_start, mut best_len, mut run_start) = (0usize, 1usize, 0usize);
617    for index in 1..count {
618        if norm_step(raw[index], raw[index - 1]) > JUMP_THRESHOLD {
619            if index - run_start > best_len {
620                best_len = index - run_start;
621                best_start = run_start;
622            }
623            run_start = index;
624        }
625    }
626    if count - run_start > best_len {
627        best_len = count - run_start;
628        best_start = run_start;
629    }
630    let (spine_lo, spine_hi) = (best_start, best_start + best_len - 1);
631
632    // Adopt the continuous footpoint only when it (a) still sits on the surface
633    // — as tight as the fit band or the global answer — and (b) genuinely
634    // closes the jump rather than trading it for another. Returns the parameter
635    // to carry forward as the next seed (the repaired one, or the untouched
636    // global when no repair applies).
637    let reseat = |edge_point: Vec3,
638                  previous: [f64; 2],
639                  global: [f64; 2]|
640     -> Result<[f64; 2], String> {
641        let global_step = norm_step(global, previous);
642        if global_step <= JUMP_THRESHOLD {
643            return Ok(global);
644        }
645        let seeded =
646            project_point_to_surface_seeded(surface, edge_point, previous[0], previous[1])?;
647        let candidate = [seeded.u, seeded.v];
648        let global_residual = surface
649            .evaluate(global[0], global[1])?
650            .sub(edge_point)
651            .length();
652        let on_surface = seeded.distance <= 4.0 * global_residual + tolerance.max(1e-12);
653        if on_surface && norm_step(candidate, previous) < 0.5 * global_step {
654            if std::env::var("BREP_DEBUG_PCURVE").is_ok() {
655                eprintln!(
656                    "pcurve repair: jump {global_step:.4} ({global:?}) -> {:.4} ({candidate:?}) res {:.2e}->{:.2e}",
657                    norm_step(candidate, previous),
658                    global_residual,
659                    seeded.distance
660                );
661            }
662            Ok(candidate)
663        } else {
664            Ok(global)
665        }
666    };
667
668    // Walk forward off the spine's high end, then backward off its low end,
669    // chaining each repaired sample as the next seed so continuity propagates.
670    //
671    // The two ENDPOINT samples (fraction 0 and 1) are NEVER moved: they are the
672    // edge's shared loop vertices. The global inversion is deterministic, so the
673    // two coedges meeting at a vertex land it at the SAME parameters even when
674    // that vertex sits on a fold reachable from two branches — moving one side
675    // to a different branch tears the loop open there (`synthesize_pole_edge`
676    // then rejects the non-collapsed gap). A vertex's genuine fold transition
677    // (an edge whose interior rides u≈0.08 but whose endpoint must meet its
678    // neighbour at u≈0.95) is exactly this case and must be preserved, not
679    // "continuity-repaired" back onto the interior branch.
680    let mut previous = raw[spine_hi];
681    for index in (spine_hi + 1)..count.saturating_sub(1) {
682        raw[index] = reseat(edge_points[index], previous, raw[index])?;
683        previous = raw[index];
684    }
685    let mut previous = raw[spine_lo];
686    for index in (1..spine_lo).rev() {
687        raw[index] = reseat(edge_points[index], previous, raw[index])?;
688        previous = raw[index];
689    }
690    Ok(())
691}
692
693/// Range fitter with explicit sampling knobs. The default entry above keeps
694/// the long-standing (base 64, 3 refinement rounds, 513 cap) budget; STEP
695/// import retries failed fits with a denser budget — a vendor spline whose
696/// parameter speed varies by orders of magnitude across a corner can hide a
697/// deviation spike the coarse refinement cannot chase down.
698#[allow(clippy::too_many_arguments)]
699pub fn build_pcurve_on_surface_range_dense(
700    surface: &NurbsSurface,
701    curve: &NurbsCurve,
702    edge_start: f64,
703    edge_end: f64,
704    forward: bool,
705    tolerance: f64,
706    base_samples: usize,
707    refinement_rounds: usize,
708    parameter_cap: usize,
709) -> Result<NurbsCurve, String> {
710    if !(edge_start.is_finite() && edge_end.is_finite() && edge_start < edge_end) {
711        return Err("build_pcurve_on_surface_range: invalid edge interval".into());
712    }
713    let evaluate_edge = |fraction: f64| {
714        let edge_fraction = if forward { fraction } else { 1.0 - fraction };
715        curve.evaluate(edge_start + (edge_end - edge_start) * edge_fraction)
716    };
717    let mut parameters = (0..=base_samples)
718        .map(|index| index as f64 / base_samples as f64)
719        .collect::<Vec<_>>();
720    // The 3D edge point behind each raw inversion sample, kept parallel so the
721    // continuity repair can re-seed a jumped station from its own footpoint.
722    let mut edge_points = parameters
723        .iter()
724        .map(|fraction| evaluate_edge(*fraction))
725        .collect::<Result<Vec<_>, _>>()?;
726    let mut raw = edge_points
727        .iter()
728        .map(|point| invert_checked(surface, *point).map(|value| value.0))
729        .collect::<Result<Vec<_>, _>>()?;
730    repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
731    let mut pcurve = build_interpolant(surface, &raw, &parameters)?;
732    for _ in 0..refinement_rounds {
733        let mut inserts = Vec::new();
734        for index in 0..parameters.len() - 1 {
735            if parameters.len() + inserts.len() >= parameter_cap {
736                break;
737            }
738            for local in [0.25, 0.5, 0.75] {
739                let fraction =
740                    parameters[index] + (parameters[index + 1] - parameters[index]) * local;
741                let uv = pcurve.evaluate(fraction)?;
742                let represented = surface.evaluate(uv.x, uv.y)?;
743                let edge_point = evaluate_edge(fraction)?;
744                if represented.sub(edge_point).length() <= tolerance {
745                    continue;
746                }
747                inserts.push((
748                    index + 1,
749                    fraction,
750                    edge_point,
751                    invert_checked(surface, edge_point)?.0,
752                ));
753            }
754        }
755        if inserts.is_empty() {
756            break;
757        }
758        for (index, fraction, edge_point, value) in inserts.into_iter().rev() {
759            parameters.insert(index, fraction);
760            edge_points.insert(index, edge_point);
761            raw.insert(index, value);
762        }
763        // Inserts are independent global inversions too — re-run the repair so
764        // a fold-flip introduced mid-refinement cannot poison the next round's
765        // deviation interpolant.
766        repair_branch_jumps(surface, &edge_points, &mut raw, tolerance)?;
767        pcurve = build_interpolant(surface, &raw, &parameters)?;
768    }
769    Ok(pcurve)
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use crate::{make_cylinder_surface, make_line, make_plane, make_sphere_surface};
776
777    #[test]
778    fn affine_pcurve_preserves_curve_representation() {
779        let surface = make_plane(
780            Vec3::new(2.0, -3.0, 5.0),
781            Vec3::new(1.0, 0.2, 0.0),
782            Vec3::new(0.0, 0.3, 1.0),
783            7.0,
784            4.0,
785        )
786        .unwrap();
787        let curve = make_line(
788            surface.evaluate(0.2, 0.7).unwrap(),
789            surface.evaluate(0.9, 0.1).unwrap(),
790        )
791        .unwrap();
792        let pcurve = build_pcurve_on_surface(&surface, &curve).unwrap();
793        assert_eq!(pcurve.degree, curve.degree);
794        assert_eq!(pcurve.knots, curve.knots);
795        for fraction in [0.0, 0.2, 0.7, 1.0] {
796            let uv = pcurve.evaluate(fraction).unwrap();
797            let expected = curve.evaluate(fraction).unwrap();
798            assert!(surface.evaluate(uv.x, uv.y).unwrap().sub(expected).length() < 1e-10);
799        }
800    }
801
802    #[test]
803    fn cylinder_pcurve_tracks_iso_curve_across_seam() {
804        let surface =
805            make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 5.0).unwrap();
806        let curve = surface.iso_curve_v(0.4).unwrap();
807        let pcurve = build_pcurve_on_surface(&surface, &curve).unwrap();
808        for index in 0..=64 {
809            let fraction = index as f64 / 64.0;
810            let uv = pcurve.evaluate(fraction).unwrap();
811            assert!(
812                surface
813                    .evaluate(uv.x, uv.y)
814                    .unwrap()
815                    .sub(curve.evaluate(fraction).unwrap())
816                    .length()
817                    < 1e-4
818            );
819        }
820    }
821
822    #[test]
823    fn sphere_pole_does_not_choose_the_adjacent_meridian_unwrap() {
824        let surface =
825            make_sphere_surface(Vec3::default(), 0.009, Vec3::new(0.0, 0.0, 1.0)).unwrap();
826        // Reduced from ABC 00005360 edge 12: inversion assigns u=0 to the
827        // north pole, while the first nonsingular samples lie on u=0.75. A
828        // normal periodic unwrap uses the meaningless pole u and changes that
829        // meridian to -0.25; analytic clamping then collapses it onto u=0.
830        let raw = [
831            [0.0, 1.0],
832            [0.75, 0.959],
833            [0.75, 0.60],
834            [0.75, 0.02],
835            [0.25, 0.0005],
836            [0.25, 0.045],
837            [0.25, 0.282],
838        ];
839        let stations = [0.0, 0.1, 0.4, 0.65, 0.7, 0.8, 1.0];
840        let pcurve = build_interpolant(&surface, &raw, &stations).unwrap();
841
842        for (station, expected) in stations.into_iter().zip(raw) {
843            let uv = pcurve.evaluate(station).unwrap();
844            assert!((uv.x - expected[0]).abs() < 1e-12, "u at {station}: {uv:?}");
845            assert!((uv.y - expected[1]).abs() < 1e-12, "v at {station}: {uv:?}");
846        }
847    }
848
849    #[test]
850    fn sphere_interior_pole_keeps_exactly_opposite_meridian_runs_independent() {
851        let surface =
852            make_sphere_surface(Vec3::default(), 0.0127, Vec3::new(0.0, 0.0, 1.0)).unwrap();
853        // Reduced from ABC 00005603 edge 2567. The edge crosses the south pole
854        // between exactly opposite meridians. A whole-curve unwrap lets tiny
855        // error around the half-period tie lift one run and moves its equator
856        // endpoint from u=0.75 to the u=0 seam.
857        let raw = [
858            [0.75, 0.5],
859            [0.75, 0.2],
860            [0.75, 0.02],
861            [0.25, 0.0005],
862            [0.25, 0.02],
863            [0.25, 0.0665598],
864        ];
865        let stations = [0.0, 0.3, 0.48, 0.52, 0.7, 1.0];
866        let pcurve = build_interpolant(&surface, &raw, &stations).unwrap();
867
868        for (station, expected) in stations.into_iter().zip(raw) {
869            let uv = pcurve.evaluate(station).unwrap();
870            assert!((uv.x - expected[0]).abs() < 1e-12, "u at {station}: {uv:?}");
871            assert!((uv.y - expected[1]).abs() < 1e-12, "v at {station}: {uv:?}");
872        }
873    }
874}