Skip to main content

ogeom_intersect/
approx.rs

1//! The approximation stage: a traced branch becomes curves.
2//!
3//! A traced branch is a polyline with a stated chord tolerance: honest, and
4//! not what anything downstream wants to hold. An edge wants a curve in space;
5//! a face wants that curve in its *own parameter space*, because splitting a
6//! face happens there and a curve the face cannot express is a curve it cannot
7//! be split along (`docs/DATA_MODEL.md` §6).
8//!
9//! So one branch becomes three fits sharing one tolerance: the 3D curve, and
10//! one pcurve per surface, each fitted from the samples the tracer already
11//! recorded. The tracer kept the parameters on both surfaces at every point
12//! precisely for this moment; re-deriving them here would be a projection per
13//! point, solving again what the marcher already solved.
14//!
15//! # The tolerance story, stated once
16//!
17//! The result's tolerance is a *sum of stated parts*, not a hope: the trace
18//! sits within its chord tolerance of the true intersection, and the fit sits
19//! within its own reported error of the trace. Both numbers are carried, and
20//! the total is what an edge built on this curve must widen its tolerance to.
21//! Nothing here rounds a miss up to a hit; a fit that could not reach its
22//! target says so, and the caller decides whether the looser curve is usable.
23//!
24//! # Seams
25//!
26//! A branch crossing a periodic surface's seam has parameter samples that jump
27//! by a period: the pcurve polyline tears even though the curve in space is
28//! smooth. The samples are unwrapped before fitting: each step is folded to
29//! the nearest image, so the pcurve runs continuously past the seam and may
30//! legitimately leave `[0, 2π)`. That is what a pcurve on a periodic surface
31//! is; folding it back would re-tear it.
32
33use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
34use ogeom_geom::{BSpline2d, BSplineCurve, Surface, SurfaceGeometry};
35use ogeom_math::Point2;
36
37use crate::march::Traced;
38
39/// A branch of an intersection, as curves.
40#[derive(Debug, Clone, PartialEq)]
41pub struct IntersectionCurve {
42    /// The curve in space.
43    pub curve: BSplineCurve,
44    /// The same curve in the first surface's parameter space.
45    pub on_a: BSpline2d,
46    /// And in the second's.
47    pub on_b: BSpline2d,
48    /// How far the *fits* may sit from the traced polyline, in millimetres.
49    ///
50    /// Measured: the curve against the trace's samples, each pcurve lifted
51    /// against the curve, the curve against the surfaces' crossing between
52    /// the samples, and, where the surfaces meet at a shallow angle, how far
53    /// along them their crossing may sit from the curve. The
54    /// distance to the true intersection adds the trace's own chord
55    /// tolerance on top; both are stated so an edge built on this knows
56    /// what to carry.
57    pub fit_error: f64,
58    /// Whether every fit met the tolerance it was asked for.
59    pub met: bool,
60    /// Whether the branch is a closed loop.
61    pub closed: bool,
62}
63
64/// Fit one traced branch to curves, within `tolerance`.
65///
66/// # Errors
67///
68/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the branch has
69/// fewer than two points or the tolerance is not a positive distance.
70pub fn approximate_branch(
71    a: &SurfaceGeometry,
72    b: &SurfaceGeometry,
73    branch: &Traced,
74    tolerance: f64,
75    tol: Tolerances,
76) -> OgeomResult<IntersectionCurve> {
77    if branch.points.len() < 2 {
78        ogeom_bail!(
79            Construction,
80            "a branch of {} points is not a curve",
81            branch.points.len()
82        );
83    }
84
85    // Marching correction can leave consecutive samples closer than the
86    // rounding it converged within, and two samples at one chord-length
87    // parameter are a knot span with no data in it: the fitting system
88    // reports itself singular where the real defect is the duplicate. Thin
89    // them here, where the trace's own step says what "too close" means.
90    let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
91    let mut kept_a = Vec::with_capacity(branch.on_a.len());
92    let mut kept_b = Vec::with_capacity(branch.on_b.len());
93    // A sample is one point seen three ways, and where the three disagree
94    // it is not data: through a point where the surfaces touch, the tracer
95    // can report a step's position with its neighbour's parameters, and
96    // the joint fit, asked to pass through both descriptions at once,
97    // stalls a thousand times above its budget at that one sample.
98    let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
99        let limit = tolerance.max(tol.confusion());
100        let (ua, va) = branch.on_a[i];
101        let (ub, vb) = branch.on_b[i];
102        a.point_at(ua, va, tol)
103            .is_ok_and(|q| q.distance(*p) <= limit)
104            && b.point_at(ub, vb, tol)
105                .is_ok_and(|q| q.distance(*p) <= limit)
106    };
107    for (i, p) in branch.points.iter().enumerate() {
108        let end = i == 0 || i + 1 == branch.points.len();
109        if let Some(last) = points.last()
110            && last.distance(*p) <= tol.confusion() * 10.0
111            && i + 1 != branch.points.len()
112        {
113            continue;
114        }
115        if !end && !agrees(i, p) {
116            continue;
117        }
118        points.push(*p);
119        kept_a.push(branch.on_a[i]);
120        kept_b.push(branch.on_b[i]);
121    }
122    if points.len() < 2 {
123        ogeom_bail!(Construction, "a branch of coincident points is not a curve");
124    }
125
126    // One fit in seven dimensions: the curve and both parameter images
127    // together. Fitted separately, each fit's parameter correction drifts
128    // its parameterization independently and the three results silently stop
129    // being same-parameter: a pcurve claiming 1e-7 can evaluate millimetres
130    // from its own curve. Jointly, one
131    // parameterization and one knot vector serve all three, and the reported
132    // error bounds every coordinate.
133    let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
134    let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
135    // A closed branch takes the loop-smoothing fit: the join's tangents are
136    // constrained to agree in all seven coordinates, so the section curve and
137    // both pcurves cross their own seam without a crease. A loop winding
138    // once round a periodic surface ends its image there a period from where
139    // it began, and that fit, closing only where every image does, takes it
140    // as an open trace whose ends lie close together, which can stall far
141    // from the trace. Where it does, the loop is fitted closed in space with
142    // the images' join C1 across the seam, right where the chart runs at one
143    // speed across it, as a periodic surface's does (a patch that merely
144    // meets itself at its seam need not), and the closer of the two stands.
145    let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
146        let (first, last) = (image[0], image[image.len() - 1]);
147        ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
148            && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
149    };
150    let free = || -> OgeomResult<Joint> {
151        if branch.closed() {
152            let closed = ogeom_geom::fit::fit_points_joint_closed(
153                &points,
154                &unwrapped_a,
155                &unwrapped_b,
156                3,
157                tolerance,
158                tol,
159            )?;
160            if !closed.0.met
161                && winds_periodically(a, &unwrapped_a)
162                && winds_periodically(b, &unwrapped_b)
163            {
164                let winding = ogeom_geom::fit::fit_points_joint_winding(
165                    &points,
166                    &unwrapped_a,
167                    &unwrapped_b,
168                    3,
169                    tolerance,
170                    tol,
171                )?;
172                if winding.0.error < closed.0.error {
173                    return Ok(winding);
174                }
175            }
176            Ok(closed)
177        } else {
178            ogeom_geom::fit::fit_points_joint(
179                &points,
180                &unwrapped_a,
181                &unwrapped_b,
182                3,
183                tolerance,
184                tol,
185            )
186        }
187    };
188    // The walk steps by how far its chord sags, which for a cubic is far
189    // finer than the fit needs: between samples a step `h` apart on a curve
190    // turning at `k`, a cubic misses by about `h^4 k^3 / 384` where the
191    // chord sags by `h^2 k / 8`. So the knots start where that estimate
192    // places a cubic's spans, every sample measures the fit, and only a
193    // fit that misses one is made the free way as well, the closer of the
194    // two standing.
195    let stations = cubic_stations(&points, &unwrapped_a, &unwrapped_b, tolerance);
196    let seeded = |closed: bool| {
197        ogeom_geom::fit::fit_points_joint_from(
198            &points,
199            &unwrapped_a,
200            &unwrapped_b,
201            &stations,
202            closed,
203            3,
204            tolerance,
205            tol,
206        )
207    };
208    let held = |closed: bool| {
209        ogeom_geom::fit::fit_points_joint_from_held(
210            &points,
211            &unwrapped_a,
212            &unwrapped_b,
213            &stations,
214            closed,
215            3,
216            tolerance,
217            tol,
218        )
219    };
220    let meets_itself = {
221        let n = points.len();
222        let (pa, pb) = (
223            unwrapped_a[n - 1] - unwrapped_a[0],
224            unwrapped_b[n - 1] - unwrapped_b[0],
225        );
226        let gap = (points[n - 1] - points[0]).square_magnitude()
227            + pa.square_magnitude()
228            + pb.square_magnitude();
229        gap.sqrt() <= tol.confusion()
230    };
231    let mut fitted = seeded(branch.closed() && meets_itself).ok();
232    if branch.closed()
233        && !meets_itself
234        && fitted.as_ref().is_none_or(|f| f.0.error > tolerance)
235        && winds_periodically(a, &unwrapped_a)
236        && winds_periodically(b, &unwrapped_b)
237        && let Ok(winding) = seeded(true)
238        && fitted.as_ref().is_none_or(|f| winding.0.error < f.0.error)
239    {
240        fitted = Some(winding);
241    }
242    let mut tried_free = true;
243    let mut chosen = match fitted {
244        Some(fitted) if fitted.0.error <= tolerance => {
245            tried_free = false;
246            fitted
247        }
248        Some(fitted) => {
249            let other = free()?;
250            if other.0.error < fitted.0.error {
251                other
252            } else {
253                fitted
254            }
255        }
256        None => free()?,
257    };
258
259    let measured =
260        |(space, on_a, on_b): &Joint| measured_error(a, b, (space, on_a, on_b), &points, tol);
261    let mut fit_error = measured(&chosen);
262    // A fit that misses is fitted again from the stations, splitting only
263    // spans that hold samples inside them
264    // ([`ogeom_geom::fit::fit_points_joint_from_held`]): where the first
265    // swung between two samples, this one stays near them. Measured the
266    // same way, the closer stands.
267    if fit_error > tolerance
268        && let Ok(held) = held(branch.closed() && meets_itself)
269    {
270        let again = measured(&held);
271        if again < fit_error {
272            (chosen, fit_error) = (held, again);
273        }
274    }
275    // A seeded fit within its tolerance at every sample stands without the
276    // free one being made, but it can pass every sample and still swing
277    // between two of them. Where it misses, measured, by more than a caller
278    // takes whole (as beside a pole, below), the free fit is made and
279    // measured the same way, and the closer stands.
280    if fit_error > tolerance * 1e2
281        && !tried_free
282        && let Ok(other) = free()
283    {
284        let again = measured(&other);
285        if again < fit_error {
286            (chosen, fit_error) = (other, again);
287        }
288    }
289    // Beside a pole of either chart the joint fit can miss by the chart's
290    // swing: there the branch is fitted in space alone, where it is smooth,
291    // and each image read off that curve ([`projected`]). Measured the same
292    // way, the closer stands. A miss within a hundred times the tolerance
293    // is one a caller takes whole (`fitted_in_pieces` in `section.rs`),
294    // and stands.
295    if fit_error > tolerance * 1e2
296        && (!crate::march::chart_poles(a, tol).is_empty()
297            || !crate::march::chart_poles(b, tol).is_empty())
298        && let Some(projected) = projected(
299            (a, b),
300            &points,
301            (&unwrapped_a, &unwrapped_b),
302            branch.closed(),
303            tolerance,
304            tol,
305        )
306    {
307        let again = measured(&projected.0).max(projected.1);
308        if again < fit_error {
309            (chosen, fit_error) = (projected.0, again);
310        }
311    }
312    // An image fitted up to a pole of its chart can overshoot the pole's
313    // bound between its samples, where the chart has no point. Its control
314    // points are held to the bound, so the image is (a spline lies in its
315    // control points' hull), and the fit measured again as it then stands.
316    if let (Some(on_a), Some(on_b)) = (
317        held_off_poles(a, &chosen.1, tol),
318        held_off_poles(b, &chosen.2, tol),
319    ) && (on_a != chosen.1 || on_b != chosen.2)
320    {
321        chosen = (chosen.0, on_a, on_b);
322        fit_error = measured(&chosen);
323    }
324    let (space, on_a, on_b) = chosen;
325    Ok(IntersectionCurve {
326        fit_error,
327        met: space.error <= tolerance,
328        curve: space.curve,
329        on_a,
330        on_b,
331        closed: branch.closed(),
332    })
333}
334
335/// The branch through `points` fitted in space alone, at their chord
336/// lengths, and each surface's image fitted to the curve's own points
337/// inverted on it, at the curve's parameters, with how far either image
338/// lifted stands from the curve where its fit read it; `None` where
339/// either fit or an inversion fails.
340///
341/// Beside a pole of a chart the section turns gently in space while its
342/// image swings: half a turn of longitude within a few times its distance
343/// from the pole, which may be a few samples of the trace or none. Fitted
344/// together, the image's swing holds the curve's knots and the curve the
345/// image's, and the fit can stall far above its tolerance. Fitted apart,
346/// the curve needs only the trace, and the image is read where the fit
347/// asks for it ([`ogeom_geom::fit::fit_curve_2d_sampled`]), between the
348/// samples as well as at them, each point inverted from the nearest one
349/// already read and unfolded against it. The image is held, in its chart,
350/// to `tolerance` over the surface's speed along the trace, and lifted it
351/// is measured against the curve at every parameter the fit read. It is
352/// fitted open, its ends pinned where the curve's are inverted: a loop's
353/// image returns to its start or, winding round the chart, a period on.
354fn projected(
355    (a, b): (&SurfaceGeometry, &SurfaceGeometry),
356    points: &[ogeom_math::Point],
357    (image_a, image_b): (&[Point2], &[Point2]),
358    closed: bool,
359    tolerance: f64,
360    tol: Tolerances,
361) -> Option<(Joint, f64)> {
362    use ogeom_geom::{Curve2d as _, Curve3d as _};
363    // Halving the step past the mantissa's bits leaves it where it started.
364    const HALVINGS: u32 = f64::MANTISSA_DIGITS;
365    // As `lift_error` climbs its peaks.
366    const PEAKS: usize = 16;
367    const CLIMB: usize = 16;
368    let mut along = Vec::with_capacity(points.len());
369    let mut length = 0.0;
370    along.push(length);
371    for pair in points.windows(2) {
372        let step = pair[0].distance(pair[1]);
373        if step <= 0.0 {
374            return None;
375        }
376        length += step;
377        along.push(length);
378    }
379    let space = if closed {
380        ogeom_geom::fit::fit_points_at_closed(&along, points, 3, tolerance, tol)
381    } else {
382        ogeom_geom::fit::fit_points_at(&along, points, 3, tolerance, tol)
383    }
384    .ok()?;
385    let image = |surface: &SurfaceGeometry, seeds: &[Point2]| -> Option<(BSpline2d, f64)> {
386        let ((ua, ub), (va, vb)) = surface.domain();
387        let periods = (
388            (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
389            (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
390        );
391        let fold = |near: f64, x: f64, period: Option<f64>| {
392            period.map_or(x, |period| x - ((x - near) / period).round() * period)
393        };
394        let speed = seeds
395            .iter()
396            .filter_map(|uv| surface.d1_at(uv.x, uv.y, tol).ok())
397            .map(|(du, dv)| du.magnitude() + dv.magnitude())
398            .fold(0.0_f64, f64::max);
399        if !(speed.is_finite() && speed > 0.0) {
400            return None;
401        }
402        // Every image read, by parameter: the parameters run from zero, so
403        // their bits sort as they do.
404        let mut read: std::collections::BTreeMap<u64, Point2> = along
405            .iter()
406            .map(|t| t.to_bits())
407            .zip(seeds.iter().copied())
408            .collect();
409        // A foot farther off than the curve may stand from the surface is
410        // Newton's step thrown past where the chart turns (onto a pole's
411        // bound, its longitude stuck): the point is reached in halves from
412        // the one read before it instead.
413        let limit = tolerance.max(space.error);
414        let foot_from = |t: f64, near: (f64, Point2)| -> OgeomResult<Point2> {
415            let mut from = near;
416            let mut to = t;
417            let mut halvings = 0;
418            loop {
419                let p = space.curve.point_at(to, tol)?;
420                let foot =
421                    ogeom_geom::project_on_surface_from(surface, p, (from.1.x, from.1.y), tol)?;
422                if foot.distance <= limit {
423                    let uv = Point2::new(
424                        fold(from.1.x, foot.parameters.0, periods.0),
425                        fold(from.1.y, foot.parameters.1, periods.1),
426                    );
427                    if to == t {
428                        return Ok(uv);
429                    }
430                    from = (to, uv);
431                    to = t;
432                    continue;
433                }
434                halvings += 1;
435                if halvings > HALVINGS {
436                    ogeom_bail!(NotDone, "a point of the curve inverts on no foot near it");
437                }
438                to = f64::midpoint(from.0, to);
439            }
440        };
441        let invert = |t: f64| -> OgeomResult<Point2> {
442            let key = t.to_bits();
443            let before = read
444                .range(..key)
445                .next_back()
446                .map(|(s, uv)| (f64::from_bits(*s), *uv));
447            let after = read
448                .range(key..)
449                .next()
450                .map(|(s, uv)| (f64::from_bits(*s), *uv));
451            let near = match (before, after) {
452                (Some(before), Some(after)) => {
453                    if t - before.0 < after.0 - t {
454                        before
455                    } else {
456                        after
457                    }
458                }
459                (Some(near), None) | (None, Some(near)) => near,
460                (None, None) => ogeom_bail!(Construction, "no image read to start from"),
461            };
462            let uv = foot_from(t, near)?;
463            read.entry(key).or_insert(uv);
464            Ok(uv)
465        };
466        let fitted =
467            ogeom_geom::fit::fit_curve_2d_sampled(invert, &along, false, 3, tolerance / speed, tol)
468                .ok()?;
469        // The lifted image against the curve at every parameter read and
470        // midway between each two, and the widest of those misses climbed
471        // to the top of its peak between its neighbours.
472        let gap = |t: f64| -> Option<f64> {
473            let uv = fitted.curve.point_at(t, tol).ok()?;
474            let lifted = surface.point_at(uv.x, uv.y, tol).ok()?;
475            Some(lifted.distance(space.curve.point_at(t, tol).ok()?))
476        };
477        let reads: Vec<f64> = read.keys().map(|&s| f64::from_bits(s)).collect();
478        let mut stations = Vec::with_capacity(2 * reads.len());
479        for pair in reads.windows(2) {
480            stations.push(pair[0]);
481            stations.push(f64::midpoint(pair[0], pair[1]));
482        }
483        stations.extend(reads.last());
484        let misses = stations
485            .iter()
486            .map(|&t| gap(t))
487            .collect::<Option<Vec<f64>>>()?;
488        let mut off = misses.iter().fold(0.0_f64, |m, &x| m.max(x));
489        let last = stations.len() - 1;
490        let mut peaks: Vec<usize> = (0..=last)
491            .filter(|&k| {
492                (k == 0 || misses[k] >= misses[k - 1]) && (k == last || misses[k] >= misses[k + 1])
493            })
494            .collect();
495        peaks.sort_by(|&i, &j| misses[j].total_cmp(&misses[i]));
496        let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
497        for k in peaks.into_iter().take(PEAKS) {
498            let (mut left, mut right) =
499                (stations[k.saturating_sub(1)], stations[(k + 1).min(last)]);
500            for _ in 0..CLIMB {
501                let (c, d) = (
502                    right - (right - left) * ratio,
503                    left + (right - left) * ratio,
504                );
505                let (rc, rd) = (gap(c)?, gap(d)?);
506                off = off.max(rc).max(rd);
507                if rc > rd {
508                    right = d;
509                } else {
510                    left = c;
511                }
512            }
513        }
514        Some((fitted.curve, off))
515    };
516    let (on_a, off_a) = image(a, image_a)?;
517    let (on_b, off_b) = image(b, image_b)?;
518    Some(((space, on_a, on_b), off_a.max(off_b)))
519}
520
521/// `image` with each control point held inside the bound of every pole of
522/// `surface`'s chart (see [`crate::march::chart_poles`]) it stands past;
523/// `None` where a held control point is no control point.
524fn held_off_poles(
525    surface: &SurfaceGeometry,
526    image: &BSpline2d,
527    tol: Tolerances,
528) -> Option<BSpline2d> {
529    let poles = crate::march::chart_poles(surface, tol);
530    if poles.is_empty() {
531        return Some(image.clone());
532    }
533    let ((ua, ub), (va, vb)) = surface.domain();
534    let mut changed = false;
535    let mut control = Vec::with_capacity(image.control_points().len());
536    for held in image.control_points() {
537        let mut point = held.point();
538        for &(k, bound, _) in &poles {
539            let (coordinate, lo, hi) = if k == 0 {
540                (&mut point.x, ua, ub)
541            } else {
542                (&mut point.y, va, vb)
543            };
544            let past = if (bound - hi).abs() <= (bound - lo).abs() {
545                *coordinate > bound
546            } else {
547                *coordinate < bound
548            };
549            if past {
550                *coordinate = bound;
551                changed = true;
552            }
553        }
554        control.push(ogeom_math::Weighted::new(point, held.weight, tol).ok()?);
555    }
556    if !changed {
557        return Some(image.clone());
558    }
559    if image.is_rational() {
560        BSpline2d::rational(image.knots().clone(), control).ok()
561    } else {
562        let points = control
563            .into_iter()
564            .map(ogeom_math::Weighted::point)
565            .collect();
566        BSpline2d::new(image.knots().clone(), points, tol).ok()
567    }
568}
569
570/// How far a joint fit stands from the section it follows, in
571/// millimetres: what [`IntersectionCurve::fit_error`] states.
572///
573/// The joint fit's own residual mixes space and chart coordinates and
574/// says little about either alone, so it serves only as a bound on what
575/// is measured here.
576fn measured_error(
577    a: &SurfaceGeometry,
578    b: &SurfaceGeometry,
579    (space, on_a, on_b): (
580        &ogeom_geom::fit::Fitted<BSplineCurve>,
581        &BSpline2d,
582        &BSpline2d,
583    ),
584    points: &[ogeom_math::Point],
585    tol: Tolerances,
586) -> f64 {
587    let lifted = lift_error(a, b, on_a, on_b, &space.curve, tol);
588    // Where the surfaces meet at a shallow angle, a curve microns off both
589    // can still sit far along them from where they cross. That distance is
590    // the surfaces' gap over the sine of their angle, and it is no more than
591    // how far the lifted pcurves stand from the crossing itself, found at
592    // the same stations. Where a station's crossing could not be found, the
593    // fit's chart residual carried through each surface's stretch bounds it
594    // at the samples. The smaller of the two stands.
595    let crossing = if lifted.unsettled {
596        let charted =
597            space_error(a, on_a, space.error, tol).max(space_error(b, on_b, space.error, tol));
598        lifted.lifted_off.max(charted.max(space.error))
599    } else {
600        lifted.lifted_off
601    };
602    let lifted = lifted
603        .along
604        .max(lifted.curve_off)
605        .max(lifted.across.min(crossing));
606    // Each sample's distance from the curve, which the fit's residual
607    // already bounds: read only where that bound would decide the error.
608    if space.error <= lifted {
609        lifted
610    } else {
611        lifted.max(trace_error(&space.curve, points, space.error, tol))
612    }
613}
614
615/// A joint fit: the curve, and its images on the two surfaces.
616type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
617
618/// The samples a cubic fit's knots start at, by index, the first and last
619/// among them: each stretch between two holds a cubic's estimated miss,
620/// `L Θ³ / 384` for a stretch `L` long turning through `Θ`, to an eighth
621/// of `tolerance`, in space and in either chart, the turn read off the
622/// trace's own polylines.
623fn cubic_stations(
624    points: &[ogeom_math::Point],
625    image_a: &[Point2],
626    image_b: &[Point2],
627    tolerance: f64,
628) -> Vec<usize> {
629    let n = points.len();
630    let target = tolerance / 8.0;
631    let traces: [Vec<[f64; 3]>; 3] = [
632        points.iter().map(|p| [p.x, p.y, p.z]).collect(),
633        image_a.iter().map(|p| [p.x, p.y, 0.0]).collect(),
634        image_b.iter().map(|p| [p.x, p.y, 0.0]).collect(),
635    ];
636    // Per trace: each segment's length, and the turn at each sample
637    // between two segments.
638    let shape: Vec<(Vec<f64>, Vec<f64>)> = traces
639        .iter()
640        .map(|trace| {
641            let segment =
642                |k: usize| -> [f64; 3] { core::array::from_fn(|d| trace[k + 1][d] - trace[k][d]) };
643            let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
644            let lengths: Vec<f64> = (0..n - 1)
645                .map(|k| dot(segment(k), segment(k)).sqrt())
646                .collect();
647            let mut turns = vec![0.0; n];
648            for k in 1..n - 1 {
649                let scale = lengths[k - 1] * lengths[k];
650                if scale > 0.0 {
651                    turns[k] = (dot(segment(k - 1), segment(k)) / scale)
652                        .clamp(-1.0, 1.0)
653                        .acos();
654                }
655            }
656            (lengths, turns)
657        })
658        .collect();
659    let mut out = vec![0];
660    let mut from = 0;
661    while from + 1 < n {
662        let mut sums: Vec<(f64, f64)> = shape
663            .iter()
664            .map(|(lengths, _)| (lengths[from], 0.0))
665            .collect();
666        let mut to = from + 1;
667        while to + 1 < n {
668            let grown: Vec<(f64, f64)> = sums
669                .iter()
670                .zip(&shape)
671                .map(|(&(length, turn), (lengths, turns))| (length + lengths[to], turn + turns[to]))
672                .collect();
673            if grown
674                .iter()
675                .any(|&(length, turn)| length * turn.powi(3) / 384.0 > target)
676            {
677                break;
678            }
679            sums = grown;
680            to += 1;
681        }
682        out.push(to);
683        from = to;
684    }
685    out
686}
687
688/// What lifting the pcurves onto their surfaces shows.
689struct Lifted {
690    /// How far either pcurve, lifted, stands from the curve at the same
691    /// parameter.
692    along: f64,
693    /// That gap over the sine of the surfaces' angle there: how far the
694    /// surfaces' true crossing may sit from the curve.
695    across: f64,
696    /// How far the curve stands from the surfaces' crossing in its normal
697    /// plane.
698    curve_off: f64,
699    /// How far either pcurve, lifted, stands from that crossing.
700    lifted_off: f64,
701    /// Whether the crossing went unfound at some station.
702    unsettled: bool,
703}
704
705/// Each pcurve lifted through its surface against the curve, at four
706/// stations in every span of the curve's knots and at least two hundred
707/// along it, between the trace's samples as well as at them. Near a cone's
708/// apex or a sphere's pole the chart turns fast, and a fit that holds at
709/// every sample can wander between them.
710///
711/// At each station the surfaces' crossing in the curve's normal plane is
712/// found by Newton's method from the pcurves' points, and the curve and
713/// both lifted pcurves are measured against it: what the fit misses
714/// between the samples of the trace, read on the geometry the trace
715/// follows. That miss runs smoothly along each span of the fit, so the
716/// stations near its widest are climbed to the top of the peak beside
717/// them.
718fn lift_error(
719    a: &SurfaceGeometry,
720    b: &SurfaceGeometry,
721    on_a: &BSpline2d,
722    on_b: &BSpline2d,
723    curve: &BSplineCurve,
724    tol: Tolerances,
725) -> Lifted {
726    // A station stands up to a fifth below the peak beside it where the
727    // miss swells between two of them; each such peak, widest first, is
728    // climbed by golden sections between its neighbours.
729    const NEAR_PEAK: f64 = 0.8;
730    const PEAKS: usize = 16;
731    const CLIMB: usize = 16;
732    let (lo, hi) = curve.knots().domain();
733    let spans = [curve.knots(), on_a.knots(), on_b.knots()]
734        .into_iter()
735        .map(|knots| knots.distinct().len().saturating_sub(1))
736        .max()
737        .unwrap_or(0)
738        .max(1);
739    let stations = (4 * spans).max(200);
740    #[allow(clippy::cast_precision_loss)]
741    let step = (hi - lo) / stations as f64;
742    let mut out = Lifted {
743        along: 0.0,
744        across: 0.0,
745        curve_off: 0.0,
746        lifted_off: 0.0,
747        unsettled: false,
748    };
749    #[allow(clippy::cast_precision_loss)]
750    let at = |k: usize| lo + (hi - lo) * k as f64 / stations as f64;
751    let read = |t: f64| station(a, b, on_a, on_b, curve, t, step, tol);
752    // Each station's miss against the crossing, zero where none was read.
753    let mut misses = Vec::with_capacity(stations + 1);
754    for k in 0..=stations {
755        let Some(here) = read(at(k)) else {
756            misses.push(0.0);
757            continue;
758        };
759        out.along = out.along.max(here.gap);
760        out.across = out.across.max(here.across);
761        if let Some((curve_off, lifted_off)) = here.off {
762            out.curve_off = out.curve_off.max(curve_off);
763            out.lifted_off = out.lifted_off.max(lifted_off);
764            misses.push(curve_off.max(lifted_off));
765        } else {
766            out.unsettled = true;
767            misses.push(0.0);
768        }
769    }
770    let widest = misses.iter().fold(0.0_f64, |m, &x| m.max(x));
771    let mut peaks: Vec<usize> = (0..=stations)
772        .filter(|&k| {
773            misses[k] > 0.0
774                && misses[k] >= widest * NEAR_PEAK
775                && (k == 0 || misses[k] >= misses[k - 1])
776                && (k == stations || misses[k] >= misses[k + 1])
777        })
778        .collect();
779    peaks.sort_by(|&i, &j| misses[j].total_cmp(&misses[i]));
780    let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
781    let mut climb = |t: f64| {
782        read(t)
783            .and_then(|s| s.off)
784            .map_or(0.0, |(curve_off, lifted_off)| {
785                out.curve_off = out.curve_off.max(curve_off);
786                out.lifted_off = out.lifted_off.max(lifted_off);
787                curve_off.max(lifted_off)
788            })
789    };
790    for k in peaks.into_iter().take(PEAKS) {
791        let (mut left, mut right) = (at(k.saturating_sub(1)), at((k + 1).min(stations)));
792        let mut c = right - (right - left) * ratio;
793        let mut d = left + (right - left) * ratio;
794        let (mut rc, mut rd) = (climb(c), climb(d));
795        for _ in 0..CLIMB {
796            if rc > rd {
797                right = d;
798                (d, rd) = (c, rc);
799                c = right - (right - left) * ratio;
800                rc = climb(c);
801            } else {
802                left = c;
803                (c, rc) = (d, rd);
804                d = left + (right - left) * ratio;
805                rd = climb(d);
806            }
807        }
808    }
809    out
810}
811
812/// What the curve reads at one parameter.
813struct Station {
814    /// How far either pcurve, lifted, stands from the curve.
815    gap: f64,
816    /// That gap over the sine of the surfaces' angle, zero where either
817    /// normal is not determined.
818    across: f64,
819    /// How far the curve and either lifted pcurve stand from the surfaces'
820    /// crossing in the curve's normal plane, `None` where it is not found.
821    off: Option<(f64, f64)>,
822}
823
824/// A surface's chart point, its point there and first derivatives.
825type Jet = (
826    Point2,
827    ogeom_math::Point,
828    ogeom_math::Vector,
829    ogeom_math::Vector,
830);
831
832/// The curve and both pcurves at `t`, measured against each other and
833/// against the surfaces' crossing in the curve's normal plane there.
834/// `None` where the curve cannot be evaluated. A crossing farther off
835/// than `step` along the curve is another stretch of the crossing met in
836/// this normal plane, not the one the curve follows, and is not read.
837#[allow(clippy::too_many_arguments)]
838fn station(
839    a: &SurfaceGeometry,
840    b: &SurfaceGeometry,
841    on_a: &BSpline2d,
842    on_b: &BSpline2d,
843    curve: &BSplineCurve,
844    t: f64,
845    step: f64,
846    tol: Tolerances,
847) -> Option<Station> {
848    use ogeom_geom::{Curve2d as _, Curve3d as _};
849    let on = curve.point_at(t, tol).ok()?;
850    let mut gap = 0.0_f64;
851    let mut normals = [None; 2];
852    let mut jets: [Option<Jet>; 2] = [None; 2];
853    for (k, (surface, pcurve)) in [(a, on_a), (b, on_b)].into_iter().enumerate() {
854        let Ok(chart) = pcurve.point_at(t, tol) else {
855            continue;
856        };
857        let Ok((lifted, du, dv)) = surface.point_d1_at(chart.x, chart.y, tol) else {
858            continue;
859        };
860        gap = gap.max(lifted.distance(on));
861        jets[k] = Some((chart, lifted, du, dv));
862        // The normal as the surface states it: none where the tangents
863        // collapse against their own length.
864        let scale = du.magnitude().max(dv.magnitude());
865        let cross = du.cross(dv);
866        let length = cross.magnitude();
867        if length > tol.angular() * scale * scale {
868            normals[k] = Some(cross * (1.0 / length));
869        }
870    }
871    let across = match normals {
872        [Some(na), Some(nb)] => gap / na.cross(nb).magnitude().max(tol.angular()),
873        _ => 0.0,
874    };
875    let off = match (jets, curve.d1_at(t, tol)) {
876        ([Some(ja), Some(jb)], Ok(tangent)) => {
877            let reach = tangent.magnitude() * step;
878            crossing(a, b, ja, jb, on, tangent, reach, tol)
879                .filter(|found| found.distance(on) <= reach)
880                .map(|found| {
881                    (
882                        on.distance(found),
883                        ja.1.distance(found).max(jb.1.distance(found)),
884                    )
885                })
886        }
887        _ => None,
888    };
889    Some(Station { gap, across, off })
890}
891
892/// Where the surfaces cross in the plane through `anchor` normal to
893/// `along`, by damped Newton steps from the two jets given, each step
894/// first cut to carry neither surface's point past `reach` and then
895/// halved until the surfaces draw closer. From the curve's own station
896/// the crossing is a few steps off where there is one; where a step finds
897/// no closer point within `HALVINGS`, or the steps do not settle within
898/// `MOST`, no crossing is near and the answer is `None`.
899#[allow(clippy::too_many_arguments)]
900fn crossing(
901    a: &SurfaceGeometry,
902    b: &SurfaceGeometry,
903    on_a: Jet,
904    on_b: Jet,
905    anchor: ogeom_math::Point,
906    along: ogeom_math::Vector,
907    reach: f64,
908    tol: Tolerances,
909) -> Option<ogeom_math::Point> {
910    const MOST: usize = 8;
911    const HALVINGS: usize = 8;
912    let settled = tol.confusion() * 0.01;
913    let residual_of = |pa: ogeom_math::Point, pb: ogeom_math::Point| {
914        let gap = pa - pb;
915        [gap.x, gap.y, gap.z, (pa - anchor).dot(along)]
916    };
917    let jacobian_of = |(au, av): (ogeom_math::Vector, ogeom_math::Vector),
918                       (bu, bv): (ogeom_math::Vector, ogeom_math::Vector)| {
919        [
920            [au.x, av.x, -bu.x, -bv.x],
921            [au.y, av.y, -bu.y, -bv.y],
922            [au.z, av.z, -bu.z, -bv.z],
923            [au.dot(along), av.dot(along), 0.0, 0.0],
924        ]
925    };
926    let norm = |r: &[f64; 4]| r.iter().map(|v| v * v).sum::<f64>().sqrt();
927    let mut x = [on_a.0.x, on_a.0.y, on_b.0.x, on_b.0.y];
928    let mut point = on_a.1;
929    let mut residual = residual_of(on_a.1, on_b.1);
930    let mut jacobian = jacobian_of((on_a.2, on_a.3), (on_b.2, on_b.3));
931    let mut size = norm(&residual);
932    for _ in 0..MOST {
933        if size <= settled {
934            break;
935        }
936        let delta = solve4(jacobian, residual)?;
937        // How far the step would carry each surface's point, to first
938        // order. A crossing past `reach` is not the one sought, so the
939        // halving starts at the first scale that stays within it.
940        let carried = (0..3)
941            .map(|row| {
942                let on_a = jacobian[row][0] * delta[0] + jacobian[row][1] * delta[1];
943                let on_b = jacobian[row][2] * delta[2] + jacobian[row][3] * delta[3];
944                (on_a * on_a, on_b * on_b)
945            })
946            .fold((0.0, 0.0), |(sa, sb), (a, b)| (sa + a, sb + b));
947        let carried = carried.0.max(carried.1).sqrt();
948        let mut scale = 1.0;
949        while scale * carried > reach {
950            scale *= 0.5;
951        }
952        let mut accepted = None;
953        for _ in 0..HALVINGS {
954            let trial: [f64; 4] = core::array::from_fn(|i| x[i] - delta[i] * scale);
955            let (ua, va) = crate::march::clamp(a, trial[0], trial[1]);
956            let (ub, vb) = crate::march::clamp(b, trial[2], trial[3]);
957            if let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) {
958                let r = residual_of(pa, pb);
959                let trial_size = norm(&r);
960                if (trial_size < size || trial_size <= settled)
961                    && let (Ok(da), Ok(db)) = (a.d1_at(ua, va, tol), b.d1_at(ub, vb, tol))
962                {
963                    accepted = Some((trial, pa, r, jacobian_of(da, db), trial_size));
964                    break;
965                }
966            }
967            scale *= 0.5;
968        }
969        let Some((next, at, r, j, next_size)) = accepted else {
970            break;
971        };
972        let step = norm(&core::array::from_fn(|i| next[i] - x[i]));
973        (x, point, residual, jacobian, size) = (next, at, r, j, next_size);
974        if step <= tol.parametric() {
975            break;
976        }
977    }
978    (size <= tol.confusion()).then_some(point)
979}
980
981/// `m x = r` for four unknowns, by elimination with partial pivoting;
982/// `None` at a zero pivot.
983fn solve4(mut m: [[f64; 4]; 4], mut r: [f64; 4]) -> Option<[f64; 4]> {
984    for col in 0..4 {
985        let pivot = (col..4).max_by(|&i, &j| m[i][col].abs().total_cmp(&m[j][col].abs()))?;
986        if m[pivot][col] == 0.0 {
987            return None;
988        }
989        m.swap(col, pivot);
990        r.swap(col, pivot);
991        let head = m[col];
992        for row in col + 1..4 {
993            let factor = m[row][col] / head[col];
994            for (entry, above) in m[row].iter_mut().zip(&head).skip(col) {
995                *entry -= factor * above;
996            }
997            r[row] -= factor * r[col];
998        }
999    }
1000    for col in (0..4).rev() {
1001        r[col] /= m[col][col];
1002        for row in 0..col {
1003            r[row] -= m[row][col] * r[col];
1004        }
1005    }
1006    Some(r)
1007}
1008
1009/// How far the curve stands from the trace it was fitted to: each sample's
1010/// distance to its nearest point on the curve.
1011///
1012/// The samples run in order along the curve, so each one's foot is found by
1013/// Newton steps from the last one's. Where that does not settle within
1014/// `bound`, the fit's own residual, which already bounds each sample's
1015/// distance from the curve at the fit's parameter, the nearest of the
1016/// curve's stations seeds the search instead, and the result never exceeds
1017/// `bound`.
1018fn trace_error(
1019    curve: &BSplineCurve,
1020    samples: &[ogeom_math::Point],
1021    bound: f64,
1022    tol: Tolerances,
1023) -> f64 {
1024    use ogeom_geom::Curve3d as _;
1025    let (lo, hi) = curve.knots().domain();
1026    let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
1027    let count = (4 * spans).max(2 * samples.len()).max(200);
1028    #[allow(clippy::cast_precision_loss)]
1029    let at = |k: usize| lo + (hi - lo) * k as f64 / count as f64;
1030    let mut stations: Option<Vec<(f64, ogeom_math::Point)>> = None;
1031    let foot = |p: ogeom_math::Point, mut t: f64| -> (f64, f64) {
1032        let mut best = (t, f64::INFINITY);
1033        for _ in 0..8 {
1034            let (Ok(q), Ok(d)) = (curve.point_at(t, tol), curve.d1_at(t, tol)) else {
1035                break;
1036            };
1037            let gap = q.distance(p);
1038            if gap < best.1 {
1039                best = (t, gap);
1040            }
1041            let speed = d.dot(d);
1042            if speed <= f64::MIN_POSITIVE {
1043                break;
1044            }
1045            let next = (t + (p - q).dot(d) / speed).clamp(lo, hi);
1046            if (next - t).abs() <= (hi - lo) * 1e-12 {
1047                break;
1048            }
1049            t = next;
1050        }
1051        if let Ok(q) = curve.point_at(t, tol)
1052            && q.distance(p) < best.1
1053        {
1054            best = (t, q.distance(p));
1055        }
1056        best
1057    };
1058    let mut t = lo;
1059    let mut worst = 0.0_f64;
1060    for p in samples {
1061        let mut found = foot(*p, t);
1062        if found.1 > bound {
1063            let stations = stations.get_or_insert_with(|| {
1064                (0..=count)
1065                    .filter_map(|k| curve.point_at(at(k), tol).ok().map(|q| (at(k), q)))
1066                    .collect()
1067            });
1068            if let Some(k) = (0..stations.len()).min_by(|&x, &y| {
1069                stations[x]
1070                    .1
1071                    .distance(*p)
1072                    .total_cmp(&stations[y].1.distance(*p))
1073            }) {
1074                // Scanned finely over the stations either side, then
1075                // narrowed by golden section: where the curve all but stops
1076                // in space (its chart image swinging round a pole) or kinks
1077                // between two samples, Newton's steps are blind and the
1078                // distance has more than one dip between stations.
1079                let gap = |u: f64| {
1080                    curve
1081                        .point_at(u, tol)
1082                        .map_or(f64::INFINITY, |q| q.distance(*p))
1083                };
1084                let (from, to) = (
1085                    stations[k.saturating_sub(2)].0,
1086                    stations[(k + 2).min(stations.len() - 1)].0,
1087                );
1088                const FINE: u32 = 256;
1089                let h = (to - from) / f64::from(FINE);
1090                let start = (0..=FINE)
1091                    .map(|j| from + h * f64::from(j))
1092                    .min_by(|&x, &y| gap(x).total_cmp(&gap(y)))
1093                    .unwrap_or(from);
1094                let (mut a, mut b) = ((start - h).max(lo), (start + h).min(hi));
1095                let ratio = 0.5 * (5.0_f64.sqrt() - 1.0);
1096                for _ in 0..60 {
1097                    let (x, y) = (b - ratio * (b - a), a + ratio * (b - a));
1098                    if gap(x) <= gap(y) {
1099                        b = y;
1100                    } else {
1101                        a = x;
1102                    }
1103                }
1104                let again = foot(*p, 0.5 * (a + b));
1105                if again.1 < found.1 {
1106                    found = again;
1107                }
1108            }
1109        }
1110        t = found.0;
1111        worst = worst.max(found.1.min(bound));
1112    }
1113    worst
1114}
1115
1116/// The fit's chart residual carried into space through the surface's
1117/// stretch along the pcurve: a bound on how far the lifted pcurve stands
1118/// from the trace, loose by the stretch where the residual is mostly in the
1119/// other coordinates, and so used only to cap an estimate, never stated.
1120fn space_error(
1121    surface: &SurfaceGeometry,
1122    pcurve: &BSpline2d,
1123    parameter_error: f64,
1124    tol: Tolerances,
1125) -> f64 {
1126    use ogeom_geom::Curve2d;
1127    // Convert the parameter-space error back through the surface's local
1128    // stretch at a few places; take the worst.
1129    let (lo, hi) = pcurve.domain();
1130    let mut worst = 0.0_f64;
1131    for i in 0..=16 {
1132        #[allow(clippy::cast_precision_loss)]
1133        let u = lo + (hi - lo) * f64::from(i) / 16.0;
1134        let Ok(at) = pcurve.point_at(u, tol) else {
1135            continue;
1136        };
1137        let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
1138            continue;
1139        };
1140        let stretch = du.magnitude().max(dv.magnitude());
1141        worst = worst.max(parameter_error * stretch);
1142    }
1143    worst
1144}
1145
1146/// Unfold parameter samples across a periodic surface's seam.
1147///
1148/// Each step is folded to the nearest image of the next sample, so a branch
1149/// crossing `u = 0` continues to `-0.1` rather than tearing to `2π - 0.1`. The
1150/// result may leave the surface's stated domain, which is what a pcurve
1151/// crossing a seam *is*.
1152fn unwrap_periodic(
1153    surface: &SurfaceGeometry,
1154    samples: &[(f64, f64)],
1155    tol: Tolerances,
1156) -> Vec<Point2> {
1157    let ((ua, ub), (va, vb)) = surface.domain();
1158    // Closure as well as periodicity: a converted drum is a clamped patch
1159    // that meets itself at its seam, and a loop walked round it lands on
1160    // either side of that seam by the walk's own rounding. Folded by the
1161    // chart's span like a period, the trace is the continuous curve it is.
1162    // Left as sampled, it jumps a whole span at the seam and the closed
1163    // fit chases the jump far from the trace.
1164    let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
1165        Some(ub - ua)
1166    } else {
1167        None
1168    };
1169    let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
1170        Some(vb - va)
1171    } else {
1172        None
1173    };
1174    let fold = |previous: f64, next: f64, period: Option<f64>| match period {
1175        None => next,
1176        Some(period) => {
1177            let mut candidate = next;
1178            while candidate - previous > period * 0.5 {
1179                candidate -= period;
1180            }
1181            while previous - candidate > period * 0.5 {
1182                candidate += period;
1183            }
1184            candidate
1185        }
1186    };
1187
1188    let mut out = Vec::with_capacity(samples.len());
1189    let mut at = Point2::new(samples[0].0, samples[0].1);
1190    out.push(at);
1191    for sample in &samples[1..] {
1192        at = Point2::new(
1193            fold(at.x, sample.0, u_period),
1194            fold(at.y, sample.1, v_period),
1195        );
1196        out.push(at);
1197    }
1198    out
1199}
1200
1201#[cfg(test)]
1202#[allow(clippy::unwrap_used)]
1203mod tests {
1204    use super::*;
1205    use crate::march::{Marching, branches};
1206    use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
1207    use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
1208
1209    const T: Tolerances = Tolerances::millimetres();
1210
1211    fn sphere(radius: f64) -> SurfaceGeometry {
1212        SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
1213    }
1214
1215    fn cylinder(radius: f64) -> SurfaceGeometry {
1216        CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
1217            .unwrap()
1218            .into()
1219    }
1220
1221    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1222        PlaneSurface::over(
1223            Plane::through(origin, Direction::new(normal, T).unwrap()),
1224            (-6.0, 6.0),
1225            (-6.0, 6.0),
1226        )
1227        .unwrap()
1228        .into()
1229    }
1230
1231    fn options() -> Marching {
1232        Marching {
1233            chord: 1e-5,
1234            ..Marching::default()
1235        }
1236    }
1237
1238    /// The distance of a fitted curve from both surfaces, sampled densely.
1239    ///
1240    /// This is the measure the whole stage exists for: the *fit* (not the
1241    /// polyline it came from) is what downstream code holds, so the fit is
1242    /// what must lie on both surfaces.
1243    fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
1244        let off = |surface: &SurfaceGeometry, p: Point| match surface {
1245            SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
1246            SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
1247            SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
1248            _ => 0.0,
1249        };
1250        let (lo, hi) = curve.knots().domain();
1251        let mut worst = 0.0_f64;
1252        for i in 0..=800 {
1253            #[allow(clippy::cast_precision_loss)]
1254            let u = lo + (hi - lo) * f64::from(i) / 800.0;
1255            if let Ok(p) = curve.point_at(u, T) {
1256                worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
1257            }
1258        }
1259        worst
1260    }
1261
1262    /// The error stated covers the fit between the trace's samples where
1263    /// the surfaces cross at a shallow angle. A bowl a hundred millimetres
1264    /// round at its foot dips two hundredths below a plane, both charts
1265    /// running evenly with `x` and `y`, and nine samples of half the circle
1266    /// they cross in are fitted through every one. Between the samples the
1267    /// fit and both pcurves leave the circle alike, within the plane, where
1268    /// the bowl stands only a fiftieth of that above it; the miss is read
1269    /// against the crossing itself.
1270    #[test]
1271    fn a_shallow_crossing_states_the_fits_miss_between_its_samples() {
1272        use crate::march::Stopped;
1273        use ogeom_geom::BSplineSurface;
1274        use ogeom_math::{ControlGrid, KnotVector};
1275        let (radius, dip, half) = (100.0, 0.02, 3.0);
1276        let a = plane(Point::ORIGIN, Vector::Z);
1277        // z = (x^2 + y^2) / (2 radius) - dip, exactly, as a quadratic patch
1278        // over [-half, half] in both.
1279        let square = [half * half, -half * half, half * half];
1280        let side = [-half, 0.0, half];
1281        let mut control = Vec::new();
1282        for i in 0..3 {
1283            for j in 0..3 {
1284                let z = (square[i] + square[j]) / (2.0 * radius) - dip;
1285                control.push(Point::new(side[i], side[j], z));
1286            }
1287        }
1288        let knots = || KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
1289        let b: SurfaceGeometry = BSplineSurface::new(
1290            knots(),
1291            knots(),
1292            &ControlGrid::new(control, 3, 3).unwrap(),
1293            T,
1294        )
1295        .unwrap()
1296        .into();
1297        let circle = (2.0 * radius * dip).sqrt();
1298        let SurfaceGeometry::Plane(flat) = &a else {
1299            unreachable!()
1300        };
1301        let mut branch = Traced {
1302            points: Vec::new(),
1303            on_a: Vec::new(),
1304            on_b: Vec::new(),
1305            stopped: Stopped::LeftTheDomain,
1306        };
1307        for k in 0..=8 {
1308            let angle = core::f64::consts::PI * f64::from(k) / 8.0;
1309            let p = Point::new(circle * angle.cos(), circle * angle.sin(), 0.0);
1310            let local = flat.plane().frame().to_local(p);
1311            let on_b = ((p.x + half) / (2.0 * half), (p.y + half) / (2.0 * half));
1312            assert!(b.point_at(on_b.0, on_b.1, T).unwrap().distance(p) < 1e-9);
1313            branch.points.push(p);
1314            branch.on_a.push((local.x, local.y));
1315            branch.on_b.push(on_b);
1316        }
1317        let fitted = approximate_branch(&a, &b, &branch, 1e-9, T).unwrap();
1318        let (lo, hi) = fitted.curve.knots().domain();
1319        let mut worst = 0.0_f64;
1320        for i in 0..=4000 {
1321            let p = fitted
1322                .curve
1323                .point_at(lo + (hi - lo) * f64::from(i) / 4000.0, T)
1324                .unwrap();
1325            let flat = Point::new(p.x, p.y, 0.0).to_vector().magnitude();
1326            worst = worst.max((flat - circle).abs().max(p.z.abs()));
1327        }
1328        assert!(
1329            worst > 1e-6,
1330            "the fit misses the circle between samples: {worst:.3e}"
1331        );
1332        assert!(
1333            fitted.fit_error >= 0.9 * worst,
1334            "the miss is stated: {:.3e} for {worst:.3e}",
1335            fitted.fit_error
1336        );
1337    }
1338
1339    #[test]
1340    fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
1341        // The tolerance story end to end: trace within 1e-5, fit within 1e-4,
1342        // so the fitted curve is within the sum of the two of the true
1343        // intersection, measured against the surfaces, not the polyline.
1344        let a = sphere(3.0);
1345        let b = cylinder(1.5);
1346        let found = branches(&a, &b, options(), T).unwrap();
1347        assert_eq!(found.len(), 2);
1348
1349        for branch in &found {
1350            let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1351            assert!(fitted.met, "fit error {:e}", fitted.fit_error);
1352            assert!(fitted.closed);
1353            let off = fitted_deviation(&a, &b, &fitted.curve);
1354            assert!(
1355                off <= 1e-4 + 1e-5,
1356                "the fitted curve is {off:e} off the surfaces"
1357            );
1358            // And it is compact: a curve, not a decorated polyline.
1359            assert!(
1360                fitted.curve.control_points().len() * 4 < branch.points.len(),
1361                "{} control points for {} samples",
1362                fitted.curve.control_points().len(),
1363                branch.points.len()
1364            );
1365        }
1366    }
1367
1368    #[test]
1369    fn the_pcurves_lift_back_onto_the_curve() {
1370        // A pcurve is only worth having if evaluating it and lifting through
1371        // its surface lands on the intersection. Checked through both
1372        // surfaces at matched ends and sampled interiors.
1373        let a = sphere(3.0);
1374        let b = cylinder(1.5);
1375        let found = branches(&a, &b, options(), T).unwrap();
1376        let branch = &found[0];
1377        let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1378
1379        for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
1380            let (lo, hi) = pcurve.domain();
1381            for i in 0..=200 {
1382                #[allow(clippy::cast_precision_loss)]
1383                let u = lo + (hi - lo) * f64::from(i) / 200.0;
1384                let at = pcurve.point_at(u, T).unwrap();
1385                let lifted = surface.point_at(at.x, at.y, T).unwrap();
1386                // The lifted point is on its own surface by construction; what
1387                // matters is that it is on the *other* one too, i.e. on the
1388                // intersection.
1389                let off = match (surface as &SurfaceGeometry, &a, &b) {
1390                    _ if core::ptr::eq(surface, &a) => match &b {
1391                        SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
1392                        _ => 0.0,
1393                    },
1394                    _ => match &a {
1395                        SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
1396                        _ => 0.0,
1397                    },
1398                };
1399                assert!(
1400                    off.abs() < 5e-4,
1401                    "a lifted pcurve point is {off:e} off the intersection"
1402                );
1403            }
1404        }
1405    }
1406
1407    #[test]
1408    fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
1409        // A plane through a cylinder's axis at an angle produces an ellipse
1410        // whose pcurve crosses the cylinder's u = 0 seam. Folded naively the
1411        // pcurve tears by 2π; unwrapped it runs smoothly and leaves the stated
1412        // domain, which is what crossing a seam means.
1413        let a = cylinder(2.0);
1414        let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
1415        let found = branches(&a, &b, options(), T).unwrap();
1416        assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
1417        let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
1418
1419        // Continuity: no two adjacent samples of the fitted pcurve jump by
1420        // anything near a period.
1421        let (lo, hi) = fitted.on_a.domain();
1422        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1423        for i in 1..=400 {
1424            #[allow(clippy::cast_precision_loss)]
1425            let u = lo + (hi - lo) * f64::from(i) / 400.0;
1426            let at = fitted.on_a.point_at(u, T).unwrap();
1427            assert!(
1428                (at.x - previous.x).abs() < 1.0,
1429                "the pcurve tears at the seam: {} to {}",
1430                previous.x,
1431                at.x
1432            );
1433            previous = at;
1434        }
1435    }
1436
1437    /// A loop walked round a converted drum is closed, seam or no seam.
1438    ///
1439    /// A cylinder converted to a patch is clamped, not periodic: it meets
1440    /// itself at its seam. A plane across it cuts a circle the walk reaches
1441    /// the seam on from both sides, each half stopping a fraction of a step
1442    /// short of it, and the joined branch has coincident ends. Left flagged
1443    /// as having left the domain, the arrangement downstream would hold a
1444    /// circle with two ends at one point. It is closed, and fitted as a loop
1445    /// whose chart image runs continuously across the seam.
1446    #[test]
1447    fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
1448        let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
1449        assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
1450        let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
1451        let found = branches(&drum, &cut, options(), T).unwrap();
1452        assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
1453        assert!(found[0].closed(), "the loop closes on the seam");
1454        let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
1455        assert!(fitted.closed);
1456        assert!(
1457            fitted.fit_error < 1e-3,
1458            "the loop fits as one: {}",
1459            fitted.fit_error
1460        );
1461        let (lo, hi) = fitted.on_a.domain();
1462        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1463        for i in 1..=400 {
1464            let u = lo + (hi - lo) * f64::from(i) / 400.0;
1465            let at = fitted.on_a.point_at(u, T).unwrap();
1466            assert!(
1467                (at.x - previous.x).abs() < 0.5,
1468                "the chart image tears at the seam: {} to {}",
1469                previous.x,
1470                at.x
1471            );
1472            previous = at;
1473        }
1474    }
1475
1476    /// A bore across a converted drum states the error its curves have.
1477    ///
1478    /// The joint fit's residual mixes millimetres with the drum chart's
1479    /// coordinates, and carried through the drum's stretch it reads hundreds
1480    /// of times larger than the distance the fitted curves stand from the
1481    /// two surfaces. The surfaces cross steeply here, so that distance is
1482    /// what is stated, within a small multiple.
1483    #[test]
1484    fn a_section_across_a_wide_drum_states_its_measured_error() {
1485        let radius = 23.6;
1486        let wall = CylinderSurface::new(
1487            Cylinder::new(Frame::WORLD, radius, T).unwrap(),
1488            (-30.0, 30.0),
1489        )
1490        .unwrap();
1491        let drum: SurfaceGeometry = SurfaceGeometry::from(wall).to_bspline(T).unwrap().into();
1492        let bore = Cylinder::new(
1493            Frame::new(
1494                Point::new(0.0, 0.0, 3.0),
1495                Direction::new(Vector::X, T).unwrap(),
1496                Direction::new(Vector::Y, T).unwrap(),
1497                T,
1498            )
1499            .unwrap(),
1500            9.0,
1501            T,
1502        )
1503        .unwrap();
1504        let drill: SurfaceGeometry = CylinderSurface::new(bore, (-40.0, 40.0)).unwrap().into();
1505        let marching = Marching {
1506            chord: 1e-4,
1507            ..Marching::default()
1508        };
1509        let found = branches(&drum, &drill, marching, T).unwrap();
1510        assert!(!found.is_empty());
1511        for branch in &found {
1512            let fitted = approximate_branch(&drum, &drill, branch, 1e-4, T).unwrap();
1513            let (lo, hi) = fitted.curve.knots().domain();
1514            let mut off = 0.0_f64;
1515            for i in 0..=2000 {
1516                let t = lo + (hi - lo) * f64::from(i) / 2000.0;
1517                let p = fitted.curve.point_at(t, T).unwrap();
1518                off = off.max(
1519                    wall.cylinder()
1520                        .distance_to(p)
1521                        .abs()
1522                        .max(bore.distance_to(p).abs()),
1523                );
1524            }
1525            // Honest both ways: no less than the curve stands off, and not
1526            // hundreds of times more.
1527            assert!(
1528                fitted.fit_error + marching.chord >= off,
1529                "states {:e} for a curve {off:e} off its surfaces",
1530                fitted.fit_error
1531            );
1532            assert!(
1533                fitted.fit_error <= 10.0 * off.max(marching.chord),
1534                "states {:e} for a curve {off:e} off its surfaces",
1535                fitted.fit_error
1536            );
1537        }
1538    }
1539
1540    #[test]
1541    fn what_cannot_be_fitted_is_refused() {
1542        let a = sphere(1.0);
1543        let b = plane(Point::ORIGIN, Vector::Z);
1544        let found = branches(&a, &b, options(), T).unwrap();
1545        assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
1546        assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
1547
1548        let empty = Traced {
1549            points: vec![],
1550            on_a: vec![],
1551            on_b: vec![],
1552            stopped: crate::march::Stopped::Stalled,
1553        };
1554        assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
1555    }
1556
1557    /// A drum whose wall holds a ball's axis meets the ball in two loops,
1558    /// each through a pole of its chart, where the chart's longitude turns
1559    /// half a turn and the curve does not. Each loop is cut at its pole and
1560    /// fitted open, pole to pole, its image on the ball ending on the pole
1561    /// row each side and inside the chart throughout; the curve and both
1562    /// images lifted lie on both surfaces to the stated error. Fitted
1563    /// whole, a loop closing on its pole joined the two ends half a turn
1564    /// apart, and its fit ran centimetres off both surfaces.
1565    #[test]
1566    fn loops_through_a_balls_poles_are_cut_there() {
1567        use core::f64::consts::FRAC_PI_2;
1568        let ball = sphere(10.0);
1569        for (cx, cy, r) in [(0.0, 1.0, 1.0), (0.2, 0.0, 0.2), (0.0, -0.3, 0.3)] {
1570            let frame =
1571                Frame::new(Point::new(cx, cy, -15.0), Direction::Z, Direction::Y, T).unwrap();
1572            let drum: SurfaceGeometry =
1573                CylinderSurface::new(Cylinder::new(frame, r, T).unwrap(), (0.0, 30.0))
1574                    .unwrap()
1575                    .into();
1576            let traced = branches(&ball, &drum, options(), T).unwrap();
1577            assert_eq!(traced.len(), 2, "drum {r} at ({cx}, {cy}): two loops");
1578            for branch in &traced {
1579                assert!(
1580                    !branch.closed(),
1581                    "drum {r} at ({cx}, {cy}): cut at its pole"
1582                );
1583                let fitted = approximate_branch(&ball, &drum, branch, 1e-6, T).unwrap();
1584                let (lo, hi) = fitted.curve.knots().domain();
1585                for t in [lo, hi] {
1586                    let p = fitted.curve.point_at(t, T).unwrap();
1587                    assert!(
1588                        (p.z.abs() - 10.0).abs() <= 1e-9,
1589                        "an end {p:?} off the pole"
1590                    );
1591                    let uv = fitted.on_a.point_at(t, T).unwrap();
1592                    assert!(
1593                        (uv.y.abs() - FRAC_PI_2).abs() <= 1e-12,
1594                        "an image end off the row"
1595                    );
1596                }
1597                let budget = options().chord + fitted.fit_error;
1598                assert!(budget < 1e-4, "drum {r} at ({cx}, {cy}): stated {budget}");
1599                for k in 0..=2000 {
1600                    let t = lo + (hi - lo) * f64::from(k) / 2000.0;
1601                    let p = fitted.curve.point_at(t, T).unwrap();
1602                    let on_ball = (p - Point::ORIGIN).magnitude() - 10.0;
1603                    let on_drum = (p.x - cx).hypot(p.y - cy) - r;
1604                    assert!(
1605                        on_ball.abs().max(on_drum.abs()) <= budget,
1606                        "drum {r} at ({cx}, {cy}): {p:?} off the surfaces"
1607                    );
1608                    let uv = fitted.on_a.point_at(t, T).unwrap();
1609                    assert!(uv.y.abs() <= FRAC_PI_2, "the image leaves the chart");
1610                    let lifted = ball.point_at(uv.x, uv.y, T).unwrap();
1611                    assert!(
1612                        lifted.distance(p) <= budget,
1613                        "the image stands off the curve"
1614                    );
1615                }
1616            }
1617        }
1618    }
1619
1620    /// A drum whose wall passes a hair beside a ball's poles, farther than
1621    /// the trace's chord, meets the ball in two loops whose images on the
1622    /// ball swing half a turn of longitude within a few times that
1623    /// distance. Each fits within its tolerance, and the curve and both
1624    /// images lifted lie on both surfaces within the stated error, read
1625    /// densely. Fitted jointly alone, the loops missed by up to 0.35.
1626    #[test]
1627    fn loops_beside_a_balls_poles_fit_within_their_tolerance() {
1628        let ball = sphere(10.0);
1629        for (r, off) in [(1.0, 1e-3), (1.0, 1e-4), (2.5, 1e-3), (4.0, 1e-2)] {
1630            let wall = r * (1.0 + off);
1631            let frame =
1632                Frame::new(Point::new(0.0, -wall, -15.0), Direction::Z, Direction::Y, T).unwrap();
1633            let drum: SurfaceGeometry =
1634                CylinderSurface::new(Cylinder::new(frame, r, T).unwrap(), (0.0, 30.0))
1635                    .unwrap()
1636                    .into();
1637            let marching = Marching::default();
1638            let traced = branches(&ball, &drum, marching, T).unwrap();
1639            assert_eq!(traced.len(), 2, "drum {r} {off} off: two loops");
1640            for branch in &traced {
1641                let fitted = approximate_branch(&ball, &drum, branch, 1e-6, T).unwrap();
1642                assert!(
1643                    fitted.fit_error <= 2e-5,
1644                    "drum {r} {off} off: fit misses by {}",
1645                    fitted.fit_error
1646                );
1647                let (lo, hi) = fitted.curve.knots().domain();
1648                for k in 0..=20_000 {
1649                    let t = lo + (hi - lo) * f64::from(k) / 20_000.0;
1650                    let p = fitted.curve.point_at(t, T).unwrap();
1651                    let on_ball = (p - Point::ORIGIN).magnitude() - 10.0;
1652                    let on_drum = p.x.hypot(p.y + wall) - r;
1653                    assert!(
1654                        on_ball.abs().max(on_drum.abs()) <= marching.chord + fitted.fit_error,
1655                        "drum {r} {off} off: {p:?} off the surfaces"
1656                    );
1657                    for (surface, image) in [(&ball, &fitted.on_a), (&drum, &fitted.on_b)] {
1658                        let uv = image.point_at(t, T).unwrap();
1659                        let lifted = surface.point_at(uv.x, uv.y, T).unwrap();
1660                        assert!(
1661                            lifted.distance(p) <= fitted.fit_error,
1662                            "drum {r} {off} off: an image {} off the curve, stating {}",
1663                            lifted.distance(p),
1664                            fitted.fit_error
1665                        );
1666                    }
1667                }
1668            }
1669        }
1670    }
1671}