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, and, where the surfaces meet at a shallow angle,
52    /// how far along them their crossing may sit from the curve. The
53    /// distance to the true intersection adds the trace's own chord
54    /// tolerance on top; both are stated so an edge built on this knows
55    /// what to carry.
56    pub fit_error: f64,
57    /// Whether every fit met the tolerance it was asked for.
58    pub met: bool,
59    /// Whether the branch is a closed loop.
60    pub closed: bool,
61}
62
63/// Fit one traced branch to curves, within `tolerance`.
64///
65/// # Errors
66///
67/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the branch has
68/// fewer than two points or the tolerance is not a positive distance.
69pub fn approximate_branch(
70    a: &SurfaceGeometry,
71    b: &SurfaceGeometry,
72    branch: &Traced,
73    tolerance: f64,
74    tol: Tolerances,
75) -> OgeomResult<IntersectionCurve> {
76    if branch.points.len() < 2 {
77        ogeom_bail!(
78            Construction,
79            "a branch of {} points is not a curve",
80            branch.points.len()
81        );
82    }
83
84    // Marching correction can leave consecutive samples closer than the
85    // rounding it converged within, and two samples at one chord-length
86    // parameter are a knot span with no data in it: the fitting system
87    // reports itself singular where the real defect is the duplicate. Thin
88    // them here, where the trace's own step says what "too close" means.
89    let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
90    let mut kept_a = Vec::with_capacity(branch.on_a.len());
91    let mut kept_b = Vec::with_capacity(branch.on_b.len());
92    // A sample is one point seen three ways, and where the three disagree
93    // it is not data: through a point where the surfaces touch, the tracer
94    // can report a step's position with its neighbour's parameters, and
95    // the joint fit, asked to pass through both descriptions at once,
96    // stalls a thousand times above its budget at that one sample.
97    let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
98        let limit = tolerance.max(tol.confusion());
99        let (ua, va) = branch.on_a[i];
100        let (ub, vb) = branch.on_b[i];
101        a.point_at(ua, va, tol)
102            .is_ok_and(|q| q.distance(*p) <= limit)
103            && b.point_at(ub, vb, tol)
104                .is_ok_and(|q| q.distance(*p) <= limit)
105    };
106    for (i, p) in branch.points.iter().enumerate() {
107        let end = i == 0 || i + 1 == branch.points.len();
108        if let Some(last) = points.last()
109            && last.distance(*p) <= tol.confusion() * 10.0
110            && i + 1 != branch.points.len()
111        {
112            continue;
113        }
114        if !end && !agrees(i, p) {
115            continue;
116        }
117        points.push(*p);
118        kept_a.push(branch.on_a[i]);
119        kept_b.push(branch.on_b[i]);
120    }
121    if points.len() < 2 {
122        ogeom_bail!(Construction, "a branch of coincident points is not a curve");
123    }
124
125    // One fit in seven dimensions: the curve and both parameter images
126    // together. Fitted separately, each fit's parameter correction drifts
127    // its parameterization independently and the three results silently stop
128    // being same-parameter: a pcurve claiming 1e-7 can evaluate millimetres
129    // from its own curve. Jointly, one
130    // parameterization and one knot vector serve all three, and the reported
131    // error bounds every coordinate.
132    let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
133    let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
134    // A closed branch takes the loop-smoothing fit: the join's tangents are
135    // constrained to agree in all seven coordinates, so the section curve and
136    // both pcurves cross their own seam without a crease. A loop winding
137    // once round a periodic surface ends its image there a period from where
138    // it began, and that fit, closing only where every image does, takes it
139    // as an open trace whose ends lie close together, which can stall far
140    // from the trace. Where it does, the loop is fitted closed in space with
141    // the images' join C1 across the seam, right where the chart runs at one
142    // speed across it, as a periodic surface's does (a patch that merely
143    // meets itself at its seam need not), and the closer of the two stands.
144    let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
145        let (first, last) = (image[0], image[image.len() - 1]);
146        ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
147            && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
148    };
149    let (space, on_a, on_b) = if branch.closed() {
150        let closed = ogeom_geom::fit::fit_points_joint_closed(
151            &points,
152            &unwrapped_a,
153            &unwrapped_b,
154            3,
155            tolerance,
156            tol,
157        )?;
158        if !closed.0.met
159            && winds_periodically(a, &unwrapped_a)
160            && winds_periodically(b, &unwrapped_b)
161        {
162            let winding = ogeom_geom::fit::fit_points_joint_winding(
163                &points,
164                &unwrapped_a,
165                &unwrapped_b,
166                3,
167                tolerance,
168                tol,
169            )?;
170            if winding.0.error < closed.0.error {
171                winding
172            } else {
173                closed
174            }
175        } else {
176            closed
177        }
178    } else {
179        ogeom_geom::fit::fit_points_joint(&points, &unwrapped_a, &unwrapped_b, 3, tolerance, tol)?
180    };
181
182    // Measured where it is promised, in millimetres. The joint fit's own
183    // residual mixes space and chart coordinates and says little about
184    // either alone, so it serves only as a bound on what is measured here.
185    let lifted = lift_error(a, b, &on_a, &on_b, &space.curve, tol);
186    let traced = trace_error(&space.curve, &points, space.error, tol);
187    // Where the surfaces meet at a shallow angle, a curve microns off both
188    // can still sit far along them from where they cross. That distance is
189    // the surfaces' gap over the sine of their angle, and it is no more than
190    // the fit's chart residual carried through each surface's stretch, which
191    // bounds how far the lifted pcurves stand from the trace whatever the
192    // angle. The smaller of the two stands.
193    let charted =
194        space_error(a, &on_a, space.error, tol).max(space_error(b, &on_b, space.error, tol));
195    let fit_error = traced
196        .max(lifted.along)
197        .max(lifted.across.min(charted.max(space.error)));
198    Ok(IntersectionCurve {
199        fit_error,
200        met: space.met,
201        curve: space.curve,
202        on_a,
203        on_b,
204        closed: branch.closed(),
205    })
206}
207
208/// What lifting the pcurves onto their surfaces shows.
209struct Lifted {
210    /// How far either pcurve, lifted, stands from the curve at the same
211    /// parameter.
212    along: f64,
213    /// That gap over the sine of the surfaces' angle there: how far the
214    /// surfaces' true crossing may sit from the curve.
215    across: f64,
216}
217
218/// Each pcurve lifted through its surface against the curve, at four
219/// stations in every span of the curve's knots and at least two hundred
220/// along it, between the trace's samples as well as at them. Near a cone's
221/// apex or a sphere's pole the chart turns fast, and a fit that holds at
222/// every sample can wander between them.
223fn lift_error(
224    a: &SurfaceGeometry,
225    b: &SurfaceGeometry,
226    on_a: &BSpline2d,
227    on_b: &BSpline2d,
228    curve: &BSplineCurve,
229    tol: Tolerances,
230) -> Lifted {
231    use ogeom_geom::{Curve2d as _, Curve3d as _};
232    let (lo, hi) = curve.knots().domain();
233    let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
234    let stations = (4 * spans).max(200);
235    let mut out = Lifted {
236        along: 0.0,
237        across: 0.0,
238    };
239    for k in 0..=stations {
240        #[allow(clippy::cast_precision_loss)]
241        let t = lo + (hi - lo) * k as f64 / stations as f64;
242        let Ok(on) = curve.point_at(t, tol) else {
243            continue;
244        };
245        let mut gap = 0.0_f64;
246        let mut normals = Vec::with_capacity(2);
247        for (surface, pcurve) in [(a, on_a), (b, on_b)] {
248            let Ok(at) = pcurve.point_at(t, tol) else {
249                continue;
250            };
251            let Ok(lifted) = surface.point_at(at.x, at.y, tol) else {
252                continue;
253            };
254            gap = gap.max(lifted.distance(on));
255            if let Ok(normal) = surface.normal_at(at.x, at.y, tol) {
256                normals.push(normal.vector());
257            }
258        }
259        out.along = out.along.max(gap);
260        if let [na, nb] = normals[..] {
261            let sine = na.cross(nb).magnitude().max(tol.angular());
262            out.across = out.across.max(gap / sine);
263        }
264    }
265    out
266}
267
268/// How far the curve stands from the trace it was fitted to: each sample's
269/// distance to its nearest point on the curve.
270///
271/// The samples run in order along the curve, so each one's foot is found by
272/// Newton steps from the last one's. Where that does not settle within
273/// `bound`, the fit's own residual, which already bounds each sample's
274/// distance from the curve at the fit's parameter, the nearest of the
275/// curve's stations seeds the search instead, and the result never exceeds
276/// `bound`.
277fn trace_error(
278    curve: &BSplineCurve,
279    samples: &[ogeom_math::Point],
280    bound: f64,
281    tol: Tolerances,
282) -> f64 {
283    use ogeom_geom::Curve3d as _;
284    let (lo, hi) = curve.knots().domain();
285    let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
286    let count = (4 * spans).max(2 * samples.len()).max(200);
287    #[allow(clippy::cast_precision_loss)]
288    let at = |k: usize| lo + (hi - lo) * k as f64 / count as f64;
289    let mut stations: Option<Vec<(f64, ogeom_math::Point)>> = None;
290    let foot = |p: ogeom_math::Point, mut t: f64| -> (f64, f64) {
291        let mut best = (t, f64::INFINITY);
292        for _ in 0..8 {
293            let (Ok(q), Ok(d)) = (curve.point_at(t, tol), curve.d1_at(t, tol)) else {
294                break;
295            };
296            let gap = q.distance(p);
297            if gap < best.1 {
298                best = (t, gap);
299            }
300            let speed = d.dot(d);
301            if speed <= f64::MIN_POSITIVE {
302                break;
303            }
304            let next = (t + (p - q).dot(d) / speed).clamp(lo, hi);
305            if (next - t).abs() <= (hi - lo) * 1e-12 {
306                break;
307            }
308            t = next;
309        }
310        if let Ok(q) = curve.point_at(t, tol)
311            && q.distance(p) < best.1
312        {
313            best = (t, q.distance(p));
314        }
315        best
316    };
317    let mut t = lo;
318    let mut worst = 0.0_f64;
319    for p in samples {
320        let mut found = foot(*p, t);
321        if found.1 > bound {
322            let stations = stations.get_or_insert_with(|| {
323                (0..=count)
324                    .filter_map(|k| curve.point_at(at(k), tol).ok().map(|q| (at(k), q)))
325                    .collect()
326            });
327            if let Some(k) = (0..stations.len()).min_by(|&x, &y| {
328                stations[x]
329                    .1
330                    .distance(*p)
331                    .total_cmp(&stations[y].1.distance(*p))
332            }) {
333                // Scanned finely over the stations either side, then
334                // narrowed by golden section: where the curve all but stops
335                // in space (its chart image swinging round a pole) or kinks
336                // between two samples, Newton's steps are blind and the
337                // distance has more than one dip between stations.
338                let gap = |u: f64| {
339                    curve
340                        .point_at(u, tol)
341                        .map_or(f64::INFINITY, |q| q.distance(*p))
342                };
343                let (from, to) = (
344                    stations[k.saturating_sub(2)].0,
345                    stations[(k + 2).min(stations.len() - 1)].0,
346                );
347                const FINE: u32 = 256;
348                let h = (to - from) / f64::from(FINE);
349                let start = (0..=FINE)
350                    .map(|j| from + h * f64::from(j))
351                    .min_by(|&x, &y| gap(x).total_cmp(&gap(y)))
352                    .unwrap_or(from);
353                let (mut a, mut b) = ((start - h).max(lo), (start + h).min(hi));
354                let ratio = 0.5 * (5.0_f64.sqrt() - 1.0);
355                for _ in 0..60 {
356                    let (x, y) = (b - ratio * (b - a), a + ratio * (b - a));
357                    if gap(x) <= gap(y) {
358                        b = y;
359                    } else {
360                        a = x;
361                    }
362                }
363                let again = foot(*p, 0.5 * (a + b));
364                if again.1 < found.1 {
365                    found = again;
366                }
367            }
368        }
369        t = found.0;
370        worst = worst.max(found.1.min(bound));
371    }
372    worst
373}
374
375/// The fit's chart residual carried into space through the surface's
376/// stretch along the pcurve: a bound on how far the lifted pcurve stands
377/// from the trace, loose by the stretch where the residual is mostly in the
378/// other coordinates, and so used only to cap an estimate, never stated.
379fn space_error(
380    surface: &SurfaceGeometry,
381    pcurve: &BSpline2d,
382    parameter_error: f64,
383    tol: Tolerances,
384) -> f64 {
385    use ogeom_geom::Curve2d;
386    // Convert the parameter-space error back through the surface's local
387    // stretch at a few places; take the worst.
388    let (lo, hi) = pcurve.domain();
389    let mut worst = 0.0_f64;
390    for i in 0..=16 {
391        #[allow(clippy::cast_precision_loss)]
392        let u = lo + (hi - lo) * f64::from(i) / 16.0;
393        let Ok(at) = pcurve.point_at(u, tol) else {
394            continue;
395        };
396        let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
397            continue;
398        };
399        let stretch = du.magnitude().max(dv.magnitude());
400        worst = worst.max(parameter_error * stretch);
401    }
402    worst
403}
404
405/// Unfold parameter samples across a periodic surface's seam.
406///
407/// Each step is folded to the nearest image of the next sample, so a branch
408/// crossing `u = 0` continues to `-0.1` rather than tearing to `2π - 0.1`. The
409/// result may leave the surface's stated domain, which is what a pcurve
410/// crossing a seam *is*.
411fn unwrap_periodic(
412    surface: &SurfaceGeometry,
413    samples: &[(f64, f64)],
414    tol: Tolerances,
415) -> Vec<Point2> {
416    let ((ua, ub), (va, vb)) = surface.domain();
417    // Closure as well as periodicity: a converted drum is a clamped patch
418    // that meets itself at its seam, and a loop walked round it lands on
419    // either side of that seam by the walk's own rounding. Folded by the
420    // chart's span like a period, the trace is the continuous curve it is.
421    // Left as sampled, it jumps a whole span at the seam and the closed
422    // fit chases the jump far from the trace.
423    let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
424        Some(ub - ua)
425    } else {
426        None
427    };
428    let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
429        Some(vb - va)
430    } else {
431        None
432    };
433    let fold = |previous: f64, next: f64, period: Option<f64>| match period {
434        None => next,
435        Some(period) => {
436            let mut candidate = next;
437            while candidate - previous > period * 0.5 {
438                candidate -= period;
439            }
440            while previous - candidate > period * 0.5 {
441                candidate += period;
442            }
443            candidate
444        }
445    };
446
447    let mut out = Vec::with_capacity(samples.len());
448    let mut at = Point2::new(samples[0].0, samples[0].1);
449    out.push(at);
450    for sample in &samples[1..] {
451        at = Point2::new(
452            fold(at.x, sample.0, u_period),
453            fold(at.y, sample.1, v_period),
454        );
455        out.push(at);
456    }
457    out
458}
459
460#[cfg(test)]
461#[allow(clippy::unwrap_used)]
462mod tests {
463    use super::*;
464    use crate::march::{Marching, branches};
465    use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
466    use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
467
468    const T: Tolerances = Tolerances::millimetres();
469
470    fn sphere(radius: f64) -> SurfaceGeometry {
471        SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
472    }
473
474    fn cylinder(radius: f64) -> SurfaceGeometry {
475        CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
476            .unwrap()
477            .into()
478    }
479
480    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
481        PlaneSurface::over(
482            Plane::through(origin, Direction::new(normal, T).unwrap()),
483            (-6.0, 6.0),
484            (-6.0, 6.0),
485        )
486        .unwrap()
487        .into()
488    }
489
490    fn options() -> Marching {
491        Marching {
492            chord: 1e-5,
493            ..Marching::default()
494        }
495    }
496
497    /// The distance of a fitted curve from both surfaces, sampled densely.
498    ///
499    /// This is the measure the whole stage exists for: the *fit* (not the
500    /// polyline it came from) is what downstream code holds, so the fit is
501    /// what must lie on both surfaces.
502    fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
503        let off = |surface: &SurfaceGeometry, p: Point| match surface {
504            SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
505            SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
506            SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
507            _ => 0.0,
508        };
509        let (lo, hi) = curve.knots().domain();
510        let mut worst = 0.0_f64;
511        for i in 0..=800 {
512            #[allow(clippy::cast_precision_loss)]
513            let u = lo + (hi - lo) * f64::from(i) / 800.0;
514            if let Ok(p) = curve.point_at(u, T) {
515                worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
516            }
517        }
518        worst
519    }
520
521    #[test]
522    fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
523        // The tolerance story end to end: trace within 1e-5, fit within 1e-4,
524        // so the fitted curve is within the sum of the two of the true
525        // intersection, measured against the surfaces, not the polyline.
526        let a = sphere(3.0);
527        let b = cylinder(1.5);
528        let found = branches(&a, &b, options(), T).unwrap();
529        assert_eq!(found.len(), 2);
530
531        for branch in &found {
532            let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
533            assert!(fitted.met, "fit error {:e}", fitted.fit_error);
534            assert!(fitted.closed);
535            let off = fitted_deviation(&a, &b, &fitted.curve);
536            assert!(
537                off <= 1e-4 + 1e-5,
538                "the fitted curve is {off:e} off the surfaces"
539            );
540            // And it is compact: a curve, not a decorated polyline.
541            assert!(
542                fitted.curve.control_points().len() * 4 < branch.points.len(),
543                "{} control points for {} samples",
544                fitted.curve.control_points().len(),
545                branch.points.len()
546            );
547        }
548    }
549
550    #[test]
551    fn the_pcurves_lift_back_onto_the_curve() {
552        // A pcurve is only worth having if evaluating it and lifting through
553        // its surface lands on the intersection. Checked through both
554        // surfaces at matched ends and sampled interiors.
555        let a = sphere(3.0);
556        let b = cylinder(1.5);
557        let found = branches(&a, &b, options(), T).unwrap();
558        let branch = &found[0];
559        let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
560
561        for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
562            let (lo, hi) = pcurve.domain();
563            for i in 0..=200 {
564                #[allow(clippy::cast_precision_loss)]
565                let u = lo + (hi - lo) * f64::from(i) / 200.0;
566                let at = pcurve.point_at(u, T).unwrap();
567                let lifted = surface.point_at(at.x, at.y, T).unwrap();
568                // The lifted point is on its own surface by construction; what
569                // matters is that it is on the *other* one too, i.e. on the
570                // intersection.
571                let off = match (surface as &SurfaceGeometry, &a, &b) {
572                    _ if core::ptr::eq(surface, &a) => match &b {
573                        SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
574                        _ => 0.0,
575                    },
576                    _ => match &a {
577                        SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
578                        _ => 0.0,
579                    },
580                };
581                assert!(
582                    off.abs() < 5e-4,
583                    "a lifted pcurve point is {off:e} off the intersection"
584                );
585            }
586        }
587    }
588
589    #[test]
590    fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
591        // A plane through a cylinder's axis at an angle produces an ellipse
592        // whose pcurve crosses the cylinder's u = 0 seam. Folded naively the
593        // pcurve tears by 2π; unwrapped it runs smoothly and leaves the stated
594        // domain, which is what crossing a seam means.
595        let a = cylinder(2.0);
596        let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
597        let found = branches(&a, &b, options(), T).unwrap();
598        assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
599        let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
600
601        // Continuity: no two adjacent samples of the fitted pcurve jump by
602        // anything near a period.
603        let (lo, hi) = fitted.on_a.domain();
604        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
605        for i in 1..=400 {
606            #[allow(clippy::cast_precision_loss)]
607            let u = lo + (hi - lo) * f64::from(i) / 400.0;
608            let at = fitted.on_a.point_at(u, T).unwrap();
609            assert!(
610                (at.x - previous.x).abs() < 1.0,
611                "the pcurve tears at the seam: {} to {}",
612                previous.x,
613                at.x
614            );
615            previous = at;
616        }
617    }
618
619    /// A loop walked round a converted drum is closed, seam or no seam.
620    ///
621    /// A cylinder converted to a patch is clamped, not periodic: it meets
622    /// itself at its seam. A plane across it cuts a circle the walk reaches
623    /// the seam on from both sides, each half stopping a fraction of a step
624    /// short of it, and the joined branch has coincident ends. Left flagged
625    /// as having left the domain, the arrangement downstream would hold a
626    /// circle with two ends at one point. It is closed, and fitted as a loop
627    /// whose chart image runs continuously across the seam.
628    #[test]
629    fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
630        let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
631        assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
632        let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
633        let found = branches(&drum, &cut, options(), T).unwrap();
634        assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
635        assert!(found[0].closed(), "the loop closes on the seam");
636        let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
637        assert!(fitted.closed);
638        assert!(
639            fitted.fit_error < 1e-3,
640            "the loop fits as one: {}",
641            fitted.fit_error
642        );
643        let (lo, hi) = fitted.on_a.domain();
644        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
645        for i in 1..=400 {
646            let u = lo + (hi - lo) * f64::from(i) / 400.0;
647            let at = fitted.on_a.point_at(u, T).unwrap();
648            assert!(
649                (at.x - previous.x).abs() < 0.5,
650                "the chart image tears at the seam: {} to {}",
651                previous.x,
652                at.x
653            );
654            previous = at;
655        }
656    }
657
658    /// A bore across a converted drum states the error its curves have.
659    ///
660    /// The joint fit's residual mixes millimetres with the drum chart's
661    /// coordinates, and carried through the drum's stretch it reads hundreds
662    /// of times larger than the distance the fitted curves stand from the
663    /// two surfaces. The surfaces cross steeply here, so that distance is
664    /// what is stated, within a small multiple.
665    #[test]
666    fn a_section_across_a_wide_drum_states_its_measured_error() {
667        let radius = 23.6;
668        let wall = CylinderSurface::new(
669            Cylinder::new(Frame::WORLD, radius, T).unwrap(),
670            (-30.0, 30.0),
671        )
672        .unwrap();
673        let drum: SurfaceGeometry = SurfaceGeometry::from(wall).to_bspline(T).unwrap().into();
674        let bore = Cylinder::new(
675            Frame::new(
676                Point::new(0.0, 0.0, 3.0),
677                Direction::new(Vector::X, T).unwrap(),
678                Direction::new(Vector::Y, T).unwrap(),
679                T,
680            )
681            .unwrap(),
682            9.0,
683            T,
684        )
685        .unwrap();
686        let drill: SurfaceGeometry = CylinderSurface::new(bore, (-40.0, 40.0)).unwrap().into();
687        let marching = Marching {
688            chord: 1e-4,
689            ..Marching::default()
690        };
691        let found = branches(&drum, &drill, marching, T).unwrap();
692        assert!(!found.is_empty());
693        for branch in &found {
694            let fitted = approximate_branch(&drum, &drill, branch, 1e-4, T).unwrap();
695            let (lo, hi) = fitted.curve.knots().domain();
696            let mut off = 0.0_f64;
697            for i in 0..=2000 {
698                let t = lo + (hi - lo) * f64::from(i) / 2000.0;
699                let p = fitted.curve.point_at(t, T).unwrap();
700                off = off.max(
701                    wall.cylinder()
702                        .distance_to(p)
703                        .abs()
704                        .max(bore.distance_to(p).abs()),
705                );
706            }
707            // Honest both ways: no less than the curve stands off, and not
708            // hundreds of times more.
709            assert!(
710                fitted.fit_error + marching.chord >= off,
711                "states {:e} for a curve {off:e} off its surfaces",
712                fitted.fit_error
713            );
714            assert!(
715                fitted.fit_error <= 10.0 * off.max(marching.chord),
716                "states {:e} for a curve {off:e} off its surfaces",
717                fitted.fit_error
718            );
719        }
720    }
721
722    #[test]
723    fn what_cannot_be_fitted_is_refused() {
724        let a = sphere(1.0);
725        let b = plane(Point::ORIGIN, Vector::Z);
726        let found = branches(&a, &b, options(), T).unwrap();
727        assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
728        assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
729
730        let empty = Traced {
731            points: vec![],
732            on_a: vec![],
733            on_b: vec![],
734            stopped: crate::march::Stopped::Stalled,
735        };
736        assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
737    }
738}