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. An exact curve never carries a fitted pcurve
10//! silently: the fit's error is returned, and the callers widen tolerances
11//! 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        let off = p.distance(lifted);
82        // Near a sphere's pole the arcsine of the height loses half its
83        // digits, and a point on the sphere lands a root of the rounding
84        // off it: past the confusion distance, the latitude read against
85        // the ring's radius, which stays exact there, is taken where it
86        // lands nearer.
87        if off > tol.confusion()
88            && let SurfaceGeometry::Sphere(s) = surface
89        {
90            let l = s.sphere().frame().to_local(p);
91            let exact = ogeom_math::Point2::new(uv.x, l.z.atan2(l.x.hypot(l.y)));
92            let nearer = p.distance(surface.point_at(exact.x, exact.y, tol)?);
93            if nearer < off {
94                return Ok((exact, nearer));
95            }
96        }
97        return Ok((uv, off));
98    }
99    let near = seed.and_then(|luv| {
100        crate::measure::project_on_surface_from(surface, p, (luv.x, luv.y), tol).ok()
101    });
102    if let Some(close) = near.filter(|f| f.distance <= tol.confusion() * 1e5) {
103        return Ok((
104            ogeom_math::Point2::new(close.parameters.0, close.parameters.1),
105            close.distance,
106        ));
107    }
108    let mut projection = crate::measure::project_on_surface(surface, p, 24, tol)?;
109    if projection.distance > tol.confusion() * 1e5 {
110        // A miss this large on a spline surface is more often a projection
111        // stuck in the wrong basin than real slop; seed denser before
112        // believing it.
113        let denser = crate::measure::project_on_surface(surface, p, 96, tol)?;
114        if denser.distance < projection.distance {
115            projection = denser;
116        }
117    }
118    Ok((
119        ogeom_math::Point2::new(projection.parameters.0, projection.parameters.1),
120        projection.distance,
121    ))
122}
123
124/// A sample of the curve landed on the surface: the parameter, the point,
125/// where it landed in the chart, and how far off the surface it sat.
126type Landed = (f64, Point, ogeom_math::Point2, f64);
127
128/// The readers' line between a file's own slop and a wrong pairing, in
129/// confusions: a millimetre at unit scale.
130pub const TRIM_CAP: f64 = 1e7;
131
132/// Fit a pcurve by projection at the reader's own line: slop under a
133/// millimetre is a file's error, honestly carried; anything past it is a
134/// wrong pairing and refuses. The exchange readers call this; a healer
135/// acting on instruction calls [`fit_projected_pcurve_capped`] with the
136/// cap its caller chose.
137///
138/// # Errors
139///
140/// As [`fit_projected_pcurve_capped`], at the millimetre cap.
141pub fn fit_projected_pcurve(
142    curve: &Curve,
143    range: (f64, f64),
144    surface: &SurfaceGeometry,
145    tol: Tolerances,
146) -> FittedPcurve {
147    fit_projected_pcurve_capped(curve, range, surface, tol.confusion() * TRIM_CAP, tol)
148}
149
150/// As the reader's projected-pcurve fit, with the acceptance cap in the
151/// caller's hands.
152///
153/// The reader draws its line at a millimetre (below it is a file's own
154/// slop, above it a wrong pairing), but a *healer* acts on instruction, and
155/// the instruction carries the cap. Returns the fitted pcurve, the fit's
156/// reached error as a length (the widest the lifted fit misses where the
157/// curve lands, between the samples as well as at them), whether it met its
158/// target, the worst measured edge-to-surface offset, and the slop note
159/// when that offset is worth saying out loud.
160///
161/// # Errors
162///
163/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a
164/// sample sits farther than `cap` from the surface, or the projection
165/// cannot converge at all.
166pub fn fit_projected_pcurve_capped(
167    curve: &Curve,
168    range: (f64, f64),
169    surface: &SurfaceGeometry,
170    cap: f64,
171    tol: Tolerances,
172) -> FittedPcurve {
173    match fit_projected_pcurve_within(curve, range, surface, cap, tol)? {
174        CappedFit::Fitted(fitted) => Ok(fitted),
175        CappedFit::TooFar(worst_off) => ogeom_bail!(
176            Construction,
177            "the edge sits {worst_off:.2e} from the surface it should bound"
178        ),
179    }
180}
181
182/// What [`fit_projected_pcurve_within`] made of an edge.
183#[derive(Debug, Clone)]
184pub enum CappedFit {
185    /// The fit, as [`fit_projected_pcurve_capped`] returns it.
186    Fitted((PlanarCurve, f64, bool, f64, Option<String>)),
187    /// Refused: some sample sits this far from the surface, past the cap.
188    TooFar(f64),
189}
190
191/// [`fit_projected_pcurve_capped`], with a refusal at the cap returned as
192/// the offset measured rather than as an error.
193///
194/// # Errors
195///
196/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
197/// the projection cannot converge at all.
198pub fn fit_projected_pcurve_within(
199    curve: &Curve,
200    range: (f64, f64),
201    surface: &SurfaceGeometry,
202    cap: f64,
203    tol: Tolerances,
204) -> OgeomResult<CappedFit> {
205    fit_projected_pcurve_aimed(curve, range, surface, cap, tol.confusion() * 1e2, tol)
206}
207
208/// [`fit_projected_pcurve_within`], the fit aimed at `aim`, a length,
209/// rather than at a hundred confusions: a producer that states its edge's
210/// tolerance asks for the pcurve to keep to it.
211///
212/// # Errors
213///
214/// As [`fit_projected_pcurve_within`].
215pub fn fit_projected_pcurve_aimed(
216    curve: &Curve,
217    range: (f64, f64),
218    surface: &SurfaceGeometry,
219    cap: f64,
220    aim: f64,
221    tol: Tolerances,
222) -> OgeomResult<CappedFit> {
223    const SAMPLES: usize = 96;
224    let mut parameters = Vec::with_capacity(SAMPLES + 1);
225    let mut trace = Vec::with_capacity(SAMPLES + 1);
226    let mut points = Vec::with_capacity(SAMPLES + 1);
227    let mut offs = Vec::with_capacity(SAMPLES + 1);
228    let mut previous: Option<(Point, ogeom_math::Point2)> = None;
229    for i in 0..=SAMPLES {
230        #[allow(clippy::cast_precision_loss)]
231        let t = range.0 + (range.1 - range.0) * i as f64 / SAMPLES as f64;
232        let p = curve.point_at(t, tol)?;
233        let (uv, off) = land(surface, p, previous.map(|(_, luv)| luv), tol)?;
234        previous = Some((p, uv));
235        parameters.push(t);
236        trace.push(uv);
237        points.push(p);
238        offs.push(off);
239    }
240    retry_stalled(surface, &points, &mut trace, &mut offs, tol);
241    // The cap separates a file's own slop from an edge paired with the
242    // wrong surface. Slop is routinely a micron or two and reaches a third
243    // of a millimetre in exchange files, while a wrong pairing misses by
244    // the distance between two different surfaces of the body, whole
245    // millimetres. One millimetre stands between the slop a file carries and
246    // the smallest wrong pairing plausible. Slop inside the
247    // cap is accepted and *recorded*: the edge's tolerance is widened to
248    // cover it, so the model says what it knows instead of refusing to
249    // triangulate. Judged after the retry, so a projection that stalled
250    // cannot refuse an edge that is actually on its surface.
251    let worst_off = offs.iter().copied().fold(0.0_f64, f64::max);
252    if worst_off > cap {
253        return Ok(CappedFit::TooFar(worst_off));
254    }
255    let mut space_run = 0.0;
256    let mut parameter_run = 0.0;
257    for i in 1..trace.len() {
258        space_run += points[i].distance(points[i - 1]);
259        parameter_run += trace[i].distance(trace[i - 1]);
260    }
261    // A trace on a periodic chart may cross the seam mid-edge; unwrap it
262    // pointwise so the fit sees a continuous curve. Closure, not
263    // periodicity, is the right test: a skinned loft's wall is a clamped
264    // B-spline that closes on itself without being periodic, and its
265    // projections near the joining column land in either copy: both
266    // answers are right pointwise, and only continuity chooses. It is
267    // decided here for both exchange readers at once.
268    let ((ua, ub), (va, vb)) = surface.domain();
269    let spans = (
270        if surface.is_periodic_u() || surface.is_closed_u(tol) {
271            ub - ua
272        } else {
273            0.0
274        },
275        if surface.is_periodic_v() || surface.is_closed_v(tol) {
276            vb - va
277        } else {
278            0.0
279        },
280    );
281    for i in 1..trace.len() {
282        if spans.0 > 0.0 {
283            while trace[i].x - trace[i - 1].x > spans.0 * 0.5 {
284                trace[i].x -= spans.0;
285            }
286            while trace[i].x - trace[i - 1].x < -spans.0 * 0.5 {
287                trace[i].x += spans.0;
288            }
289        }
290        if spans.1 > 0.0 {
291            while trace[i].y - trace[i - 1].y > spans.1 * 0.5 {
292                trace[i].y -= spans.1;
293            }
294            while trace[i].y - trace[i - 1].y < -spans.1 * 0.5 {
295                trace[i].y += spans.1;
296            }
297        }
298    }
299    slide_into_chart(&mut trace, spans, ((ua, ub), (va, vb)));
300    // Where the chart collapses (a sphere's pole, a cone's apex), the
301    // u of a sample is atan2 of noise: the point determines no angle.
302    // The *arc* does: a smooth curve through the pole approaches it at
303    // a definite chart angle, which is the limit of its well-conditioned
304    // neighbours. Samples whose u-direction has collapsed relative to
305    // their v-direction are repaired by interpolating u between the
306    // nearest sound samples, extrapolating at the ends.
307    //
308    // Weak is measured in millimetres, not against `dv`. A ratio calls a
309    // direction weak whenever the *other* one is strong: on a patch whose
310    // `v` is parameterised a thousand times more densely than its `u`,
311    // every sample of every edge is called weak, its `u` held at one value,
312    // and an edge across the patch fits as a single point. What makes a
313    // direction degenerate is that crossing the whole of it moves the point
314    // less than a micron; that question has an answer in length, and only
315    // in length.
316    let (u_span, _) = {
317        let ((ua, ub), (va, vb)) = surface.domain();
318        (ub - ua, vb - va)
319    };
320    let weak: Vec<bool> = trace
321        .iter()
322        .map(|uv| {
323            surface
324                .d1_at(uv.x, uv.y, tol)
325                .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
326        })
327        .collect();
328    if weak.iter().all(|w| *w) && !weak.is_empty() {
329        // Not a row that collapses but a whole patch that does: a sliver
330        // four microns wide and a tenth of a millimetre long, where `u` is
331        // noise everywhere and the projector answers 0 at one sample and 1
332        // at the next. A fit through that swings across the chart and its
333        // controls are dragged back by hundreds of units. Any `u` describes
334        // the same points to within the sliver's own width, so they all
335        // take one: the middle of what the projections claimed, which is
336        // the least arbitrary of the arbitrary answers.
337        let mut claimed: Vec<f64> = trace.iter().map(|uv| uv.x).collect();
338        claimed.sort_by(f64::total_cmp);
339        let held = claimed[claimed.len() / 2];
340        for uv in &mut trace {
341            uv.x = held;
342        }
343    } else if weak.iter().any(|w| *w) && weak.iter().filter(|w| !**w).count() >= 2 {
344        let strong: Vec<usize> = (0..trace.len()).filter(|&i| !weak[i]).collect();
345        let u_span = if surface.is_periodic_u() {
346            ua.max(ub) - ua.min(ub)
347        } else {
348            f64::INFINITY
349        };
350        for i in 0..trace.len() {
351            if !weak[i] {
352                continue;
353            }
354            let after = strong.iter().position(|&s| s > i);
355            let (a, b) = match after {
356                Some(0) => (strong[0], strong[1]),
357                Some(k) => (strong[k - 1], strong[k]),
358                None => (strong[strong.len() - 2], strong[strong.len() - 1]),
359            };
360            // A curve *through* the pole genuinely jumps its angle
361            // there; only a run whose sound neighbours agree is noise
362            // to smooth over.
363            if a < i && i < b && (trace[b].x - trace[a].x).abs() > u_span * 0.25 {
364                continue;
365            }
366            let (ta, tb) = (parameters[a], parameters[b]);
367            let f = if (tb - ta).abs() <= f64::MIN_POSITIVE {
368                0.0
369            } else {
370                (parameters[i] - ta) / (tb - ta)
371            };
372            trace[i].x = trace[a].x + (trace[b].x - trace[a].x) * f;
373        }
374    }
375
376    // The tolerance carried into the chart through the trace's own
377    // metric: the honest cheap version, refined by the fit's report.
378    let scale = if space_run > tol.confusion() {
379        parameter_run / space_run
380    } else {
381        1.0
382    };
383    let target = (aim * scale).max(f64::MIN_POSITIVE);
384    let fit_and_clamp = |parameters: &[f64],
385                         trace: &[ogeom_math::Point2]|
386     -> OgeomResult<ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>> {
387        let fitted = ogeom_geom::fit::fit_points_2d_at(parameters, trace, 3, target, tol)?;
388        // A least-squares fit wiggles past its samples at the ends, and a
389        // surface with a *tight* stated window (an imported patch, not a
390        // reader-built analytic with its enormous extents) refuses
391        // evaluation a hair outside it. The control points clamp into the
392        // window on the non-periodic axes: the curve lives in its controls'
393        // hull, so the clamp is a guarantee. Whatever the clamp cost is not
394        // hidden either: it is the clamped curve that is measured below.
395        // Periodic axes stay free: an unwrapped trace crosses the seam on
396        // purpose.
397        let ((wa, wb), (va2, vb2)) = surface.domain();
398        let clamp_u = !(surface.is_periodic_u() || surface.is_closed_u(tol));
399        let clamp_v = !(surface.is_periodic_v() || surface.is_closed_v(tol));
400        if !(clamp_u || clamp_v) {
401            return Ok(fitted);
402        }
403        let mut moved = 0.0_f64;
404        let knots = fitted.curve.knots().clone();
405        let control: Vec<ogeom_math::Point2> = fitted
406            .curve
407            .control_points()
408            .iter()
409            .map(|w| {
410                let p = w.point();
411                let q = ogeom_math::Point2::new(
412                    if clamp_u { p.x.clamp(wa, wb) } else { p.x },
413                    if clamp_v { p.y.clamp(va2, vb2) } else { p.y },
414                );
415                moved = moved.max(p.distance(q));
416                q
417            })
418            .collect();
419        if moved > 0.0 {
420            Ok(ogeom_geom::fit::Fitted {
421                curve: ogeom_geom::BSpline2d::new(knots, control, tol)?,
422                ..fitted
423            })
424        } else {
425            Ok(fitted)
426        }
427    };
428    // What the caller is told, as a length. The fitter reports its error
429    // in *chart* units, and a chart's units are whatever the file chose: a
430    // patch can span four microns across its `u` and ten millimetres along
431    // its `v`, so no single scale converts the one number into the other,
432    // and a control point dragged back into such a chart by the clamp reads
433    // as hundreds of millimetres of mesh error on a face a tenth of a
434    // millimetre across. So the fitted curve is walked instead, through the
435    // surface, against the trace it was fitted to: that difference is the
436    // fit's own and is measured where the mesh will be.
437    //
438    // Measured between the samples as well as at them. The samples are
439    // spaced evenly in the curve's parameter, and a curve is free to run
440    // many times faster at one end than the other: one that turns through
441    // most of its bend inside the first interval leaves a cubic held only
442    // at the interval's ends hooking well past it, off the face and across
443    // its neighbouring ring, while every sample sits within a hundredth. A
444    // midpoint the fit leaves is projected and joins the samples, and the
445    // fit is asked again, a few rounds at most.
446    let closed_form = points
447        .first()
448        .is_some_and(|p| chart_of(surface, *p).is_some());
449    // A landing is believed only where it belongs: on the surface as
450    // convincingly as its neighbours, and inside the chart interval
451    // they span, widened by the interval itself. A projection that
452    // settled in another basin, or on the far side of a seam, would
453    // otherwise be fitted as if the curve went there. Beside its
454    // neighbour on a periodic chart, as the trace was unwrapped; where
455    // the chart collapses its angle is noise, and the neighbours' is
456    // taken.
457    let landing =
458        |trace: &[ogeom_math::Point2], offs: &[f64], index: usize, tm: f64| -> Option<Landed> {
459            let before = trace[index - 1];
460            let after = trace[index];
461            let p = curve.point_at(tm, tol).ok()?;
462            let (mut uv, off) = land(surface, p, Some(before), tol).ok()?;
463            if spans.0 > 0.0 {
464                while uv.x - before.x > spans.0 * 0.5 {
465                    uv.x -= spans.0;
466                }
467                while uv.x - before.x < -spans.0 * 0.5 {
468                    uv.x += spans.0;
469                }
470            }
471            if spans.1 > 0.0 {
472                while uv.y - before.y > spans.1 * 0.5 {
473                    uv.y -= spans.1;
474                }
475                while uv.y - before.y < -spans.1 * 0.5 {
476                    uv.y += spans.1;
477                }
478            }
479            if surface
480                .d1_at(uv.x, uv.y, tol)
481                .is_ok_and(|(du, _)| du.magnitude() * u_span < tol.confusion() * 1e4)
482            {
483                uv.x = 0.5 * (before.x + after.x);
484            }
485            let reach = before.distance(after).max(f64::EPSILON);
486            let mid =
487                ogeom_math::Point2::new(0.5 * (before.x + after.x), 0.5 * (before.y + after.y));
488            let sound = offs[index - 1].max(offs[index]).max(tol.confusion() * 1e5);
489            (off <= 2.0 * sound && mid.distance(uv) <= reach).then_some((tm, p, uv, off))
490        };
491    let deviation = |fitted: &ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
492                     parameters: &[f64],
493                     trace: &[ogeom_math::Point2],
494                     offs: &[f64]|
495     -> (f64, f64, Vec<Landed>) {
496        let mut error = 0.0_f64;
497        let mut between_all = 0.0_f64;
498        let mut more = Vec::new();
499        let mut landed_middles = Vec::new();
500        let mut left = false;
501        let landing = |index: usize, tm: f64| landing(trace, offs, index, tm);
502        for (index, t) in parameters.iter().enumerate() {
503            let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, *t, tol) else {
504                continue;
505            };
506            let (Ok(fitted_at), Ok(traced_at)) = (
507                surface.point_at(at.x, at.y, tol),
508                surface.point_at(trace[index].x, trace[index].y, tol),
509            ) else {
510                continue;
511            };
512            error = error.max(fitted_at.distance(traced_at));
513            if index == 0 {
514                continue;
515            }
516            // Probed at the quarters as well as the middle where the chart
517            // inverts in closed form, which costs nothing: a hook sits
518            // where the curve turns, wherever in the interval that is. On a
519            // patch every landing is a projection, and the middle alone is
520            // asked: a hook is the cubic's own excursion, broad across the
521            // interval, and once the fit is asked again at twice the
522            // samples the quarters of this round are the middles of the
523            // next.
524            let (ta, tb) = (parameters[index - 1], *t);
525            let tm = 0.5 * (ta + tb);
526            let quarters = [0.5 * (ta + tm), tm, 0.5 * (tm + tb)];
527            let probes: &[f64] = if closed_form {
528                &quarters
529            } else {
530                &quarters[1..2]
531            };
532            for &probe in probes {
533                let Ok(at) = ogeom_geom::Curve2d::point_at(&fitted.curve, probe, tol) else {
534                    continue;
535                };
536                let Ok(fitted_at) = surface.point_at(at.x, at.y, tol) else {
537                    continue;
538                };
539                if !closed_form {
540                    // Against the curve's own point first, which costs no
541                    // projection: the curve sits about as far off the
542                    // surface here as at the samples either side, so a
543                    // fitted point within the bar and that slop of it is
544                    // within the bar of where the curve lands. A hook worth
545                    // the name is hundreds of times the slop; only a probe
546                    // that misses by more than the slop is projected.
547                    let Ok(p) = curve.point_at(probe, tol) else {
548                        continue;
549                    };
550                    let slop = offs[index - 1].max(offs[index]);
551                    let rough = fitted_at.distance(p);
552                    if rough <= tol.confusion() * 1e4 + slop {
553                        between_all = between_all.max((rough - slop).max(0.0));
554                        continue;
555                    }
556                }
557                let Some(landed) = landing(index, probe) else {
558                    continue;
559                };
560                let Ok(traced_at) = surface.point_at(landed.2.x, landed.2.y, tol) else {
561                    continue;
562                };
563                let between = fitted_at.distance(traced_at);
564                between_all = between_all.max(between);
565                if between > tol.confusion() * 1e4 {
566                    left = true;
567                }
568                if probe == tm {
569                    landed_middles.push(landed);
570                }
571            }
572        }
573        // The fit is asked again at twice the samples everywhere, the
574        // middle of every interval joining them, projected here where the
575        // rough test spared it.
576        if left {
577            more = landed_middles;
578            if !closed_form {
579                more = (1..parameters.len())
580                    .filter_map(|index| {
581                        landing(index, 0.5 * (parameters[index - 1] + parameters[index]))
582                    })
583                    .collect();
584            }
585        }
586        (error, between_all, more)
587    };
588    // A micron between samples is the bar, a tenth of the finest chord a
589    // mesh is asked for, and a hook worth the name is hundreds of times
590    // that. Every fit pays one pass of probes; only the few that leave the
591    // curve pay a refit, at twice the samples everywhere (a handful of new
592    // samples in one interval draw the fitter's knots to themselves and the
593    // curve wobbles on either side, while an even doubling keeps it
594    // steady), and a curve the fit cannot follow, a corner inside the edge,
595    // stops at a few hundred samples rather than doubling for ever.
596    const DENSIFY: usize = 6;
597    const MOST: usize = 512;
598    let mut fitted = fit_and_clamp(&parameters, &trace)?;
599    #[allow(
600        clippy::type_complexity,
601        reason = "a fit, its two misses and its sample count"
602    )]
603    let mut best: Option<(
604        ogeom_geom::fit::Fitted<ogeom_geom::BSpline2d>,
605        f64,
606        f64,
607        usize,
608    )> = None;
609    let mut round = 0;
610    loop {
611        let (at_samples, between, more) = deviation(&fitted, &parameters, &trace, &offs);
612        // The best round stands, whichever it was: a refit is not obliged
613        // to improve, and the fit handed on is the one measured closest.
614        if best
615            .as_ref()
616            .is_none_or(|(_, a, b, _)| at_samples.max(between) < a.max(*b))
617        {
618            best = Some((fitted.clone(), at_samples, between, parameters.len()));
619        }
620        if more.is_empty() || round == DENSIFY || parameters.len() >= MOST {
621            break;
622        }
623        for (tm, p, uv, off) in more {
624            let k = parameters.partition_point(|&t| t < tm);
625            parameters.insert(k, tm);
626            trace.insert(k, uv);
627            points.insert(k, p);
628            offs.insert(k, off);
629        }
630        fitted = fit_and_clamp(&parameters, &trace)?;
631        round += 1;
632    }
633    let (fitted, at_samples, between, own) =
634        best.unwrap_or((fitted, f64::INFINITY, f64::INFINITY, 0));
635    // The probes above decide where to refit; the fit handed on states the
636    // widest miss its quarter points and their peaks show.
637    let between = between.max(climbed_miss(
638        &fitted.curve,
639        (&parameters, own),
640        &trace,
641        &offs,
642        closed_form,
643        |t| curve.point_at(t, tol).ok(),
644        surface,
645        tol,
646    ));
647    let worst_off = offs.iter().copied().fold(worst_off, f64::max);
648    // Each miss against its own bar: the samples hold the fit to a hair,
649    // and the points between them to the micron that sent it back.
650    let error = at_samples.max(between);
651    let met = at_samples <= tol.confusion() * 1e2 && between <= tol.confusion() * 1e4;
652    let slop = (worst_off > tol.confusion() * 1e3).then(|| {
653        format!(
654            "an edge sits up to {worst_off:.2e} from the surface it \
655             bounds; the file's own slop, carried into the chart"
656        )
657    });
658    Ok(CappedFit::Fitted((
659        fitted.curve.into(),
660        error,
661        met,
662        worst_off,
663        slop,
664    )))
665}
666
667/// The widest a fitted pcurve, lifted through `surface`, misses where the
668/// curve lands between the samples of `parameters` (`trace` where they
669/// landed, `offs` how far off the surface), and at those the fit was not
670/// fitted through: the samples past the first `own`, which a refit added.
671///
672/// Every interval's quarter points are read, and the widest few readings,
673/// those within a fifth of the widest, at most `PEAKS`, are climbed to the
674/// tops of their peaks by golden sections between the quarters either
675/// side. Where the chart inverts in closed form (`closed_form`) a reading
676/// is the miss itself. On a patch every landing is a projection, and a
677/// reading is the lifted fit's distance from the curve's own point
678/// instead, which differs from the miss by no more than the curve's offset
679/// from the surface there; a top is stated with the wider offset either
680/// side of it added.
681#[allow(
682    clippy::too_many_arguments,
683    reason = "the fit's context, each piece read"
684)]
685fn climbed_miss(
686    pcurve: &ogeom_geom::BSpline2d,
687    (parameters, own): (&[f64], usize),
688    trace: &[ogeom_math::Point2],
689    offs: &[f64],
690    closed_form: bool,
691    curve_at: impl Fn(f64) -> Option<Point>,
692    surface: &SurfaceGeometry,
693    tol: Tolerances,
694) -> f64 {
695    const PEAKS: usize = 4;
696    const NEAR_PEAK: f64 = 0.8;
697    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
698    let lifted = |t: f64| -> Option<Point> {
699        let at = ogeom_geom::Curve2d::point_at(pcurve, t, tol).ok()?;
700        surface.point_at(at.x, at.y, tol).ok()
701    };
702    let reading = |t: f64| -> f64 {
703        let on = if closed_form {
704            curve_at(t)
705                .and_then(|p| chart_of(surface, p))
706                .and_then(|uv| surface.point_at(uv.x, uv.y, tol).ok())
707        } else {
708            curve_at(t)
709        };
710        match (lifted(t), on) {
711            (Some(fit), Some(on)) => fit.distance(on),
712            _ => 0.0,
713        }
714    };
715    let mut widest = 0.0_f64;
716    // The samples a refit added after this fit's round stand between its
717    // own.
718    if parameters.len() != own {
719        for (&t, uv) in parameters.iter().zip(trace) {
720            if let (Some(fit), Ok(traced)) = (lifted(t), surface.point_at(uv.x, uv.y, tol)) {
721                widest = widest.max(fit.distance(traced));
722            }
723        }
724    }
725    // Each interval's widest quarter reading, where it stands, and the
726    // quarter's length.
727    let mut readings: Vec<(f64, usize, f64, f64)> = (1..parameters.len())
728        .map(|index| {
729            let (a, b) = (parameters[index - 1], parameters[index]);
730            let quarter = (b - a) / 4.0;
731            (1..=3)
732                .map(|k| {
733                    let t = quarter.mul_add(f64::from(k), a);
734                    (reading(t), index, t, quarter)
735                })
736                .fold((f64::NEG_INFINITY, index, a, quarter), |held, r| {
737                    if r.0 > held.0 { r } else { held }
738                })
739        })
740        .collect();
741    readings.sort_by(|a, b| b.0.total_cmp(&a.0));
742    let tallest = readings.first().map_or(0.0, |r| r.0);
743    for &(height, index, at, quarter) in readings
744        .iter()
745        .take_while(|r| r.0 > 0.0 && r.0 >= tallest * NEAR_PEAK)
746        .take(PEAKS)
747    {
748        let (lo, hi) = (parameters[index - 1], parameters[index]);
749        let (mut a, mut b) = ((at - quarter).max(lo), (at + quarter).min(hi));
750        let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
751        let (mut rc, mut rd) = (reading(c), reading(d));
752        let mut top = height.max(rc).max(rd);
753        for _ in 0..20 {
754            if rc > rd {
755                (b, d, rd) = (d, c, rc);
756                c = b - (b - a) * ratio;
757                rc = reading(c);
758            } else {
759                (a, c, rc) = (c, d, rd);
760                d = a + (b - a) * ratio;
761                rd = reading(d);
762            }
763            top = top.max(rc).max(rd);
764        }
765        let slop = if closed_form {
766            0.0
767        } else {
768            offs[index - 1].max(offs[index])
769        };
770        widest = widest.max(top + slop);
771    }
772    widest
773}
774
775/// How far `pcurve`, lifted through `surface`, stands from `curve`, each
776/// over its range and read at the same fraction along it.
777///
778/// This is the width an edge carrying both must state: the curve sitting
779/// off the surface (an exact pcurve is the curve's projection, and a file's
780/// line can lie microns off its plane) and a fit straying between the
781/// samples it was fitted through both show here. The lifted point is
782/// compared with the curve's point at the same fraction; unless `paced`
783/// (an edge claiming same parameter is held to its claim), where that
784/// misses by more than the confusion tolerance it is compared with the
785/// nearest point of the curve within one sample's step, so a pcurve paced
786/// a little differently is not charged for its pace. Samples the surface
787/// cannot evaluate (past a pole) are passed over.
788///
789/// A fit strays most where its chart runs fast, as a curve passing close
790/// by a sphere's pole sweeps across the chart in a few thousandths of its
791/// length, and a stray that narrow falls between even samples. So the
792/// widest few local peaks of the samples, and the two ends where a fit's
793/// trace is seeded, are each searched for their summit between the samples
794/// either side. Unless `paced`, the peaks are those of the lifted point's
795/// distance square off the curve's tangent, which is the nearest-point gap
796/// to first order: a pcurve paced a little differently strays along the
797/// curve most where it strays across it least, and the peaks of the paced
798/// distance stand away from those of the gap stated.
799///
800/// # Errors
801///
802/// Propagates a failure to evaluate the curve or the pcurve over its range.
803pub fn lifted_gap(
804    (curve, range): (&Curve, (f64, f64)),
805    (pcurve, pcurve_range): (&PlanarCurve, (f64, f64)),
806    surface: &SurfaceGeometry,
807    paced: bool,
808    tol: Tolerances,
809) -> OgeomResult<f64> {
810    use ogeom_geom::Curve2d as _;
811    // Eight times the samples `check` takes, so its samples are among them.
812    const SAMPLES: u32 = 256;
813    // The peaks searched besides the ends: the widest stray and its
814    // runners-up standing within `NEAR_PEAK` of it, at most `PEAKS`, since
815    // the widest sample need not stand under the widest summit. A fit's
816    // wiggle can be as narrow as these samples are apart, and a sample
817    // then reads its summit a fifth low.
818    const PEAKS: usize = 8;
819    const NEAR_PEAK: f64 = 0.7;
820    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
821    let n = f64::from(SAMPLES);
822    let step = (range.1 - range.0) / n;
823    let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
824    // The lifted point at fraction `f` and its distance from the curve's
825    // point there, or `None` where the surface cannot be evaluated.
826    let paced_at = |f: f64| -> OgeomResult<Option<(Point, f64)>> {
827        let uv = pcurve.point_at(
828            (pcurve_range.1 - pcurve_range.0).mul_add(f, pcurve_range.0),
829            tol,
830        )?;
831        let Ok(lifted) = surface.point_at(uv.x, uv.y, tol) else {
832            return Ok(None);
833        };
834        let t = (range.1 - range.0).mul_add(f, range.0);
835        Ok(Some((lifted, curve.point_at(t, tol)?.distance(lifted))))
836    };
837    // The part of the paced distance at fraction `f` square off the
838    // curve's tangent there: the height the peaks are picked by.
839    let across_at = |f: f64, (lifted, gap): (Point, f64)| -> OgeomResult<f64> {
840        if paced {
841            return Ok(gap);
842        }
843        let t = (range.1 - range.0).mul_add(f, range.0);
844        let tangent = curve.d1_at(t, tol)?;
845        let length = tangent.magnitude();
846        if length <= f64::MIN_POSITIVE {
847            return Ok(gap);
848        }
849        let along = (lifted - curve.point_at(t, tol)?).dot(tangent) / length;
850        Ok(along.mul_add(-along, gap * gap).max(0.0).sqrt())
851    };
852    // The gap the edge must state at fraction `f`, given the lifted point
853    // and its paced distance there: never more than the paced distance.
854    let gap_at = |f: f64, (lifted, gap): (Point, f64)| -> OgeomResult<f64> {
855        if paced || gap <= tol.confusion() {
856            return Ok(gap);
857        }
858        let at = |s: f64| -> OgeomResult<f64> { Ok(curve.point_at(s, tol)?.distance(lifted)) };
859        let t = (range.1 - range.0).mul_add(f, range.0);
860        // A lifted point standing square off the curve's tangent is already
861        // as near the curve's point here as any, to well under a part in a
862        // thousand of the gap.
863        let tangent = curve.d1_at(t, tol)?;
864        let along = (lifted - curve.point_at(t, tol)?).dot(tangent).abs();
865        if along <= gap * tangent.magnitude() * 1e-2 {
866            return Ok(gap);
867        }
868        let (mut a, mut b) = ((t - step.abs()).max(lo), (t + step.abs()).min(hi));
869        for _ in 0..24 {
870            let (c, d) = (b - (b - a) * ratio, a + (b - a) * ratio);
871            if at(c)? < at(d)? {
872                b = d;
873            } else {
874                a = c;
875            }
876        }
877        Ok(gap.min(at(f64::midpoint(a, b))?))
878    };
879    let samples = (0..=SAMPLES)
880        .map(|i| paced_at(f64::from(i) / n))
881        .collect::<OgeomResult<Vec<_>>>()?;
882    let read = |i: usize| samples[i].map_or(0.0, |(_, gap)| gap);
883    let across = (0..=SAMPLES)
884        .zip(&samples)
885        .map(|(i, sample)| sample.map_or(Ok(0.0), |sample| across_at(f64::from(i) / n, sample)))
886        .collect::<OgeomResult<Vec<_>>>()?;
887
888    // The widest sample, each measured fully only while its paced distance,
889    // which bounds it, could still beat the widest found.
890    let mut order: Vec<usize> = (0..samples.len()).collect();
891    order.sort_by(|&a, &b| read(b).total_cmp(&read(a)));
892    let mut widest = 0.0_f64;
893    for &i in &order {
894        let Some(sample) = samples[i] else { continue };
895        if sample.1 <= widest {
896            break;
897        }
898        #[allow(clippy::cast_precision_loss)]
899        let f = i as f64 / n;
900        widest = widest.max(gap_at(f, sample)?);
901    }
902
903    // Each summit, found by a golden-section search on the gap itself.
904    let mut by_height: Vec<usize> = (0..samples.len()).collect();
905    by_height.sort_by(|&a, &b| across[b].total_cmp(&across[a]));
906    let tallest = across[by_height[0]];
907    let mut peaks: Vec<usize> = by_height
908        .into_iter()
909        .filter(|&i| {
910            across[i] > tol.confusion()
911                && (i == 0 || across[i] >= across[i - 1])
912                && (i + 1 == samples.len() || across[i] >= across[i + 1])
913        })
914        .take(PEAKS)
915        .enumerate()
916        .take_while(|&(k, i)| k < 3 || across[i] >= tallest * NEAR_PEAK)
917        .map(|(_, i)| i)
918        .collect();
919    peaks.extend([0, samples.len() - 1]);
920    for i in peaks {
921        #[allow(clippy::cast_precision_loss)]
922        let (mut a, mut b) = (
923            i.saturating_sub(1) as f64 / n,
924            (i + 1).min(samples.len() - 1) as f64 / n,
925        );
926        // Climbed on the height the peaks were picked by, which costs no
927        // search along the curve, and the gap stated read at the top.
928        let height = |f: f64| -> OgeomResult<f64> {
929            paced_at(f)?.map_or(Ok(0.0), |sample| across_at(f, sample))
930        };
931        let (mut c, mut d) = (b - (b - a) * ratio, a + (b - a) * ratio);
932        let (mut hc, mut hd) = (height(c)?, height(d)?);
933        for _ in 0..20 {
934            if hc > hd {
935                (b, d, hd) = (d, c, hc);
936                c = b - (b - a) * ratio;
937                hc = height(c)?;
938            } else {
939                (a, c, hc) = (c, d, hd);
940                d = a + (b - a) * ratio;
941                hd = height(d)?;
942            }
943        }
944        let top = if hc > hd { c } else { d };
945        if let Some(sample) = paced_at(top)? {
946            widest = widest.max(gap_at(top, sample)?);
947        }
948    }
949    Ok(widest)
950}
951
952/// [`lifted_gap`] where it exceeds the confusion distance; within it, the
953/// answer is only known to be within it.
954///
955/// A line, a circle or an ellipse against a line, a circle or an ellipse on
956/// a plane, or an iso line of a cylinder, a cone, a sphere or a torus, is
957/// answered in closed form where that stands within the confusion distance
958/// ([`crate::pcurve_gap::pcurve_gap`]'s forms). Within it the sampling
959/// compares every point with the same fraction's point of the curve, so
960/// its answer is the widest of the direct gaps it read: no wider than the
961/// widest over the range, which the closed form bounds, and no narrower
962/// than the gap at the ends, which the bound is within rounding of.
963///
964/// # Errors
965///
966/// As [`lifted_gap`].
967pub fn lifted_gap_past_confusion(
968    curve: (&Curve, (f64, f64)),
969    pcurve: (&PlanarCurve, (f64, f64)),
970    surface: &SurfaceGeometry,
971    paced: bool,
972    tol: Tolerances,
973) -> OgeomResult<f64> {
974    let stated = tol.confusion();
975    let Some((gap, margin)) = crate::pcurve_gap::closed_gap(curve, pcurve, surface, stated, tol)
976    else {
977        return lifted_gap(curve, pcurve, surface, paced, tol);
978    };
979    if cfg!(debug_assertions) {
980        let sampled = lifted_gap(curve, pcurve, surface, paced, tol);
981        assert!(
982            sampled
983                .as_ref()
984                .is_ok_and(|s| (gap - s).abs() <= margin && *s <= stated),
985            "closed-form lifted gap {gap:e} (within {margin:e}) against sampled {sampled:?}, \
986             stated {stated:e}"
987        );
988    }
989    Ok(gap)
990}
991
992/// Re-project the samples a stalled projection left behind.
993///
994/// Where a chart collapses (a spline patch whose whole `v = 0` row is a
995/// single point), the projector has no direction to move in, and it answers
996/// with the pole's own parameters and the distance to it. A run of samples
997/// pinned to such a row would be taken for the file's own boundary slop,
998/// the edge widened to cover it, and a curve drawn through it. A sample
999/// that landed badly is retried from a neighbour that landed well, the
1000/// same seeding the forward walk already trusts, run in both directions so
1001/// a run of them unwinds from whichever end is sound. The retry is kept
1002/// only when it lands closer, so it can never make an honest projection
1003/// worse: a file's real slop is left alone.
1004fn retry_stalled(
1005    surface: &SurfaceGeometry,
1006    points: &[Point],
1007    trace: &mut [ogeom_math::Point2],
1008    offs: &mut [f64],
1009    tol: Tolerances,
1010) {
1011    let sound = tol.confusion() * 1e5;
1012    if offs.iter().all(|off| *off <= sound) {
1013        return;
1014    }
1015    for backwards in [true, false] {
1016        let order: Vec<usize> = if backwards {
1017            (0..offs.len()).rev().collect()
1018        } else {
1019            (0..offs.len()).collect()
1020        };
1021        for i in order {
1022            if offs[i] <= sound {
1023                continue;
1024            }
1025            let Some(j) = (if backwards {
1026                i.checked_add(1)
1027            } else {
1028                i.checked_sub(1)
1029            }) else {
1030                continue;
1031            };
1032            if offs.get(j).is_none_or(|off| *off > sound) {
1033                continue;
1034            }
1035            let seed = (trace[j].x, trace[j].y);
1036            if let Ok(found) =
1037                crate::measure::project_on_surface_from(surface, points[i], seed, tol)
1038                && found.distance < offs[i]
1039            {
1040                trace[i] = ogeom_math::Point2::new(found.parameters.0, found.parameters.1);
1041                offs[i] = found.distance;
1042            }
1043        }
1044    }
1045}
1046
1047/// Slide a trace back into its chart by whole turns.
1048///
1049/// Unwrapped for continuity, a trace can end up a whole turn outside the
1050/// chart it belongs to: a projection that starts near one edge of a closed
1051/// chart and walks off it keeps walking, and the surface then refuses to be
1052/// evaluated where its own trim lies, so the face draws as a hole.
1053///
1054/// A rigid shift keeps the trace exactly as continuous as the unwrap left
1055/// it and can only move it inward. One that genuinely spans more than a
1056/// turn has nowhere to go and is left alone; one that fits nowhere whole
1057/// takes the turn that centres it, which is the nearest thing to inside
1058/// there is.
1059fn slide_into_chart(
1060    trace: &mut [ogeom_math::Point2],
1061    spans: (f64, f64),
1062    domain: ((f64, f64), (f64, f64)),
1063) {
1064    let ((ua, ub), (va, vb)) = domain;
1065    for (across, span, lo, hi) in [(true, spans.0, ua, ub), (false, spans.1, va, vb)] {
1066        if span <= 0.0 {
1067            continue;
1068        }
1069        let read = |uv: &ogeom_math::Point2| if across { uv.x } else { uv.y };
1070        let (mut least, mut most) = (f64::INFINITY, f64::NEG_INFINITY);
1071        for uv in trace.iter() {
1072            least = least.min(read(uv));
1073            most = most.max(read(uv));
1074        }
1075        if !(least.is_finite() && most.is_finite()) || most - least > span {
1076            continue;
1077        }
1078        let turns = {
1079            let up = ((lo - least) / span).ceil();
1080            let down = ((hi - most) / span).floor();
1081            if up <= down {
1082                // Somewhere it fits whole; the nearest such turn.
1083                up.max(down.min(0.0))
1084            } else {
1085                (f64::midpoint(lo, hi) - f64::midpoint(least, most)) / span
1086            }
1087        };
1088        let turns = if turns.is_finite() {
1089            turns.round()
1090        } else {
1091            0.0
1092        };
1093        if turns == 0.0 {
1094            continue;
1095        }
1096        for uv in trace.iter_mut() {
1097            if across {
1098                uv.x += turns * span;
1099            } else {
1100                uv.y += turns * span;
1101            }
1102        }
1103    }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    #![allow(clippy::unwrap_used, reason = "test code")]
1109    use ogeom_math::Point2;
1110
1111    /// A trace unwrapped clean off its chart is slid back by whole turns.
1112    ///
1113    /// A fitted `v` running from −2.5π to −π on a chart that stops at −π
1114    /// is continuous and outside. The surface refuses to be asked about
1115    /// it, and the face draws as a hole.
1116    #[test]
1117    fn a_trace_that_walked_off_its_chart_is_slid_back() {
1118        let pi = core::f64::consts::PI;
1119        let chart = ((0.0, 1.0), (-pi, pi));
1120        let turn = (0.0, 2.0 * pi);
1121
1122        // A whole turn below: slid up, and as continuous as it was.
1123        let mut trace = vec![
1124            Point2::new(0.5, -2.5 * pi),
1125            Point2::new(0.5, -2.0 * pi),
1126            Point2::new(0.5, -1.5 * pi),
1127        ];
1128        super::slide_into_chart(&mut trace, turn, chart);
1129        assert!(
1130            trace.iter().all(|at| at.y >= -pi && at.y <= pi),
1131            "slid into the chart: {trace:?}"
1132        );
1133        for pair in trace.windows(2) {
1134            assert!(
1135                (pair[1].y - pair[0].y - 0.5 * pi).abs() < 1e-12,
1136                "and rigidly"
1137            );
1138        }
1139
1140        // Already inside: untouched.
1141        let mut held = vec![Point2::new(0.5, -1.0), Point2::new(0.5, 1.0)];
1142        let was = held.clone();
1143        super::slide_into_chart(&mut held, turn, chart);
1144        assert_eq!(held, was);
1145
1146        // Wider than a turn: nowhere to slide to, and left alone.
1147        let mut wide = vec![Point2::new(0.5, -4.0 * pi), Point2::new(0.5, 0.0)];
1148        let was = wide.clone();
1149        super::slide_into_chart(&mut wide, turn, chart);
1150        assert_eq!(wide, was);
1151
1152        // An axis that does not close is not slid on at all.
1153        let mut across = vec![Point2::new(9.0, 0.0), Point2::new(9.5, 0.0)];
1154        let was = across.clone();
1155        super::slide_into_chart(&mut across, (0.0, 2.0 * pi), chart);
1156        assert_eq!(across, was);
1157    }
1158
1159    use super::*;
1160    use ogeom_core::Tolerances;
1161    use ogeom_geom::{CircleCurve, CylinderSurface};
1162    use ogeom_math::{Circle, Cylinder, Frame};
1163
1164    const T: Tolerances = Tolerances::millimetres();
1165
1166    /// A sample the projector left at a pole is retried from its neighbour.
1167    ///
1168    /// The patch is `S(u, v) = v·C(u)`: its whole `v = 0` row is the origin,
1169    /// so a projection that reaches the pole has no direction left to move
1170    /// in and stops there, however far off it is. Seeding from the pole is
1171    /// shown stuck first (that is the trap the forward walk falls into,
1172    /// once per imported edge that starts on such a row), and the retry from a
1173    /// sound neighbour is shown to get out of it.
1174    #[test]
1175    fn a_sample_stalled_at_a_pole_is_retried_from_its_neighbour() {
1176        use ogeom_geom::{BSplineSurface, Surface as _};
1177        use ogeom_math::{ControlGrid, KnotVector};
1178
1179        let mut control = Vec::new();
1180        for i in 0..4 {
1181            let across = -1.0 + 2.0 * f64::from(i) / 3.0;
1182            for j in 0..4 {
1183                let out = f64::from(j) / 3.0;
1184                control.push(Point::new(out, across * out, (0.5 + across * across) * out));
1185            }
1186        }
1187        let grid = ControlGrid::new(control, 4, 4).unwrap();
1188        let cone: SurfaceGeometry = BSplineSurface::new(
1189            KnotVector::clamped_uniform(3, 4).unwrap(),
1190            KnotVector::clamped_uniform(3, 4).unwrap(),
1191            &grid,
1192            T,
1193        )
1194        .unwrap()
1195        .into();
1196        assert!(
1197            cone.d1_at(0.5, 0.0, T).unwrap().0.magnitude() < 1e-12,
1198            "the v = 0 row is a pole"
1199        );
1200
1201        let at = cone.point_at(1.0, 0.2, T).unwrap();
1202        let stuck = crate::measure::project_on_surface_from(&cone, at, (0.0, 0.0), T).unwrap();
1203        assert!(
1204            stuck.distance > 0.1,
1205            "a projection seeded at the pole stays there: {stuck:?}"
1206        );
1207
1208        let mut trace = vec![
1209            ogeom_math::Point2::new(0.0, 0.0),
1210            ogeom_math::Point2::new(0.0, 0.0),
1211            ogeom_math::Point2::new(1.0, 0.5),
1212        ];
1213        let points = vec![
1214            cone.point_at(1.0, 0.1, T).unwrap(),
1215            at,
1216            cone.point_at(1.0, 0.5, T).unwrap(),
1217        ];
1218        let mut offs = vec![points[0].distance(Point::ORIGIN), stuck.distance, 0.0];
1219        retry_stalled(&cone, &points, &mut trace, &mut offs, T);
1220        for (index, off) in offs.iter().enumerate() {
1221            assert!(
1222                *off < T.confusion(),
1223                "sample {index} found its surface: {off:.3e}"
1224            );
1225        }
1226        assert!(
1227            (trace[1].x - 1.0).abs() < 1e-6 && (trace[1].y - 0.2).abs() < 1e-6,
1228            "and its own parameters: {:?}",
1229            trace[1]
1230        );
1231
1232        // An honest miss is not a stall: nothing lands closer, so the
1233        // file's own slop survives the retry untouched.
1234        let adrift = Point::new(0.0, 0.0, -0.5);
1235        let mut honest = vec![trace[2], ogeom_math::Point2::new(1.0, 0.5)];
1236        let was = honest.clone();
1237        let mut misses = vec![0.0, 0.5];
1238        retry_stalled(&cone, &[points[2], adrift], &mut honest, &mut misses, T);
1239        assert_eq!(honest[1], was[1]);
1240        assert!((misses[1] - 0.5).abs() < 1e-12);
1241    }
1242
1243    /// A direction is weak by what crossing it moves, not by its neighbour.
1244    ///
1245    /// The patch is a flat strip: `u` runs a millimetre across it and `v`
1246    /// runs twenty millimetres along it over a parameter span of a hundredth,
1247    /// two thousand times denser than `u`. Against `dv`, `du` looks weak at
1248    /// every sample, and a ratio test holds every `u` at one value: an edge
1249    /// a millimetre long across the strip fits as a single chart point.
1250    /// Crossing the whole of `u` moves the point a millimetre, which is the
1251    /// only thing "weak" can honestly mean, and it is not.
1252    #[test]
1253    fn a_direction_is_weak_by_what_crossing_it_moves() {
1254        use ogeom_geom::{BSplineSurface, LineCurve};
1255        use ogeom_math::{ControlGrid, KnotVector, Point};
1256        let mut control = Vec::new();
1257        for i in 0..2 {
1258            for j in 0..2 {
1259                control.push(Point::new(f64::from(i), 20.0 * f64::from(j), 0.0));
1260            }
1261        }
1262        let strip: SurfaceGeometry = BSplineSurface::new(
1263            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1264            KnotVector::new(vec![0.0, 0.0, 0.01, 0.01], 1).unwrap(),
1265            &ControlGrid::new(control, 2, 2).unwrap(),
1266            T,
1267        )
1268        .unwrap()
1269        .into();
1270        // Across the strip at v = 0.005 (the middle in space, 10 mm along).
1271        let across: Curve =
1272            LineCurve::segment(Point::new(0.0, 10.0, 0.0), Point::new(1.0, 10.0, 0.0), T)
1273                .unwrap()
1274                .into();
1275        let (pcurve, error, _, _, _) =
1276            fit_projected_pcurve(&across, (0.0, 1.0), &strip, T).unwrap();
1277        let a = ogeom_geom::Curve2d::point_at(&pcurve, 0.0, T).unwrap();
1278        let b = ogeom_geom::Curve2d::point_at(&pcurve, 1.0, T).unwrap();
1279        assert!(
1280            (b.x - a.x).abs() > 0.99,
1281            "the edge crosses the whole of u: {a:?} -> {b:?}"
1282        );
1283        assert!(error < 1e-6, "and fits: {error:.2e}");
1284    }
1285
1286    /// A flat patch over the unit square, a spline surface whose chart
1287    /// inverts only by projection.
1288    fn flat_patch() -> SurfaceGeometry {
1289        use ogeom_geom::BSplineSurface;
1290        use ogeom_math::{ControlGrid, KnotVector, Point};
1291        let control = [(0.0, -1.0), (0.0, 1.0), (1.0, -1.0), (1.0, 1.0)]
1292            .map(|(x, y)| Point::new(x, y, 0.0))
1293            .to_vec();
1294        BSplineSurface::new(
1295            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1296            KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1).unwrap(),
1297            &ControlGrid::new(control, 2, 2).unwrap(),
1298            T,
1299        )
1300        .unwrap()
1301        .into()
1302    }
1303
1304    /// A pcurve's error covers the points between its samples on a patch.
1305    ///
1306    /// The curve runs across the patch in a sine of a hundredth of a
1307    /// millimetre whose period is the samples' spacing: every sample and
1308    /// every middle between two stands on the sine's axis, and the fit
1309    /// through them is the axis. The quarter points stand on its crests,
1310    /// and the error stated is the crest's height.
1311    #[test]
1312    fn a_fit_on_a_patch_states_its_widest_miss_between_the_samples() {
1313        use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
1314        use ogeom_math::Point;
1315        let amplitude = 0.01;
1316        let sine = |t: f64| {
1317            Point::new(
1318                t,
1319                amplitude * (core::f64::consts::TAU * 96.0 * t).sin(),
1320                0.0,
1321            )
1322        };
1323        let ts: Vec<f64> = (0..=3840).map(|i| f64::from(i) / 3840.0).collect();
1324        let points: Vec<Point> = ts.iter().map(|t| sine(*t)).collect();
1325        let fitted = ogeom_geom::fit::fit_points_at(&ts, &points, 3, 1e-8, T).unwrap();
1326        let curve: Curve = fitted.curve.into();
1327        let patch = flat_patch();
1328        let (pcurve, error, met, _, _) =
1329            fit_projected_pcurve(&curve, (0.0, 1.0), &patch, T).unwrap();
1330        let mut worst = 0.0_f64;
1331        for i in 0..=20_000 {
1332            let t = f64::from(i) / 20_000.0;
1333            let uv = pcurve.point_at(t, T).unwrap();
1334            let lifted = patch.point_at(uv.x, uv.y, T).unwrap();
1335            worst = worst.max(lifted.distance(curve.point_at(t, T).unwrap()));
1336        }
1337        assert!(
1338            worst > 0.9 * amplitude,
1339            "the fit misses the crests: {worst:.3e}"
1340        );
1341        assert!(
1342            error >= worst,
1343            "the widest miss is stated: {error:.3e} for {worst:.3e}"
1344        );
1345        assert!(!met, "and the target is not met");
1346    }
1347
1348    /// The gap stated where a pcurve is paced differently from its curve
1349    /// is found at its widest. The pcurve runs along a straight line of the
1350    /// patch a little ahead of it, and strays across it in bumps about as
1351    /// narrow as the measure's samples are apart, the tallest three
1352    /// quarters along. The paced distance is the lead's, which peaks
1353    /// halfway; the samples read the tallest bump a few percent low, and
1354    /// only a climb from a peak of the distance across reaches its top.
1355    #[test]
1356    fn a_pcurve_paced_differently_is_measured_at_its_widest_bump() {
1357        use ogeom_geom::{Curve2d as _, LineCurve};
1358        use ogeom_math::{Point, Point2};
1359        let edge: Curve =
1360            LineCurve::segment(Point::new(0.0, 0.0, 0.0), Point::new(1.0, 0.0, 0.0), T)
1361                .unwrap()
1362                .into();
1363        let patch = flat_patch();
1364        // u = t plus a slow lead; v (half of y on this patch) carries the
1365        // bumps, tallest three quarters along.
1366        let lead = |t: f64| 0.002 * (core::f64::consts::PI * t).sin();
1367        let across = |t: f64| {
1368            let bump = (core::f64::consts::TAU * 37.3 * t).sin().max(0.0).powi(8);
1369            1e-5 * (1.0 + 0.5 * (-((t - 0.75) / 0.05).powi(2)).exp()) * bump
1370        };
1371        let ts: Vec<f64> = (0..=4000).map(|i| f64::from(i) / 4000.0).collect();
1372        let chart: Vec<Point2> = ts
1373            .iter()
1374            .map(|&t| Point2::new(t + lead(t), 0.5 + 0.5 * across(t)))
1375            .collect();
1376        let pcurve: PlanarCurve = ogeom_geom::fit::fit_points_2d_at(&ts, &chart, 3, 1e-10, T)
1377            .unwrap()
1378            .curve
1379            .into();
1380        let mut truth = 0.0_f64;
1381        for i in 0..=40_000 {
1382            let t = f64::from(i) / 40_000.0;
1383            let uv = pcurve.point_at(t, T).unwrap();
1384            let lifted = patch.point_at(uv.x, uv.y, T).unwrap();
1385            // The edge is the line y = 0: the nearest-point gap is |y|.
1386            truth = truth.max(lifted.y.abs());
1387        }
1388        let gap = lifted_gap((&edge, (0.0, 1.0)), (&pcurve, (0.0, 1.0)), &patch, false, T).unwrap();
1389        assert!(
1390            gap >= 0.99 * truth && gap <= truth * 1.001 + 1e-12,
1391            "the widest bump, {truth:.4e}, is stated: {gap:.4e}"
1392        );
1393    }
1394
1395    /// A boundary 0.3 mm off its surface fits, and says so.
1396    ///
1397    /// Exchange files carry boundary curves that far from the surfaces
1398    /// they trim, up to a third of a millimetre. The fit
1399    /// accepts anything under a millimetre and reports the offset, so the
1400    /// reader widens the edge's tolerance instead of leaving the face
1401    /// without a trim; a miss of whole millimetres (the signature of an
1402    /// edge paired with the wrong surface) still refuses.
1403    #[test]
1404    fn slop_under_a_millimetre_fits_and_is_reported() {
1405        let wall: SurfaceGeometry =
1406            CylinderSurface::new(Cylinder::new(Frame::WORLD, 10.0, T).unwrap(), (-50.0, 50.0))
1407                .unwrap()
1408                .into();
1409        let rim = |radius: f64| -> Curve {
1410            CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
1411        };
1412        // 0.3 mm proud of the wall: every sample sits exactly that far off.
1413        let (_, _, _, worst_off, warning) =
1414            fit_projected_pcurve(&rim(10.3), (0.0, core::f64::consts::TAU), &wall, T).unwrap();
1415        assert!(
1416            (worst_off - 0.3).abs() < 1e-6,
1417            "the offset is measured: {worst_off}"
1418        );
1419        assert!(warning.is_some(), "slop this large is worth a warning");
1420        // 3 mm off is not slop; it is the wrong surface.
1421        assert!(
1422            fit_projected_pcurve(&rim(13.0), (0.0, core::f64::consts::TAU), &wall, T).is_err(),
1423            "a miss of millimetres still refuses"
1424        );
1425    }
1426
1427    /// A fit is held between its samples, where a fast-running curve bends.
1428    ///
1429    /// The samples are spaced evenly in the curve's parameter, and this
1430    /// curve spends a twentieth of its parameter on a steep drop of a
1431    /// millimetre before running slowly round the drum for the rest: the
1432    /// whole of the drop, and the bend at its foot, fall inside the first
1433    /// sample interval. A cubic held only at the samples hooks past the
1434    /// curve there. Measured between the samples and refitted where it
1435    /// leaves them, the pcurve follows the curve everywhere.
1436    #[test]
1437    fn a_fit_is_held_between_its_samples_where_the_curve_runs_fast() {
1438        use ogeom_geom::{BSplineCurve, Curve, Curve2d, Curve3d, Surface, SurfaceGeometry};
1439        use ogeom_math::{KnotVector, Point};
1440        let radius = 10.0;
1441        let on = |angle: f64, z: f64| Point::new(radius * angle.cos(), radius * angle.sin(), z);
1442        let mut knots = vec![0.0, 0.0, 0.0, 0.0, 0.05];
1443        knots.extend((1..8).map(f64::from));
1444        knots.extend([8.0; 4]);
1445        let control = vec![
1446            on(0.12, 4.0),
1447            on(0.13, 3.7),
1448            on(0.14, 3.4),
1449            on(0.15, 3.1),
1450            on(0.10, 2.5),
1451            on(0.0, 1.5),
1452            on(-0.1, 0.5),
1453            on(-0.2, -0.5),
1454            on(-0.3, -1.5),
1455            on(-0.35, -2.5),
1456            on(-0.38, -3.3),
1457            on(-0.4, -4.0),
1458        ];
1459        let curve: Curve = BSplineCurve::new(KnotVector::new(knots, 3).unwrap(), control, T)
1460            .unwrap()
1461            .into();
1462        let drum: SurfaceGeometry =
1463            CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-5.0, 5.0))
1464                .unwrap()
1465                .into();
1466        let (pcurve, error, _, _, _) = fit_projected_pcurve(&curve, (0.0, 8.0), &drum, T).unwrap();
1467        let mut worst = 0.0_f64;
1468        for i in 0..=2000 {
1469            let t = 8.0 * f64::from(i) / 2000.0;
1470            let p = curve.point_at(t, T).unwrap();
1471            let uv = pcurve.point_at(t, T).unwrap();
1472            let lifted = drum.point_at(uv.x, uv.y, T).unwrap();
1473            // The pcurve's own miss: the curve's point, pulled onto the drum.
1474            worst = worst.max(lifted.distance(on(p.y.atan2(p.x), p.z)));
1475        }
1476        assert!(
1477            worst <= T.confusion() * 3e4,
1478            "the pcurve leaves the curve by {worst:.3e} between the samples, \
1479             {error:.3e} reported"
1480        );
1481        assert!(
1482            error >= worst,
1483            "and the widest miss between the samples is reported: {error:.3e} for {worst:.3e}"
1484        );
1485    }
1486
1487    /// A circle on a plane against its image moved across the plane: by a
1488    /// hair under the confusion distance it is answered in closed form,
1489    /// within rounding of the sampled gap; by a micron, past it, it is
1490    /// the sampled gap itself, which the edge is widened by.
1491    #[test]
1492    fn a_gap_past_the_confusion_distance_is_measured_as_sampling_measures_it() {
1493        use ogeom_core::Tolerances;
1494        use ogeom_geom::{Circle2d, CircleCurve, PlaneSurface};
1495        use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, Plane, Vector2};
1496        const T: Tolerances = Tolerances::millimetres();
1497        let plane: super::SurfaceGeometry = PlaneSurface::new(Plane::new(Frame::WORLD)).into();
1498        let curve: super::Curve =
1499            CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1500        let range = (0.0, core::f64::consts::TAU);
1501        for (shift, past) in [(0.5e-7, false), (1e-6, true)] {
1502            let frame = Frame2::new(
1503                Point2::new(shift, 0.0),
1504                Direction2::new(Vector2::new(1.0, 0.0), T).unwrap(),
1505            );
1506            let image: super::PlanarCurve =
1507                Circle2d::new(Circle2::new(frame, 4.0, T).unwrap()).into();
1508            let sampled =
1509                super::lifted_gap((&curve, range), (&image, range), &plane, false, T).unwrap();
1510            let answered = super::lifted_gap_past_confusion(
1511                (&curve, range),
1512                (&image, range),
1513                &plane,
1514                false,
1515                T,
1516            )
1517            .unwrap();
1518            if past {
1519                assert_eq!(
1520                    answered.to_bits(),
1521                    sampled.to_bits(),
1522                    "{answered:e} {sampled:e}"
1523                );
1524            } else {
1525                // The closed form's rounding margin for numbers of size 4.
1526                assert!(
1527                    answered <= T.confusion() && (answered - shift).abs() <= 1e-12,
1528                    "{answered:e}"
1529                );
1530                assert!((sampled - shift).abs() <= 1e-12, "{sampled:e}");
1531            }
1532        }
1533    }
1534}