Skip to main content

ogeom_algo/
pcurve_fit.rs

1//! Pcurves fitted by projection, for faces whose trims have no closed form.
2//!
3//! An exchange file's edge must end up with a curve in each bounding face's
4//! parameters, or the face cannot be split or triangulated. Where the
5//! curve/surface pair has a closed form the exact projection is used; where
6//! it does not (a spline surface, mostly), the pcurve is *fitted at the
7//! curve's own parameters*: sample the edge, project each sample into the
8//! chart, fit the trace with the parameters held fixed, so the same-parameter
9//! law holds by construction. This honours the standing decision that an
10//! exact curve never carries a fitted pcurve silently: the fit's error is
11//! returned, and the callers widen tolerances and warn with it. That error
12//! is reported as a *length*, in the model's own units: the fitted pcurve is
13//! walked through the surface and compared against the trace it was fitted
14//! to. A chart's units are whatever the file chose, and no single scale
15//! converts them: a patch can span four microns across its `u` and ten
16//! millimetres along its `v`.
17
18use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19use ogeom_geom::Curve3d as _;
20use ogeom_geom::Surface as _;
21use ogeom_geom::{Curve, PlanarCurve, SurfaceGeometry};
22use ogeom_math::Point;
23
24/// The fitted pcurve, its fit error as a length, whether the target was met,
25/// the worst distance any sample sat from the surface, and the slop warning
26/// to record when that distance is large enough to say out loud.
27pub type FittedPcurve = OgeomResult<(PlanarCurve, f64, bool, f64, Option<String>)>;
28
29pub(crate) fn chart_of(surface: &SurfaceGeometry, p: Point) -> Option<ogeom_math::Point2> {
30    let tau = core::f64::consts::TAU;
31    match surface {
32        SurfaceGeometry::Plane(s) => {
33            let l = s.plane().frame().to_local(p);
34            Some(ogeom_math::Point2::new(l.x, l.y))
35        }
36        SurfaceGeometry::Cylinder(s) => {
37            let l = s.cylinder().frame().to_local(p);
38            Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
39        }
40        SurfaceGeometry::Cone(s) => {
41            let l = s.cone().frame().to_local(p);
42            Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), l.z))
43        }
44        SurfaceGeometry::Sphere(s) => {
45            let sphere = s.sphere();
46            let l = sphere.frame().to_local(p);
47            let lat = (l.z / sphere.radius()).clamp(-1.0, 1.0).asin();
48            Some(ogeom_math::Point2::new(l.y.atan2(l.x).rem_euclid(tau), lat))
49        }
50        SurfaceGeometry::Torus(s) => {
51            let torus = s.torus();
52            let l = torus.frame().to_local(p);
53            let u = l.y.atan2(l.x).rem_euclid(tau);
54            let radial = l.x.hypot(l.y) - torus.major_radius();
55            let v = l.z.atan2(radial).rem_euclid(tau);
56            Some(ogeom_math::Point2::new(u, v))
57        }
58        _ => None,
59    }
60}
61
62/// Where a point of the curve lands on the surface: its chart position and
63/// how far off the surface it sat.
64///
65/// Analytic surfaces invert in closed form: grid seeding over a plane's
66/// or cylinder's enormous stated extents lands microns off, and a fitted
67/// pcurve inherits every micron. On a patch, where the previous sample
68/// landed is a far better starting guess than any grid: consecutive
69/// samples of a curve are neighbouring points of the surface. Trusted only
70/// when it lands convincingly *on* the surface (the same bar the denser
71/// reseed is judged against), so a guess that wandered into the wrong
72/// basin, or a first sample with no predecessor, still pays for the grid.
73fn land(
74    surface: &SurfaceGeometry,
75    p: Point,
76    seed: Option<ogeom_math::Point2>,
77    tol: Tolerances,
78) -> OgeomResult<(ogeom_math::Point2, f64)> {
79    if let Some(uv) = chart_of(surface, p) {
80        let lifted = surface.point_at(uv.x, uv.y, tol)?;
81        return Ok((uv, p.distance(lifted)));
82    }
83    let near = seed.and_then(|luv| {
84        crate::measure::project_on_surface_from(surface, p, (luv.x, luv.y), tol).ok()
85    });
86    if let Some(close) = near.filter(|f| f.distance <= tol.confusion() * 1e5) {
87        return Ok((
88            ogeom_math::Point2::new(close.parameters.0, close.parameters.1),
89            close.distance,
90        ));
91    }
92    let mut projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
93    if projection.distance > tol.confusion() * 1e5 {
94        // A miss this large on a spline surface is more often a projection
95        // stuck in the wrong basin than real slop; seed denser before
96        // believing it.
97        let denser = crate::measure::project_on_surface(surface, p, 96, tol)?;
98        if denser.distance < projection.distance {
99            projection = denser;
100        }
101    }
102    Ok((
103        ogeom_math::Point2::new(projection.parameters.0, projection.parameters.1),
104        projection.distance,
105    ))
106}
107
108/// A sample of the curve landed on the surface: the parameter, the point,
109/// where it landed in the chart, and how far off the surface it sat.
110type Landed = (f64, Point, ogeom_math::Point2, f64);
111
112/// Fit a pcurve by projection at the reader's own line: slop under a
113/// millimetre is a file's error, honestly carried; anything past it is a
114/// wrong pairing and refuses. The exchange readers call this; a healer
115/// acting on instruction calls [`fit_projected_pcurve_capped`] with the
116/// cap its caller chose.
117///
118/// # Errors
119///
120/// As [`fit_projected_pcurve_capped`], at the millimetre cap.
121pub fn fit_projected_pcurve(
122    curve: &Curve,
123    range: (f64, f64),
124    surface: &SurfaceGeometry,
125    tol: Tolerances,
126) -> FittedPcurve {
127    fit_projected_pcurve_capped(curve, range, surface, tol.confusion() * 1e7, tol)
128}
129
130/// As the reader's projected-pcurve fit, with the acceptance cap in the
131/// caller's hands.
132///
133/// The reader draws its line at a millimetre (below it is a file's own
134/// slop, above it a wrong pairing), but a *healer* acts on instruction, and
135/// the instruction carries the cap. Returns the fitted pcurve, the fit's
136/// reached error as a length, whether it met its target, the worst measured
137/// edge-to-surface offset, and the slop note when that offset is worth
138/// saying out loud.
139///
140/// # Errors
141///
142/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
143/// sample sits farther than `cap` from the surface, or the projection
144/// cannot converge at all.
145pub fn fit_projected_pcurve_capped(
146    curve: &Curve,
147    range: (f64, f64),
148    surface: &SurfaceGeometry,
149    cap: f64,
150    tol: Tolerances,
151) -> FittedPcurve {
152    const SAMPLES: usize = 96;
153    let mut parameters = Vec::with_capacity(SAMPLES + 1);
154    let mut trace = Vec::with_capacity(SAMPLES + 1);
155    let mut points = Vec::with_capacity(SAMPLES + 1);
156    let mut offs = Vec::with_capacity(SAMPLES + 1);
157    let mut previous: Option<(Point, ogeom_math::Point2)> = None;
158    for i in 0..=SAMPLES {
159        #[allow(clippy::cast_precision_loss)]
160        let t = range.0 + (range.1 - range.0) * i as f64 / SAMPLES as f64;
161        let p = curve.point_at(t, tol)?;
162        let (uv, off) = land(surface, p, previous.map(|(_, luv)| luv), tol)?;
163        previous = Some((p, uv));
164        parameters.push(t);
165        trace.push(uv);
166        points.push(p);
167        offs.push(off);
168    }
169    retry_stalled(surface, &points, &mut trace, &mut offs, tol);
170    // The cap separates a file's own slop from an edge paired with the
171    // wrong surface. Slop is routinely a micron or two, but real community
172    // exports carry as much as 0.34 mm, while a wrong pairing
173    // misses by the distance between two different surfaces of the body,
174    // whole millimetres. One millimetre stands between the worst slop
175    // observed and the smallest wrong pairing plausible. Slop inside the
176    // cap is accepted and *recorded*: the edge's tolerance is widened to
177    // cover it, so the model says what it knows instead of refusing to
178    // triangulate. Judged after the retry, so a projection that stalled
179    // cannot refuse an edge that is actually on its surface.
180    let worst_off = offs.iter().copied().fold(0.0_f64, f64::max);
181    if worst_off > cap {
182        ogeom_bail!(
183            Construction,
184            "the edge sits {worst_off:.2e} from the surface it should bound"
185        );
186    }
187    let mut space_run = 0.0;
188    let mut parameter_run = 0.0;
189    for i in 1..trace.len() {
190        space_run += points[i].distance(points[i - 1]);
191        parameter_run += trace[i].distance(trace[i - 1]);
192    }
193    // A trace on a periodic chart may cross the seam mid-edge; unwrap it
194    // pointwise so the fit sees a continuous curve. Closure, not
195    // periodicity, is the right test: a skinned loft's wall is a clamped
196    // B-spline that closes on itself without being periodic, and its
197    // projections near the joining column land in either copy: both
198    // answers are right pointwise, and only continuity chooses. This is
199    // the docs/PLAN.md F5 case, and it is decided here for both exchange
200    // readers at once.
201    let ((ua, ub), (va, vb)) = surface.domain();
202    let spans = (
203        if surface.is_periodic_u() || surface.is_closed_u(tol) {
204            ub - ua
205        } else {
206            0.0
207        },
208        if surface.is_periodic_v() || surface.is_closed_v(tol) {
209            vb - va
210        } else {
211            0.0
212        },
213    );
214    for i in 1..trace.len() {
215        if spans.0 > 0.0 {
216            while trace[i].x - trace[i - 1].x > spans.0 * 0.5 {
217                trace[i].x -= spans.0;
218            }
219            while trace[i].x - trace[i - 1].x < -spans.0 * 0.5 {
220                trace[i].x += spans.0;
221            }
222        }
223        if spans.1 > 0.0 {
224            while trace[i].y - trace[i - 1].y > spans.1 * 0.5 {
225                trace[i].y -= spans.1;
226            }
227            while trace[i].y - trace[i - 1].y < -spans.1 * 0.5 {
228                trace[i].y += spans.1;
229            }
230        }
231    }
232    slide_into_chart(&mut trace, spans, ((ua, ub), (va, vb)));
233    // Where the chart collapses (a sphere's pole, a cone's apex), the
234    // u of a sample is atan2 of noise: the point determines no angle.
235    // The *arc* does: a smooth curve through the pole approaches it at
236    // a definite chart angle, which is the limit of its well-conditioned
237    // neighbours. Samples whose u-direction has collapsed relative to
238    // their v-direction are repaired by interpolating u between the
239    // nearest sound samples, extrapolating at the ends.
240    //
241    // Weak is measured in millimetres, not against `dv`. A ratio calls a
242    // direction weak whenever the *other* one is strong, and a patch whose
243    // `v` is parameterised a thousand times more densely than its `u`
244    // (three millimetres over twelve thousandths of a unit, beside a unit of
245    // `u` for a little over half a millimetre) had every sample of every
246    // edge called weak, its `u` held at one value, and two edges half a
247    // millimetre long fitted as a single point. What makes a direction
248    // degenerate is that crossing the whole of it moves the point less than
249    // a micron; that question has an answer in length, and only in length.
250    let (u_span, _) = {
251        let ((ua, ub), (va, vb)) = surface.domain();
252        (ub - ua, vb - va)
253    };
254    let weak: Vec<bool> = trace
255        .iter()
256        .map(|uv| {
257            surface
258                .d1_at(uv.x, uv.y, tol)
259                .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
260        })
261        .collect();
262    if weak.iter().all(|w| *w) && !weak.is_empty() {
263        // Not a row that collapses but a whole patch that does: a sliver
264        // four microns wide and a tenth of a millimetre long, where `u` is
265        // noise everywhere and the projector answers 0 at one sample and 1
266        // at the next. A fit through that swings across the chart and its
267        // controls are dragged back by hundreds of units. Any `u` describes
268        // the same points to within the sliver's own width, so they all
269        // take one: the middle of what the projections claimed, which is
270        // the least arbitrary of the arbitrary answers.
271        let mut claimed: Vec<f64> = trace.iter().map(|uv| uv.x).collect();
272        claimed.sort_by(f64::total_cmp);
273        let held = claimed[claimed.len() / 2];
274        for uv in &mut trace {
275            uv.x = held;
276        }
277    } else if weak.iter().any(|w| *w) && weak.iter().filter(|w| !**w).count() >= 2 {
278        let strong: Vec<usize> = (0..trace.len()).filter(|&i| !weak[i]).collect();
279        let u_span = if surface.is_periodic_u() {
280            ua.max(ub) - ua.min(ub)
281        } else {
282            f64::INFINITY
283        };
284        for i in 0..trace.len() {
285            if !weak[i] {
286                continue;
287            }
288            let after = strong.iter().position(|&s| s > i);
289            let (a, b) = match after {
290                Some(0) => (strong[0], strong[1]),
291                Some(k) => (strong[k - 1], strong[k]),
292                None => (strong[strong.len() - 2], strong[strong.len() - 1]),
293            };
294            // A curve *through* the pole genuinely jumps its angle
295            // there; only a run whose sound neighbours agree is noise
296            // to smooth over.
297            if a < i && i < b && (trace[b].x - trace[a].x).abs() > u_span * 0.25 {
298                continue;
299            }
300            let (ta, tb) = (parameters[a], parameters[b]);
301            let f = if (tb - ta).abs() <= f64::MIN_POSITIVE {
302                0.0
303            } else {
304                (parameters[i] - ta) / (tb - ta)
305            };
306            trace[i].x = trace[a].x + (trace[b].x - trace[a].x) * f;
307        }
308    }
309
310    // The tolerance carried into the chart through the trace's own
311    // metric: the honest cheap version, refined by the fit's report.
312    let scale = if space_run > tol.confusion() {
313        parameter_run / space_run
314    } else {
315        1.0
316    };
317    let target = (tol.confusion() * 1e2 * scale).max(f64::MIN_POSITIVE);
318    let fit_and_clamp = |parameters: &[f64],
319                         trace: &[ogeom_math::Point2]|
320     -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>> {
321        let fitted = ogeom_geom::fit::fit_points_2d_at(parameters, trace, 3, target, tol)?;
322        // A least-squares fit wiggles past its samples at the ends, and a
323        // surface with a *tight* stated window (an imported patch, not a
324        // reader-built analytic with its enormous extents) refuses
325        // evaluation a hair outside it. The control points clamp into the
326        // window on the non-periodic axes: the curve lives in its controls'
327        // hull, so the clamp is a guarantee. Whatever the clamp cost is not
328        // hidden either: it is the clamped curve that is measured below.
329        // Periodic axes stay free: an unwrapped trace crosses the seam on
330        // purpose.
331        let ((wa, wb), (va2, vb2)) = surface.domain();
332        let clamp_u = !(surface.is_periodic_u() || surface.is_closed_u(tol));
333        let clamp_v = !(surface.is_periodic_v() || surface.is_closed_v(tol));
334        if !(clamp_u || clamp_v) {
335            return Ok(fitted);
336        }
337        let mut moved = 0.0_f64;
338        let knots = fitted.curve.knots().clone();
339        let control: Vec<ogeom_math::Point2> = fitted
340            .curve
341            .control_points()
342            .iter()
343            .map(|w| {
344                let p = w.point();
345                let q = ogeom_math::Point2::new(
346                    if clamp_u { p.x.clamp(wa, wb) } else { p.x },
347                    if clamp_v { p.y.clamp(va2, vb2) } else { p.y },
348                );
349                moved = moved.max(p.distance(q));
350                q
351            })
352            .collect();
353        if moved > 0.0 {
354            Ok(ogeom_geom::fit::Fitted {
355                curve: ogeom_geom::BSpline2d::new(knots, control, tol)?,
356                ..fitted
357            })
358        } else {
359            Ok(fitted)
360        }
361    };
362    // What the caller is told, as a length. The fitter reports its error
363    // in *chart* units, and a chart's units are whatever the file chose: one
364    // patch met in the wild spans four microns across its `u` and ten
365    // millimetres along its `v`, so no single scale converts the one number
366    // into the other: a control point dragged back into that chart by the
367    // clamp read as seven hundred millimetres of mesh error, on a face a
368    // tenth of a millimetre across. So the fitted curve is walked instead,
369    // through the surface, against the trace it was fitted to: that
370    // difference is the fit's own and is measured where the mesh will be.
371    //
372    // Measured between the samples as well as at them. The samples are
373    // spaced evenly in the curve's parameter, and a curve is free to run
374    // sixteen times faster at one end than the other: a blade's root
375    // meeting a hub turns through most of its bend inside the first
376    // interval, and a cubic held only at the interval's ends hooked four
377    // tenths of a millimetre past the curve there, off the face and across
378    // its neighbouring ring, while every sample sat within a hundredth. A
379    // midpoint the fit leaves is projected and joins the samples, and the
380    // fit is asked again, a few rounds at most.
381    let closed_form = points
382        .first()
383        .is_some_and(|p| chart_of(surface, *p).is_some());
384    let deviation = |fitted: &ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
385                     parameters: &[f64],
386                     trace: &[ogeom_math::Point2],
387                     offs: &[f64]|
388     -> (f64, f64, Vec<Landed>) {
389        let mut error = 0.0_f64;
390        let mut between_all = 0.0_f64;
391        let mut more = Vec::new();
392        let mut landed_middles = Vec::new();
393        let mut left = false;
394        // A landing is believed only where it belongs: on the surface as
395        // convincingly as its neighbours, and inside the chart interval
396        // they span, widened by the interval itself. A projection that
397        // settled in another basin, or on the far side of a seam, would
398        // otherwise be fitted as if the curve went there. Beside its
399        // neighbour on a periodic chart, as the trace was unwrapped; where
400        // the chart collapses its angle is noise, and the neighbours' is
401        // taken.
402        let landing = |index: usize, tm: f64| -> Option<Landed> {
403            let before = trace[index - 1];
404            let after = trace[index];
405            let p = curve.point_at(tm, tol).ok()?;
406            let (mut uv, off) = land(surface, p, Some(before), tol).ok()?;
407            if spans.0 > 0.0 {
408                while uv.x - before.x > spans.0 * 0.5 {
409                    uv.x -= spans.0;
410                }
411                while uv.x - before.x < -spans.0 * 0.5 {
412                    uv.x += spans.0;
413                }
414            }
415            if spans.1 > 0.0 {
416                while uv.y - before.y > spans.1 * 0.5 {
417                    uv.y -= spans.1;
418                }
419                while uv.y - before.y < -spans.1 * 0.5 {
420                    uv.y += spans.1;
421                }
422            }
423            if surface
424                .d1_at(uv.x, uv.y, tol)
425                .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
426            {
427                uv.x = 0.5 * (before.x + after.x);
428            }
429            let reach = before.distance(after).max(f64::EPSILON);
430            let mid =
431                ogeom_math::Point2::new(0.5 * (before.x + after.x), 0.5 * (before.y + after.y));
432            let sound = offs[index - 1].max(offs[index]).max(tol.confusion() * 1e5);
433            (off <= 2.0 * sound && mid.distance(uv) <= reach).then_some((tm, p, uv, off))
434        };
435        for (index, t) in parameters.iter().enumerate() {
436            let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, *t, tol) else {
437                continue;
438            };
439            let (Ok(fitted_at), Ok(traced_at)) = (
440                surface.point_at(at.x, at.y, tol),
441                surface.point_at(trace[index].x, trace[index].y, tol),
442            ) else {
443                continue;
444            };
445            error = error.max(fitted_at.distance(traced_at));
446            if index == 0 {
447                continue;
448            }
449            // Probed at the quarters as well as the middle where the chart
450            // inverts in closed form, which costs nothing: a hook sits
451            // where the curve turns, wherever in the interval that is. On a
452            // patch every landing is a projection, and the middle alone is
453            // asked: a hook is the cubic's own excursion, broad across the
454            // interval, and once the fit is asked again at twice the
455            // samples the quarters of this round are the middles of the
456            // next.
457            let (ta, tb) = (parameters[index - 1], *t);
458            let tm = 0.5 * (ta + tb);
459            let quarters = [0.5 * (ta + tm), tm, 0.5 * (tm + tb)];
460            let probes: &[f64] = if closed_form {
461                &quarters
462            } else {
463                &quarters[1..2]
464            };
465            for &probe in probes {
466                let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, probe, tol) else {
467                    continue;
468                };
469                let Ok(fitted_at) = surface.point_at(at.x, at.y, tol) else {
470                    continue;
471                };
472                if !closed_form {
473                    // Against the curve's own point first, which costs no
474                    // projection: the curve sits about as far off the
475                    // surface here as at the samples either side, so a
476                    // fitted point within the bar and that slop of it is
477                    // within the bar of where the curve lands. A hook worth
478                    // the name is hundreds of times the slop; only a probe
479                    // that misses by more than the slop is projected.
480                    let Ok(p) = curve.point_at(probe, tol) else {
481                        continue;
482                    };
483                    let slop = offs[index - 1].max(offs[index]);
484                    let rough = fitted_at.distance(p);
485                    if rough <= tol.confusion() * 1e4 + slop {
486                        between_all = between_all.max((rough - slop).max(0.0));
487                        continue;
488                    }
489                }
490                let Some(landed) = landing(index, probe) else {
491                    continue;
492                };
493                let Ok(traced_at) = surface.point_at(landed.2.x, landed.2.y, tol) else {
494                    continue;
495                };
496                let between = fitted_at.distance(traced_at);
497                between_all = between_all.max(between);
498                if between > tol.confusion() * 1e4 {
499                    left = true;
500                }
501                if probe == tm {
502                    landed_middles.push(landed);
503                }
504            }
505        }
506        // The fit is asked again at twice the samples everywhere, the
507        // middle of every interval joining them, landed now where the
508        // rough test spared it the projection.
509        if left {
510            more = landed_middles;
511            if !closed_form {
512                more = (1..parameters.len())
513                    .filter_map(|index| {
514                        landing(index, 0.5 * (parameters[index - 1] + parameters[index]))
515                    })
516                    .collect();
517            }
518        }
519        (error, between_all, more)
520    };
521    // A micron between samples is the bar, a tenth of the finest chord a
522    // mesh is asked for: the hook was four hundred times that. Every fit
523    // pays one pass of probes; only the few that leave the curve pay a
524    // refit, at twice the samples everywhere (a handful of new samples
525    // in one interval draw the fitter's knots to themselves and the curve
526    // wobbles on either side, while an even doubling keeps it steady), and
527    // a curve the fit cannot follow, a corner inside the edge, stops at a
528    // few hundred samples rather than doubling for ever.
529    const DENSIFY: usize = 6;
530    const MOST: usize = 512;
531    let mut fitted = fit_and_clamp(&parameters, &trace)?;
532    let mut best: Option<(ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>, f64, f64)> = None;
533    let mut round = 0;
534    loop {
535        let (at_samples, between, more) = deviation(&fitted, &parameters, &trace, &offs);
536        // The best round stands, whichever it was: a refit is not obliged
537        // to improve, and the fit handed on is the one measured closest.
538        if best
539            .as_ref()
540            .is_none_or(|(_, a, b)| at_samples.max(between) < a.max(*b))
541        {
542            best = Some((fitted.clone(), at_samples, between));
543        }
544        if more.is_empty() || round == DENSIFY || parameters.len() >= MOST {
545            break;
546        }
547        for (tm, p, uv, off) in more {
548            let k = parameters.partition_point(|&t| t < tm);
549            parameters.insert(k, tm);
550            trace.insert(k, uv);
551            points.insert(k, p);
552            offs.insert(k, off);
553        }
554        fitted = fit_and_clamp(&parameters, &trace)?;
555        round += 1;
556    }
557    let (fitted, at_samples, between) = best.unwrap_or((fitted, f64::INFINITY, f64::INFINITY));
558    let worst_off = offs.iter().copied().fold(worst_off, f64::max);
559    // Each miss against its own bar: the samples hold the fit to a hair,
560    // and the probes between them to the micron that sent them back.
561    let error = at_samples.max(between);
562    let met = at_samples <= tol.confusion() * 1e2 && between <= tol.confusion() * 1e4;
563    let slop = (worst_off > tol.confusion() * 1e3).then(|| {
564        format!(
565            "an edge sits up to {worst_off:.2e} from the surface it \
566             bounds; the file's own slop, carried into the chart"
567        )
568    });
569    Ok((fitted.curve.into(), error, met, worst_off, slop))
570}
571
572/// Re-project the samples a stalled projection left behind.
573///
574/// Where a chart collapses (a spline patch whose whole `v = 0` row is a
575/// single point), the projector has no direction to move in, and it answers
576/// with the pole's own parameters and the distance to it. Four consecutive
577/// samples of one imported edge came back pinned to such a row, the last of
578/// them a tenth of a millimetre out; the reader repeated that as the file's
579/// own boundary slop, widened the edge to cover it, and the fitter tried to
580/// draw a curve through it. A sample that landed badly is retried from a
581/// neighbour that landed well, the same seeding the forward walk already
582/// trusts, run in both directions so a run of them unwinds from whichever
583/// end is sound. The retry is kept only when it lands closer, so it can
584/// never make an honest projection worse: a file's real slop is left alone.
585fn retry_stalled(
586    surface: &SurfaceGeometry,
587    points: &[Point],
588    trace: &mut [ogeom_math::Point2],
589    offs: &mut [f64],
590    tol: Tolerances,
591) {
592    let sound = tol.confusion() * 1e5;
593    if offs.iter().all(|off| *off <= sound) {
594        return;
595    }
596    for backwards in [true, false] {
597        let order: Vec<usize> = if backwards {
598            (0..offs.len()).rev().collect()
599        } else {
600            (0..offs.len()).collect()
601        };
602        for i in order {
603            if offs[i] <= sound {
604                continue;
605            }
606            let Some(j) = (if backwards {
607                i.checked_add(1)
608            } else {
609                i.checked_sub(1)
610            }) else {
611                continue;
612            };
613            if offs.get(j).is_none_or(|off| *off > sound) {
614                continue;
615            }
616            let seed = (trace[j].x, trace[j].y);
617            if let Ok(found) =
618                crate::measure::project_on_surface_from(surface, points[i], seed, tol)
619                && found.distance < offs[i]
620            {
621                trace[i] = ogeom_math::Point2::new(found.parameters.0, found.parameters.1);
622                offs[i] = found.distance;
623            }
624        }
625    }
626}
627
628/// Slide a trace back into its chart by whole turns.
629///
630/// Unwrapped for continuity, a trace can end up a whole turn outside the
631/// chart it belongs to: a projection that starts near one edge of a closed
632/// chart and walks off it keeps walking, and the surface then refuses to be
633/// evaluated where its own trim lies: an imported face whose
634/// fitted v ran to −2.5π on a chart that stops at −π, and drew as a hole.
635///
636/// A rigid shift keeps the trace exactly as continuous as the unwrap left
637/// it and can only move it inward. One that genuinely spans more than a
638/// turn has nowhere to go and is left alone; one that fits nowhere whole
639/// takes the turn that centres it, which is the nearest thing to inside
640/// there is.
641fn slide_into_chart(
642    trace: &mut [ogeom_math::Point2],
643    spans: (f64, f64),
644    domain: ((f64, f64), (f64, f64)),
645) {
646    let ((ua, ub), (va, vb)) = domain;
647    for (across, span, lo, hi) in [(true, spans.0, ua, ub), (false, spans.1, va, vb)] {
648        if span <= 0.0 {
649            continue;
650        }
651        let read = |uv: &ogeom_math::Point2| if across { uv.x } else { uv.y };
652        let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
653        for uv in trace.iter() {
654            least = least.min(read(uv));
655            most = most.max(read(uv));
656        }
657        if !(least.is_finite() && most.is_finite()) || most - least > span {
658            continue;
659        }
660        let turns = {
661            let up = ((lo - least) / span).ceil();
662            let down = ((hi - most) / span).floor();
663            if up <= down {
664                // Somewhere it fits whole; the nearest such turn.
665                up.max(down.min(0.0))
666            } else {
667                (f64::midpoint(lo, hi) - f64::midpoint(least, most)) / span
668            }
669        };
670        let turns = if turns.is_finite() {
671            turns.round()
672        } else {
673            0.0
674        };
675        if turns == 0.0 {
676            continue;
677        }
678        for uv in trace.iter_mut() {
679            if across {
680                uv.x += turns * span;
681            } else {
682                uv.y += turns * span;
683            }
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    #![allow(clippy::unwrap_used, reason = "test code")]
691    use ogeom_math::Point2;
692
693    /// A trace unwrapped clean off its chart is slid back by whole turns.
694    ///
695    /// A real assembly has a face whose fitted `v` ran from −2.5π to
696    /// −π on a chart that stops at −π: continuous, outside, and the surface
697    /// refuses to be asked about it, so the face drew as a hole.
698    #[test]
699    fn a_trace_that_walked_off_its_chart_is_slid_back() {
700        let pi = core::f64::consts::PI;
701        let chart = ((0.0, 1.0), (-pi, pi));
702        let turn = (0.0, 2.0 * pi);
703
704        // A whole turn below: slid up, and as continuous as it was.
705        let mut trace = vec![
706            Point2::new(0.5, -2.5 * pi),
707            Point2::new(0.5, -2.0 * pi),
708            Point2::new(0.5, -1.5 * pi),
709        ];
710        super::slide_into_chart(&mut trace, turn, chart);
711        assert!(
712            trace.iter().all(|at| at.y >= -pi && at.y <= pi),
713            "slid into the chart: {trace:?}"
714        );
715        for pair in trace.windows(2) {
716            assert!(
717                (pair[1].y - pair[0].y - 0.5 * pi).abs() < 1e-12,
718                "and rigidly"
719            );
720        }
721
722        // Already inside: untouched.
723        let mut held = vec![Point2::new(0.5, -1.0), Point2::new(0.5, 1.0)];
724        let was = held.clone();
725        super::slide_into_chart(&mut held, turn, chart);
726        assert_eq!(held, was);
727
728        // Wider than a turn: nowhere to slide to, and left alone.
729        let mut wide = vec![Point2::new(0.5, -4.0 * pi), Point2::new(0.5, 0.0)];
730        let was = wide.clone();
731        super::slide_into_chart(&mut wide, turn, chart);
732        assert_eq!(wide, was);
733
734        // An axis that does not close is not slid on at all.
735        let mut across = vec![Point2::new(9.0, 0.0), Point2::new(9.5, 0.0)];
736        let was = across.clone();
737        super::slide_into_chart(&mut across, (0.0, 2.0 * pi), chart);
738        assert_eq!(across, was);
739    }
740
741    use super::*;
742    use ogeom_core::Tolerances;
743    use ogeom_geom::{CircleCurve, CylinderSurface};
744    use ogeom_math::{Circle, Cylinder, Frame};
745
746    const T: Tolerances = Tolerances::millimetres();
747
748    /// A sample the projector left at a pole is retried from its neighbour.
749    ///
750    /// The patch is `S(u, v) = v·C(u)`: its whole `v = 0` row is the origin,
751    /// so a projection that reaches the pole has no direction left to move
752    /// in and stops there, however far off it is. Seeding from the pole is
753    /// shown stuck first (that is the trap the forward walk falls into,
754    /// once per imported edge that starts on such a row), and the retry from a
755    /// sound neighbour is shown to get out of it.
756    #[test]
757    fn a_sample_stalled_at_a_pole_is_retried_from_its_neighbour() {
758        use ogeom_geom::{BSplineSurface, Surface as _};
759        use ogeom_math::{ControlGrid, KnotVector};
760
761        let mut control = Vec::new();
762        for i in 0..4 {
763            let across = -1.0 + 2.0 * f64::from(i) / 3.0;
764            for j in 0..4 {
765                let out = f64::from(j) / 3.0;
766                control.push(Point::new(out, across * out, (0.5 + across * across) * out));
767            }
768        }
769        let grid = ControlGrid::new(control, 4, 4).unwrap();
770        let cone: SurfaceGeometry = BSplineSurface::new(
771            KnotVector::clamped_uniform(3, 4).unwrap(),
772            KnotVector::clamped_uniform(3, 4).unwrap(),
773            &grid,
774            T,
775        )
776        .unwrap()
777        .into();
778        assert!(
779            cone.d1_at(0.5, 0.0, T).unwrap().0.magnitude() < 1e-12,
780            "the v = 0 row is a pole"
781        );
782
783        let at = cone.point_at(1.0, 0.2, T).unwrap();
784        let stuck = crate::measure::project_on_surface_from(&cone, at, (0.0, 0.0), T).unwrap();
785        assert!(
786            stuck.distance > 0.1,
787            "a projection seeded at the pole stays there: {stuck:?}"
788        );
789
790        let mut trace = vec![
791            ogeom_math::Point2::new(0.0, 0.0),
792            ogeom_math::Point2::new(0.0, 0.0),
793            ogeom_math::Point2::new(1.0, 0.5),
794        ];
795        let points = vec![
796            cone.point_at(1.0, 0.1, T).unwrap(),
797            at,
798            cone.point_at(1.0, 0.5, T).unwrap(),
799        ];
800        let mut offs = vec![points[0].distance(Point::ORIGIN), stuck.distance, 0.0];
801        retry_stalled(&cone, &points, &mut trace, &mut offs, T);
802        for (index, off) in offs.iter().enumerate() {
803            assert!(
804                *off < T.confusion(),
805                "sample {index} found its surface: {off:.3e}"
806            );
807        }
808        assert!(
809            (trace[1].x - 1.0).abs() < 1e-6 && (trace[1].y - 0.2).abs() < 1e-6,
810            "and its own parameters: {:?}",
811            trace[1]
812        );
813
814        // An honest miss is not a stall: nothing lands closer, so the
815        // file's own slop survives the retry untouched.
816        let adrift = Point::new(0.0, 0.0, -0.5);
817        let mut honest = vec![trace[2], ogeom_math::Point2::new(1.0, 0.5)];
818        let was = honest.clone();
819        let mut misses = vec![0.0, 0.5];
820        retry_stalled(&cone, &[points[2], adrift], &mut honest, &mut misses, T);
821        assert_eq!(honest[1], was[1]);
822        assert!((misses[1] - 0.5).abs() < 1e-12);
823    }
824
825    /// A direction is weak by what crossing it moves, not by its neighbour.
826    ///
827    /// The patch is a flat strip: `u` runs a millimetre across it and `v`
828    /// runs twenty millimetres along it over a parameter span of a hundredth,
829    /// two thousand times denser than `u`. Against `dv`, `du` looks weak at
830    /// every sample, and a ratio test held every `u` at one value: an edge
831    /// a millimetre long across the strip fitted as a single chart point.
832    /// Crossing the whole of `u` moves the point a millimetre, which is the
833    /// only thing "weak" can honestly mean, and it is not.
834    #[test]
835    fn a_direction_is_weak_by_what_crossing_it_moves() {
836        use ogeom_geom::{BSplineSurface, LineCurve};
837        use ogeom_math::{ControlGrid, KnotVector, Point};
838        let mut control = Vec::new();
839        for i in 0..2 {
840            for j in 0..2 {
841                control.push(Point::new(f64::from(i), 20.0 * f64::from(j), 0.0));
842            }
843        }
844        let strip: SurfaceGeometry = BSplineSurface::new(
845            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
846            KnotVector::new(vec![0.0, 0.0, 0.01, 0.01], 1).unwrap(),
847            &ControlGrid::new(control, 2, 2).unwrap(),
848            T,
849        )
850        .unwrap()
851        .into();
852        // Across the strip at v = 0.005 (the middle in space, 10 mm along).
853        let across: Curve =
854            LineCurve::segment(Point::new(0.0, 10.0, 0.0), Point::new(1.0, 10.0, 0.0), T)
855                .unwrap()
856                .into();
857        let (pcurve, error, _, _, _) =
858            fit_projected_pcurve(&across, (0.0, 1.0), &strip, T).unwrap();
859        let a = ogeom_geom::Curve2d::point_at(&pcurve, 0.0, T).unwrap();
860        let b = ogeom_geom::Curve2d::point_at(&pcurve, 1.0, T).unwrap();
861        assert!(
862            (b.x - a.x).abs() > 0.99,
863            "the edge crosses the whole of u: {a:?} -> {b:?}"
864        );
865        assert!(error < 1e-6, "and fits: {error:.2e}");
866    }
867
868    /// A boundary 0.3 mm off its surface fits, and says so.
869    ///
870    /// Community exports carry boundary curves that far from the surfaces
871    /// they trim: 0.12–0.34 mm on a real assembly. The fit
872    /// accepts anything under a millimetre and reports the offset, so the
873    /// reader widens the edge's tolerance instead of leaving the face
874    /// without a trim; a miss of whole millimetres (the signature of an
875    /// edge paired with the wrong surface) still refuses.
876    #[test]
877    fn slop_under_a_millimetre_fits_and_is_reported() {
878        let wall: SurfaceGeometry =
879            CylinderSurface::new(Cylinder::new(Frame::WORLD, 10.0, T).unwrap(), (-50.0, 50.0))
880                .unwrap()
881                .into();
882        let rim = |radius: f64| -> Curve {
883            CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
884        };
885        // 0.3 mm proud of the wall: every sample sits exactly that far off.
886        let (_, _, _, worst_off, warning) =
887            fit_projected_pcurve(&rim(10.3), (0.0, core::f64::consts::TAU), &wall, T).unwrap();
888        assert!(
889            (worst_off - 0.3).abs() < 1e-6,
890            "the offset is measured: {worst_off}"
891        );
892        assert!(warning.is_some(), "slop this large is worth a warning");
893        // 3 mm off is not slop; it is the wrong surface.
894        assert!(
895            fit_projected_pcurve(&rim(13.0), (0.0, core::f64::consts::TAU), &wall, T).is_err(),
896            "a miss of millimetres still refuses"
897        );
898    }
899
900    /// A fit is held between its samples, where a fast-running curve bends.
901    ///
902    /// The samples are spaced evenly in the curve's parameter, and this
903    /// curve spends a twentieth of its parameter on a steep drop of a
904    /// millimetre before running slowly round the drum for the rest: the
905    /// whole of the drop, and the bend at its foot, fall inside the first
906    /// sample interval. A cubic held only at the samples hooked past the
907    /// curve there; measured between the samples and refitted where it
908    /// leaves them, the pcurve follows the curve everywhere.
909    #[test]
910    fn a_fit_is_held_between_its_samples_where_the_curve_runs_fast() {
911        use ogeom_geom::{BSplineCurve, Curve, Curve2d, Curve3d, Surface, SurfaceGeometry};
912        use ogeom_math::{KnotVector, Point};
913        let radius = 10.0;
914        let on = |angle: f64, z: f64| Point::new(radius * angle.cos(), radius * angle.sin(), z);
915        let mut knots = vec![0.0, 0.0, 0.0, 0.0, 0.05];
916        knots.extend((1..8).map(f64::from));
917        knots.extend([8.0; 4]);
918        let control = vec![
919            on(0.12, 4.0),
920            on(0.13, 3.7),
921            on(0.14, 3.4),
922            on(0.15, 3.1),
923            on(0.10, 2.5),
924            on(0.0, 1.5),
925            on(-0.1, 0.5),
926            on(-0.2, -0.5),
927            on(-0.3, -1.5),
928            on(-0.35, -2.5),
929            on(-0.38, -3.3),
930            on(-0.4, -4.0),
931        ];
932        let curve: Curve = BSplineCurve::new(KnotVector::new(knots, 3).unwrap(), control, T)
933            .unwrap()
934            .into();
935        let drum: SurfaceGeometry =
936            CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-5.0, 5.0))
937                .unwrap()
938                .into();
939        let (pcurve, error, _, _, _) = fit_projected_pcurve(&curve, (0.0, 8.0), &drum, T).unwrap();
940        let mut worst = 0.0_f64;
941        for i in 0..=2000 {
942            let t = 8.0 * f64::from(i) / 2000.0;
943            let p = curve.point_at(t, T).unwrap();
944            let uv = pcurve.point_at(t, T).unwrap();
945            let lifted = drum.point_at(uv.x, uv.y, T).unwrap();
946            // The pcurve's own miss: the curve's point, pulled onto the drum.
947            worst = worst.max(lifted.distance(on(p.y.atan2(p.x), p.z)));
948        }
949        assert!(
950            worst <= T.confusion() * 3e4,
951            "the pcurve leaves the curve by {worst:.3e} between the samples, \
952             {error:.3e} reported"
953        );
954        assert!(
955            error >= 0.5 * worst,
956            "and the miss between the samples is reported: {error:.3e} for {worst:.3e}"
957        );
958    }
959}