Skip to main content

ogeom_intersect/
section.rs

1//! Where two surfaces meet: the one call.
2//!
3//! Everything else in this crate is a stage: closed forms, seeding, tracing,
4//! fitting. This is the function an application calls, and the one `ogeom-bool`
5//! builds on: give it two surfaces, get back what they do to each other,
6//! with the analytic path taken where it exists and the marched-and-fitted
7//! path where it does not. The caller does not choose; the pair does.
8//!
9//! *Elsewhere* this is `GeomAPI_IntSS` over `IntPatch`/`GeomInt`: one entry
10//! point hiding an analytic dispatch and a walking intersector.
11//!
12//! # What a section curve carries
13//!
14//! Three descriptions, because three consumers: the curve in space for the
15//! edge, and a pcurve per surface for the faces; face splitting happens in
16//! parameter space, and a curve a face cannot express is one it cannot be
17//! split along. Analytic results carry exact pcurves where the projection has
18//! a closed form and `None` where it does not; fitted results always carry
19//! fitted pcurves, because the tracer recorded the parameters as it walked.
20//!
21//! A pcurve here is **same-parameter** with its 3D curve: evaluating either at
22//! the same `t` lands on the same point of the intersection. That is the claim
23//! `docs/DATA_MODEL.md` ยง6 makes edges carry, and it is arranged here by
24//! construction (the 2D curves inherit the 3D curve's own parameterization)
25//! rather than asserted and repaired later.
26
27use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
28use ogeom_geom::{
29    Circle2d, Curve, Curve2d as _, Curve3d, Ellipse2d, Line2d, PlanarCurve, Surface,
30    SurfaceGeometry,
31};
32use ogeom_math::{Circle2, Ellipse2, Frame2, Point, Point2};
33
34use crate::approx::approximate_branch;
35use crate::march::{Marching, branches, trace_tangential};
36use crate::surface::{Meeting, surface_surface};
37
38/// How to intersect, when the general path runs.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct IntersectOptions {
41    /// The tolerance the fitted curves are held to.
42    pub tolerance: f64,
43    /// The marching settings, for pairs with no closed form.
44    pub marching: Marching,
45}
46
47impl Default for IntersectOptions {
48    fn default() -> Self {
49        Self {
50            tolerance: 1e-6,
51            marching: Marching::default(),
52        }
53    }
54}
55
56/// One curve of a section, with its parameter-space descriptions.
57#[derive(Debug, Clone, PartialEq)]
58pub struct SectionCurve {
59    /// The curve in space.
60    pub curve: Curve,
61    /// The curve in the first surface's parameter space, where it has one.
62    ///
63    /// Always present for a fitted curve. For an exact curve, present when the
64    /// projection has a closed form (a line on a plane, a circle on the
65    /// cylinder it wraps) and `None` where it does not, which is a statement
66    /// about the projection rather than about the curve.
67    pub on_a: Option<PlanarCurve>,
68    /// The same, on the second surface.
69    pub on_b: Option<PlanarCurve>,
70    /// How far this curve may sit from the true intersection.
71    ///
72    /// Zero for an exact curve. For a fitted one, the trace's chord tolerance
73    /// plus the fit's reported error: the sum of the stated parts.
74    pub tolerance: f64,
75    /// Whether the curve came from a closed form.
76    pub exact: bool,
77    /// Whether it is a closed loop.
78    pub closed: bool,
79    /// Whether the surfaces *touch* along this curve rather than crossing
80    /// it.
81    ///
82    /// A tangential contact is a real curve (the two surfaces meet there,
83    /// and a drawing has to show it), but it carries no boundary parity:
84    /// neither surface passes through the other, so nothing is inside on
85    /// one side and outside on the other. Consumers that classify by
86    /// crossing must leave these out of that arithmetic; consumers that
87    /// draw or measure contact want them.
88    pub tangential: bool,
89}
90
91/// What two surfaces do to each other.
92#[derive(Debug, Clone, PartialEq)]
93pub enum SurfaceIntersection {
94    /// They do not meet.
95    ///
96    /// From the general path this means *no crossing was found at the seeding
97    /// resolution*: a branch thinner than the sampling grid is invisible to
98    /// it, and the completeness instrument in `tests/support/coverage.rs` is
99    /// what checks.
100    Apart,
101    /// They touch at isolated points without crossing.
102    Touching(Vec<Point>),
103    /// They meet along these curves.
104    Along(Vec<SectionCurve>),
105    /// They are the same surface wherever they overlap.
106    Same,
107}
108
109/// Where two surfaces meet.
110///
111/// The analytic path answers the pairs with closed forms, exactly, with
112/// tolerance zero. Every other pair is seeded, traced and fitted to
113/// `options.tolerance`. One call, and the pair decides the path.
114///
115/// # Errors
116///
117/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the options
118/// are unusable. A pair the marcher finds nothing for is [`Apart`], not an
119/// error; see that variant for what it can and cannot claim.
120///
121/// [`Apart`]: SurfaceIntersection::Apart
122pub fn intersect_surfaces(
123    a: &SurfaceGeometry,
124    b: &SurfaceGeometry,
125    options: IntersectOptions,
126    tol: Tolerances,
127) -> OgeomResult<SurfaceIntersection> {
128    if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
129        ogeom_bail!(
130            Construction,
131            "a tolerance of {} is not a distance",
132            options.tolerance
133        );
134    }
135
136    // A plane all but along a drum's axis meets it in an ellipse
137    // kilometres long, whose parameter is too coarse a ruler for the few
138    // millimetres of it the drum's height holds: a crossing solved on it
139    // lands tens of microns off. Over that height it is two lines.
140    if let Some(sections) = near_parallel_plane_drum(a, b, tol) {
141        return Ok(if sections.is_empty() {
142            SurfaceIntersection::Apart
143        } else {
144            SurfaceIntersection::Along(sections)
145        });
146    }
147    match surface_surface(a, b, tol) {
148        Ok(Meeting::Apart) => Ok(SurfaceIntersection::Apart),
149        Ok(Meeting::Same) => Ok(SurfaceIntersection::Same),
150        Ok(Meeting::Touching(points)) => Ok(SurfaceIntersection::Touching(points)),
151        Ok(Meeting::Along(curves)) => {
152            let sections: Vec<SectionCurve> = curves
153                .into_iter()
154                .filter_map(|curve| exact_section(curve, a, b, tol))
155                .collect();
156            Ok(if sections.is_empty() {
157                // Every curve fell outside the surfaces' stated extents: the
158                // unbounded geometries meet, the surfaces as given do not.
159                SurfaceIntersection::Apart
160            } else {
161                SurfaceIntersection::Along(sections)
162            })
163        }
164        // No closed form for this pair: the statement that sends it to the
165        // marcher, unless the pair is two drums all but parallel.
166        Err(_) => match near_parallel_drums(a, b, tol)
167            .or_else(|| ball_through_drum(a, b, tol))
168            .or_else(|| axial_plane_revolution(a, b, tol))
169            .or_else(|| plane_along_spline_lines(a, b, tol))
170        {
171            Some(sections) if sections.is_empty() => Ok(SurfaceIntersection::Apart),
172            Some(sections) => Ok(SurfaceIntersection::Along(sections)),
173            None => marched(a, b, options, tol),
174        },
175    }
176}
177
178/// A plane through the axis of a surface of revolution whose profile lies
179/// in a plane through that axis: the profile turned to each angle that sets
180/// its plane on the cut, a column of the revolution's chart.
181///
182/// Marched, such a section runs through the pole wherever the profile meets
183/// the axis, where the chart pinches to a point and the march stalls. Here
184/// the profile is cut where it crosses the axis, and each piece is turned to
185/// both angles, the one setting its side of the axis on each half of the
186/// cut: a revolution whose profile crosses the axis covers the section
187/// twice, once from each column, and a face on either column finds its own
188/// piece. `None` for any other plane or profile, and where the turns fall
189/// outside the sweep the answer is empty.
190fn axial_plane_revolution(
191    a: &SurfaceGeometry,
192    b: &SurfaceGeometry,
193    tol: Tolerances,
194) -> Option<Vec<SectionCurve>> {
195    const SAMPLES: u32 = 64;
196    let (plane, revolution, plane_first) = match (a, b) {
197        (SurfaceGeometry::Plane(p), SurfaceGeometry::Revolution(r)) => (p, r, true),
198        (SurfaceGeometry::Revolution(r), SurfaceGeometry::Plane(p)) => (p, r, false),
199        _ => return None,
200    };
201    let cut = plane.plane();
202    let axis = revolution.axis();
203    let normal = cut.normal();
204    if normal.dot(axis.direction).abs() > tol.angular()
205        || cut.signed_distance_to(axis.location).abs() > tol.confusion()
206    {
207        return None;
208    }
209    let profile = revolution.curve();
210    let (v0, v1) = profile.domain();
211    let at = |k: u32| v0 + (v1 - v0) * f64::from(k) / f64::from(SAMPLES);
212    let radial = |v: f64| {
213        let p = profile.point_at(v, tol).ok()?;
214        Some(p - axis.project(p))
215    };
216    // The profile's own side of the axis, from its point furthest off it.
217    let mut widest = ogeom_math::Vector::ZERO;
218    for k in 0..=SAMPLES {
219        let r = radial(at(k))?;
220        if r.magnitude() > widest.magnitude() {
221            widest = r;
222        }
223    }
224    let side = ogeom_math::Direction::new(widest, tol).ok()?;
225    let across = axis.direction.cross_with(side.vector());
226    // Every point of the profile in the plane of the axis and that side.
227    let offset = |v: f64| radial(v).map(|r| (r.dot(side.vector()), r.dot(across)));
228    for k in 0..=SAMPLES {
229        let (_, off) = offset(at(k))?;
230        if off.abs() > tol.confusion() {
231            return None;
232        }
233    }
234    // The pieces between the profile's crossings of the axis, each crossing
235    // narrowed by bisection.
236    let mut cuts = vec![v0];
237    for k in 0..SAMPLES {
238        let (mut lo, mut hi) = (at(k), at(k + 1));
239        let (s_lo, s_hi) = (offset(lo)?.0, offset(hi)?.0);
240        if s_lo.abs() <= tol.confusion() || s_lo * s_hi >= 0.0 {
241            continue;
242        }
243        for _ in 0..80 {
244            let mid = f64::midpoint(lo, hi);
245            if offset(mid)?.0 * s_lo > 0.0 {
246                lo = mid;
247            } else {
248                hi = mid;
249            }
250        }
251        cuts.push(f64::midpoint(lo, hi));
252    }
253    cuts.push(v1);
254    // The turns setting the profile's side on the cut's two halves.
255    let out = axis.direction.cross_with(normal.vector());
256    let first = across.dot(out).atan2(side.vector().dot(out));
257    let (u0, u1) = revolution.domain().0;
258    let turns: Vec<f64> = [first, first + core::f64::consts::PI]
259        .into_iter()
260        .filter_map(|u| {
261            let u = u0 + (u - u0).rem_euclid(core::f64::consts::TAU);
262            let u = if (u - u0 - core::f64::consts::TAU).abs() <= tol.angular() {
263                u0
264            } else {
265                u
266            };
267            (u <= u1 + tol.angular()).then_some(u.min(u1))
268        })
269        .collect();
270    let mut sections = Vec::new();
271    for &u in &turns {
272        let turned = ogeom_geom::Transformable::transformed(
273            profile,
274            &ogeom_math::Transform::rotation(axis, u),
275            tol,
276        )
277        .ok()?;
278        for piece in cuts.windows(2) {
279            let (va, vb) = (piece[0], piece[1]);
280            if vb - va <= tol.parametric() {
281                continue;
282            }
283            let curve: Curve = ogeom_geom::TrimmedCurve::new(turned.clone(), va, vb, tol)
284                .ok()?
285                .into();
286            let column: PlanarCurve = Line2d::over(
287                ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
288                va,
289                vb,
290            )
291            .ok()?
292            .into();
293            let flat = exact_pcurve(&curve, (va, vb), a_or_b(plane_first, a, b), tol);
294            let (on_a, on_b) = if plane_first {
295                (flat, Some(column))
296            } else {
297                (Some(column), flat)
298            };
299            sections.push(SectionCurve {
300                on_a,
301                on_b,
302                tolerance: 0.0,
303                exact: true,
304                closed: false,
305                tangential: false,
306                curve,
307            });
308        }
309    }
310    Some(sections)
311}
312
313/// A plane holding whole columns or rows of a spline surface, and meeting
314/// it nowhere else: those iso lines, exactly.
315///
316/// A surface of revolution converted to a spline and scaled keeps its
317/// meridians as columns, and a plane through its axis holds two of them,
318/// one often the seam along the chart's border. Marched, such a section
319/// runs along the chart's edge or through its poles and is not found. An
320/// iso line lies in the plane where every control point of it does, which
321/// is a root of each control point's weighted distance to the plane, found
322/// along the chart. The answer stands only where a grid over the chart
323/// finds the surface on one side of the plane between the lines found;
324/// anything else meets the plane elsewhere too and is marched.
325fn plane_along_spline_lines(
326    a: &SurfaceGeometry,
327    b: &SurfaceGeometry,
328    tol: Tolerances,
329) -> Option<Vec<SectionCurve>> {
330    const SAMPLES: u32 = 96;
331    let (plane, spline, plane_first) = match (a, b) {
332        (SurfaceGeometry::Plane(p), SurfaceGeometry::BSpline(s)) => (p.plane(), s, true),
333        (SurfaceGeometry::BSpline(s), SurfaceGeometry::Plane(p)) => (p.plane(), s, false),
334        _ => return None,
335    };
336    let distance = |p: Point| plane.signed_distance_to(p);
337    let ((u0, u1), (v0, v1)) = spline.domain();
338    // An iso line's control points' weighted distances to the plane, and
339    // whether the line has any length.
340    let line_at = |along_u: bool, t: f64| -> Option<ogeom_geom::BSplineCurve> {
341        if along_u {
342            spline.iso_u_curve(t, tol).ok()
343        } else {
344            spline.iso_v_curve(t, tol).ok()
345        }
346    };
347    let weighted = |curve: &ogeom_geom::BSplineCurve| -> Vec<f64> {
348        curve
349            .control_points()
350            .iter()
351            .map(|w| w.weight * distance(w.point()))
352            .collect()
353    };
354    let lies_in = |curve: &ogeom_geom::BSplineCurve| {
355        curve
356            .control_points()
357            .iter()
358            .all(|w| distance(w.point()).abs() <= tol.confusion())
359    };
360    let has_length = |curve: &ogeom_geom::BSplineCurve| {
361        let first = curve.control_points()[0].point();
362        curve
363            .control_points()
364            .iter()
365            .any(|w| w.point().distance(first) > tol.confusion())
366    };
367    // The iso lines of one family lying in the plane: the chart's borders,
368    // and every root of the control point that strays furthest.
369    let found = |along_u: bool| -> Option<Vec<f64>> {
370        let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
371        let at = |k: u32| lo + (hi - lo) * f64::from(k) / f64::from(SAMPLES);
372        let rows: Vec<Vec<f64>> = (0..=SAMPLES)
373            .map(|k| line_at(along_u, at(k)).map(|c| weighted(&c)))
374            .collect::<Option<_>>()?;
375        let count = rows[0].len();
376        if rows.iter().any(|r| r.len() != count) {
377            return None;
378        }
379        let widest = (0..count).max_by(|&i, &j| {
380            let spread = |i: usize| rows.iter().fold(0.0_f64, |m, r| m.max(r[i].abs()));
381            spread(i).total_cmp(&spread(j))
382        })?;
383        let mut roots = vec![lo, hi];
384        for k in 0..SAMPLES {
385            let (mut a, mut b) = (at(k), at(k + 1));
386            let (da, db) = (rows[k as usize][widest], rows[k as usize + 1][widest]);
387            if da == 0.0 {
388                roots.push(a);
389                continue;
390            }
391            if da * db > 0.0 {
392                continue;
393            }
394            let sign = da.signum();
395            for _ in 0..80 {
396                let mid = f64::midpoint(a, b);
397                let d = weighted(&line_at(along_u, mid)?)[widest];
398                if d * sign > 0.0 {
399                    a = mid;
400                } else {
401                    b = mid;
402                }
403            }
404            roots.push(f64::midpoint(a, b));
405        }
406        roots.sort_by(f64::total_cmp);
407        roots.dedup_by(|x, y| (*x - *y).abs() <= tol.parametric());
408        Some(
409            roots
410                .into_iter()
411                .filter(|&t| line_at(along_u, t).is_some_and(|c| lies_in(&c) && has_length(&c)))
412                .collect(),
413        )
414    };
415    let columns = found(true)?;
416    let rows = found(false)?;
417    if columns.is_empty() && rows.is_empty() {
418        return None;
419    }
420    // The chart cut by the lines found into cells: the surface keeps to one
421    // side of the plane within each, or it meets the plane elsewhere too.
422    let strip = |lines: &[f64], t: f64| lines.iter().filter(|&&x| x < t).count();
423    let near_line = |lines: &[f64], t: f64, span: f64| {
424        lines
425            .iter()
426            .any(|&x| (x - t).abs() <= span / f64::from(SAMPLES) * 0.25)
427    };
428    let mut sides: std::collections::HashMap<(usize, usize), f64> =
429        std::collections::HashMap::new();
430    let band = tol.confusion() * 10.0;
431    for i in 0..=SAMPLES {
432        let u = u0 + (u1 - u0) * (f64::from(i) + 0.5) / f64::from(SAMPLES + 1);
433        if near_line(&columns, u, u1 - u0) {
434            continue;
435        }
436        for j in 0..=SAMPLES {
437            let v = v0 + (v1 - v0) * (f64::from(j) + 0.5) / f64::from(SAMPLES + 1);
438            if near_line(&rows, v, v1 - v0) {
439                continue;
440            }
441            let d = distance(spline.point_at(u, v, tol).ok()?);
442            if d.abs() <= band {
443                continue;
444            }
445            let cell = (strip(&columns, u), strip(&rows, v));
446            match sides.get(&cell) {
447                Some(side) if side * d < 0.0 => return None,
448                Some(_) => {}
449                None => {
450                    sides.insert(cell, d.signum());
451                }
452            }
453        }
454    }
455    // Each line must be a crossing, the cells either side of it on
456    // opposite sides of the plane (across the border of a closed chart,
457    // the cells at its two ends). A line the surface only touches, or with
458    // no cell beside it to say, is a tangency the marcher and the contact
459    // handling answer, and so is the whole pair.
460    let closed_u = spline.is_closed_u(tol);
461    let closed_v = spline.is_closed_v(tol);
462    let side_of = |cu: Option<usize>, cv: Option<usize>| -> Option<f64> {
463        let mut found = sides
464            .iter()
465            .filter(|((u, v), _)| cu.is_none_or(|c| c == *u) && cv.is_none_or(|c| c == *v))
466            .map(|(_, s)| *s);
467        let first = found.next()?;
468        found.all(|s| s == first).then_some(first)
469    };
470    let crosses = |k: usize, count: usize, closed: bool, cell: &dyn Fn(usize) -> Option<f64>| {
471        let below = cell(k).or_else(|| closed.then(|| (0..=count).rev().find_map(cell)).flatten());
472        let above = cell(k + 1).or_else(|| closed.then(|| (0..=count).find_map(cell)).flatten());
473        matches!((below, above), (Some(x), Some(y)) if x * y < 0.0)
474    };
475    for k in 0..columns.len() {
476        if !crosses(k, columns.len(), closed_u, &|c| side_of(Some(c), None)) {
477            return None;
478        }
479    }
480    for k in 0..rows.len() {
481        if !crosses(k, rows.len(), closed_v, &|c| side_of(None, Some(c))) {
482            return None;
483        }
484    }
485    let mut sections = Vec::new();
486    let mut emit = |along_u: bool, t: f64| -> Option<()> {
487        let iso = line_at(along_u, t)?;
488        let curve: Curve = iso.into();
489        let range = curve.domain();
490        let chart: PlanarCurve = if along_u {
491            Line2d::over(
492                ogeom_math::Axis2::new(Point2::new(t, 0.0), ogeom_math::Direction2::Y),
493                range.0,
494                range.1,
495            )
496        } else {
497            Line2d::over(
498                ogeom_math::Axis2::new(Point2::new(0.0, t), ogeom_math::Direction2::X),
499                range.0,
500                range.1,
501            )
502        }
503        .ok()?
504        .into();
505        let flat = exact_pcurve(&curve, range, a_or_b(plane_first, a, b), tol)?;
506        let (on_a, on_b) = if plane_first {
507            (Some(flat), Some(chart))
508        } else {
509            (Some(chart), Some(flat))
510        };
511        sections.push(SectionCurve {
512            on_a,
513            on_b,
514            tolerance: 0.0,
515            exact: true,
516            closed: curve.is_closed(tol),
517            tangential: false,
518            curve,
519        });
520        Some(())
521    };
522    // Every line found: the chart's two borders across a closed surface
523    // are one line in space, stated once.
524    for (k, &u) in columns.iter().enumerate() {
525        if closed_u
526            && k + 1 == columns.len()
527            && k > 0
528            && columns[0] == u0
529            && (u - u1).abs() <= tol.parametric()
530        {
531            continue;
532        }
533        emit(true, u)?;
534    }
535    for (k, &v) in rows.iter().enumerate() {
536        if closed_v
537            && k + 1 == rows.len()
538            && k > 0
539            && rows[0] == v0
540            && (v - v1).abs() <= tol.parametric()
541        {
542            continue;
543        }
544        emit(false, v)?;
545    }
546    Some(sections)
547}
548
549/// The first surface where `first` holds, else the second.
550fn a_or_b<'s>(first: bool, a: &'s SurfaceGeometry, b: &'s SurfaceGeometry) -> &'s SurfaceGeometry {
551    if first { a } else { b }
552}
553
554/// Two drums whose axes are all but parallel, over the height they share.
555///
556/// Parallel drums meet in straight lines along their axes, and drums whose
557/// axes lean a ten-thousandth apart (a drilled hole beside a fillet of a
558/// converted mesh, each axis fitted to its own facets) meet in a quartic
559/// that departs from those lines by less than a micron over any height a
560/// part has. Marched, it comes back as fitted curves that cost seconds to
561/// cross and wander where the drums nearly touch. Here each is solved in
562/// the cross-sections along the shared height and kept as the line through
563/// its ends where every station lies near it, that departure stated as the
564/// section's tolerance.
565///
566/// `None` where the axes lean further, where the drums do not cross
567/// cleanly at every station (a crossing starting part way up, or a near
568/// touch), or where a station strays: the marcher answers those. An empty
569/// answer is drums that share no height.
570fn near_parallel_drums(
571    a: &SurfaceGeometry,
572    b: &SurfaceGeometry,
573    tol: Tolerances,
574) -> Option<Vec<SectionCurve>> {
575    const LEAN: f64 = 1e-3;
576    let (SurfaceGeometry::Cylinder(sa), SurfaceGeometry::Cylinder(sb)) = (a, b) else {
577        return None;
578    };
579    let (ca, cb) = (sa.cylinder(), sb.cylinder());
580    let (axis_a, axis_b) = (ca.axis(), cb.axis());
581    let (da, db) = (axis_a.direction.vector(), axis_b.direction.vector());
582    let (ra, rb) = (ca.radius(), cb.radius());
583    let cos = da.dot(db);
584    if da.cross(db).magnitude() > LEAN || cos.abs() < 0.5 {
585        return None;
586    }
587    let (pa, pb) = (axis_a.location, axis_b.location);
588    // The shared height, measured along the first axis.
589    let (_, (a0, a1)) = a.domain();
590    let (_, (b0, b1)) = b.domain();
591    let along = |v: f64| (pb - pa).dot(da) + v * cos;
592    let (lo, hi) = (
593        a0.min(a1).max(along(b0).min(along(b1))),
594        a0.max(a1).min(along(b0).max(along(b1))),
595    );
596    if !(lo.is_finite() && hi.is_finite()) {
597        return None;
598    }
599    if hi - lo <= tol.confusion() {
600        return Some(Vec::new());
601    }
602    // Where the two cross-sections at a station meet, left and right of
603    // the line of centres: the second drum's section is an ellipse only a
604    // square of its lean away from a circle, a stated part of the stray.
605    let meet = |z: f64| -> Option<[Point; 2]> {
606        let centre_a = pa + da * z;
607        let s = (centre_a - pb).dot(da) / cos;
608        let centre_b = pb + db * s;
609        let mut between = centre_b - centre_a;
610        between = between - da * between.dot(da);
611        let d = between.magnitude();
612        // Axes, or a touch, well inside the weld distance are one: the
613        // sliver between the drums is welded rather than sectioned. Half
614        // of it, so a sliver at the weld distance itself is sectioned
615        // whole rather than lost between the two readings.
616        let margin = tol.confusion() * 50.0;
617        if d <= margin || d >= ra + rb - margin || d <= (ra - rb).abs() + margin {
618            return None;
619        }
620        let x = (d * d + ra * ra - rb * rb) / (2.0 * d);
621        let h = (ra * ra - x * x).max(0.0).sqrt();
622        let ex = between / d;
623        let ey = da.cross(ex);
624        Some([centre_a + ex * x + ey * h, centre_a + ex * x - ey * h])
625    };
626    lines_through_stations(lo, hi, meet, rb * (1.0 / cos.abs() - 1.0), tol)
627}
628
629/// How far a near-parallel pair's sections may stray from the true
630/// crossing: what a fitted section typically carries.
631const NEAR_PARALLEL_STRAY: f64 = 1e-5;
632
633/// The two curves a near-parallel pair meets in over the height `lo..hi`,
634/// from where `meet` puts the crossing at each height: the line through the
635/// ends where every station lies within a micron of it, else a cubic
636/// through the stations at their heights, checked midway between them.
637/// Either is kept within [`NEAR_PARALLEL_STRAY`], the departure stated as
638/// its tolerance. `None` where a station has no clean crossing or the
639/// curve strays.
640fn lines_through_stations(
641    lo: f64,
642    hi: f64,
643    meet: impl Fn(f64) -> Option<[Point; 2]>,
644    stated: f64,
645    tol: Tolerances,
646) -> Option<Vec<SectionCurve>> {
647    const STATIONS: u32 = 32;
648    const STRAIGHT: f64 = 1e-6;
649    let at = |k: f64| (hi - lo).mul_add(k / f64::from(STATIONS), lo);
650    let heights: Vec<f64> = (0..=STATIONS).map(|k| at(f64::from(k))).collect();
651    let met: Vec<[Point; 2]> = heights.iter().map(|&z| meet(z)).collect::<Option<_>>()?;
652    let between: Vec<[Point; 2]> = (0..STATIONS)
653        .map(|k| meet(at(f64::from(k) + 0.5)))
654        .collect::<Option<_>>()?;
655    let mut out = Vec::with_capacity(2);
656    for side in 0..2 {
657        let (from, to) = (met[0][side], met[met.len() - 1][side]);
658        let span = to - from;
659        let length = span.magnitude();
660        if length <= tol.confusion() {
661            return None;
662        }
663        let off_line = |p: Point| {
664            let t = (p - from).dot(span) / (length * length);
665            p.distance(from + span * t)
666        };
667        let stray = met
668            .iter()
669            .chain(&between)
670            .map(|pair| off_line(pair[side]))
671            .fold(0.0_f64, f64::max);
672        let (curve, stray): (Curve, f64) = if stray <= STRAIGHT {
673            (
674                ogeom_geom::LineCurve::segment(from, to, tol).ok()?.into(),
675                stray,
676            )
677        } else {
678            let points: Vec<Point> = met.iter().map(|pair| pair[side]).collect();
679            let fitted =
680                ogeom_geom::fit::fit_points_at(&heights, &points, 3, tol.confusion(), tol).ok()?;
681            let curve: Curve = fitted.curve.into();
682            let mut worst = fitted.error;
683            for (k, pair) in (0..STATIONS).zip(&between) {
684                let p = curve.point_at(at(f64::from(k) + 0.5), tol).ok()?;
685                worst = worst.max(p.distance(pair[side]));
686            }
687            (curve, worst)
688        };
689        let tolerance = stray + stated + tol.confusion();
690        if tolerance > NEAR_PARALLEL_STRAY {
691            return None;
692        }
693        out.push(SectionCurve {
694            curve,
695            on_a: None,
696            on_b: None,
697            tolerance,
698            exact: false,
699            closed: false,
700            tangential: false,
701        });
702    }
703    Some(out)
704}
705
706/// A drum passing clean through a ball: every line along the drum meets
707/// the ball twice, within the drum's height.
708///
709/// Then each of the two loops the drum and ball meet in is a function of
710/// the angle round the drum: at each angle, where the line along the drum
711/// enters and leaves the ball is a quadratic's two roots. The loops are
712/// sampled so, exactly, and fitted closed, the fit's error stated as the
713/// section's tolerance. Marched instead, a drum that all but grazes the
714/// ball's far side leaves loops long and thin, and the trace wanders along
715/// them past any bound. `None` where some line misses or grazes the ball,
716/// or leaves the drum's height: the marcher answers those.
717fn ball_through_drum(
718    a: &SurfaceGeometry,
719    b: &SurfaceGeometry,
720    tol: Tolerances,
721) -> Option<Vec<SectionCurve>> {
722    const SAMPLES: u32 = 256;
723    const STRAY: f64 = 1e-5;
724    let (ball, drum, ball_first) = match (a, b) {
725        (SurfaceGeometry::Sphere(s), SurfaceGeometry::Cylinder(c)) => (s, c, true),
726        (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Sphere(s)) => (s, c, false),
727        _ => return None,
728    };
729    let (sphere, cylinder) = (ball.sphere(), drum.cylinder());
730    let frame = cylinder.frame();
731    let (x, y, d) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
732    let (origin, r) = (frame.origin(), cylinder.radius());
733    let (centre, big) = (sphere.centre(), sphere.radius());
734    let ball_frame = sphere.frame();
735    let (_, (h0, h1)) = drum.domain();
736    // A line that only just meets the ball leaves the loop turning sharply
737    // there; a tenth of the drum's radius of chord inside the ball keeps
738    // the loops smooth enough to fit.
739    let margin = r * 0.1;
740    // Where the line along the drum at `angle` enters and leaves the ball.
741    let heights = |angle: f64| -> Option<[f64; 2]> {
742        let foot = origin + (x * angle.cos() + y * angle.sin()) * r;
743        let w = foot - centre;
744        let half = d.dot(w);
745        let disc = half.mul_add(half, -(w.dot(w) - big * big));
746        if disc <= margin * margin {
747            return None;
748        }
749        let root = disc.sqrt();
750        let pair = [-half - root, -half + root];
751        pair.iter().all(|v| *v >= h0 && *v <= h1).then_some(pair)
752    };
753    let at = |angle: f64, v: f64| origin + (x * angle.cos() + y * angle.sin()) * r + d * v;
754    // The ball's longitude and latitude of a point, as its chart reads them.
755    let on_ball = |p: Point, before: Option<Point2>| -> Point2 {
756        let local = ball_frame.to_local(p);
757        let lat = local.z.atan2(local.x.hypot(local.y));
758        let mut lon = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
759        if let Some(prev) = before {
760            while lon - prev.x > core::f64::consts::PI {
761                lon -= core::f64::consts::TAU;
762            }
763            while prev.x - lon > core::f64::consts::PI {
764                lon += core::f64::consts::TAU;
765            }
766        }
767        Point2::new(lon, lat)
768    };
769    let angle_of = |k: f64| core::f64::consts::TAU * k / f64::from(SAMPLES);
770    let params: Vec<f64> = (0..=SAMPLES).map(|k| angle_of(f64::from(k))).collect();
771    let mut sampled: Vec<[f64; 2]> = Vec::with_capacity(params.len());
772    for &angle in &params {
773        sampled.push(heights(angle)?);
774    }
775    let mut out = Vec::with_capacity(2);
776    for side in 0..2 {
777        let points: Vec<Point> = params
778            .iter()
779            .zip(&sampled)
780            .map(|(&angle, pair)| at(angle, pair[side]))
781            .collect();
782        let on_drum: Vec<Point2> = params
783            .iter()
784            .zip(&sampled)
785            .map(|(&angle, pair)| Point2::new(angle, pair[side]))
786            .collect();
787        let mut on_sphere: Vec<Point2> = Vec::with_capacity(points.len());
788        for p in &points {
789            let q = on_ball(*p, on_sphere.last().copied());
790            on_sphere.push(q);
791        }
792        let target = tol.confusion() * 10.0;
793        let curve: Curve = ogeom_geom::fit::fit_points_at(&params, &points, 3, target, tol)
794            .ok()?
795            .curve
796            .into();
797        let drum_image: PlanarCurve =
798            ogeom_geom::fit::fit_points_2d_at(&params, &on_drum, 3, target, tol)
799                .ok()?
800                .curve
801                .into();
802        let ball_image: PlanarCurve =
803            ogeom_geom::fit::fit_points_2d_at(&params, &on_sphere, 3, target, tol)
804                .ok()?
805                .curve
806                .into();
807        // Checked at the samples and midway between them: the curve, and
808        // each surface read through its image, against the true meeting.
809        let mut stray = 0.0_f64;
810        for k in 0..(2 * SAMPLES) {
811            let angle = angle_of(f64::from(k) / 2.0);
812            let truth = at(angle, heights(angle)?[side]);
813            let on_curve = curve.point_at(angle, tol).ok()?;
814            let uv = drum_image.point_at(angle, tol).ok()?;
815            let through_drum = drum.point_at(uv.x, uv.y, tol).ok()?;
816            let uv = ball_image.point_at(angle, tol).ok()?;
817            let through_ball = ball.point_at(uv.x, uv.y, tol).ok()?;
818            stray = stray
819                .max(truth.distance(on_curve))
820                .max(truth.distance(through_drum))
821                .max(truth.distance(through_ball));
822        }
823        let tolerance = stray.max(tol.confusion());
824        if tolerance > STRAY {
825            return None;
826        }
827        let (on_a, on_b) = if ball_first {
828            (ball_image, drum_image)
829        } else {
830            (drum_image, ball_image)
831        };
832        out.push(SectionCurve {
833            curve,
834            on_a: Some(on_a),
835            on_b: Some(on_b),
836            tolerance,
837            exact: false,
838            closed: true,
839            tangential: false,
840        });
841    }
842    Some(out)
843}
844
845/// A plane leaning all but along a drum's axis, over the drum's height.
846///
847/// The closed form is an ellipse whose long axis is the drum's radius over
848/// the lean, kilometres for a facet group fitted a hundred-thousandth off
849/// a hole's axis. Its parameter spans the few millimetres the drum holds in
850/// a millionth of a turn, and crossings solved on it are only as good as
851/// that ruler. The crossing is solved instead in the drum's cross-sections
852/// along its height and kept as two lines where they hold, as
853/// [`near_parallel_drums`] does. `None` where the lean is exactly nothing
854/// (the closed form's lines are exact) or more than a thousandth, or where
855/// the plane does not cross the drum cleanly all the way up.
856fn near_parallel_plane_drum(
857    a: &SurfaceGeometry,
858    b: &SurfaceGeometry,
859    tol: Tolerances,
860) -> Option<Vec<SectionCurve>> {
861    const LEAN: f64 = 1e-3;
862    const SPAN: f64 = 3e4;
863    let (plane, drum, surface) = match (a, b) {
864        (SurfaceGeometry::Plane(p), SurfaceGeometry::Cylinder(c)) => (p.plane(), c.cylinder(), b),
865        (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Plane(p)) => (p.plane(), c.cylinder(), a),
866        _ => return None,
867    };
868    let axis = drum.axis();
869    let (d, r) = (axis.direction.vector(), drum.radius());
870    let n = plane.normal().vector();
871    let lean = n.dot(d).abs();
872    // Only where the ellipse is thirty metres or more across: there a
873    // parameter solved to its last billionth lands tens of nanometres off in
874    // space, past the weld of a face with tight edges. A shorter one is
875    // ruler enough, and its closed form crosses faster than a fitted curve.
876    if lean <= tol.angular() || lean > LEAN || r / lean < SPAN {
877        return None;
878    }
879    let across = n - d * n.dot(d);
880    let k = across.magnitude();
881    let e1 = across / k;
882    let e2 = d.cross(e1);
883    let (_, (lo, hi)) = surface.domain();
884    if !(lo.is_finite() && hi.is_finite()) || hi - lo <= tol.confusion() {
885        return None;
886    }
887    let meet = |z: f64| -> Option<[Point; 2]> {
888        let centre = axis.location + d * z;
889        let u = -plane.signed_distance_to(centre) / k;
890        let margin = tol.confusion() * 1e3;
891        if u.abs() >= r - margin {
892            return None;
893        }
894        let w = r.mul_add(r, -(u * u)).sqrt();
895        Some([centre + e1 * u + e2 * w, centre + e1 * u - e2 * w])
896    };
897    lines_through_stations(lo, hi, meet, 0.0, tol)
898}
899
900/// An exact curve dressed as a section, clipped to the surfaces it lies on.
901///
902/// The analytic layer works on the unbounded geometry (a plane and a cylinder
903/// meet in unbounded lines), but the *surfaces* carry finite extents, and a
904/// section running a billion units past both is not something an edge can be
905/// built on. A line is clipped to the parameter interval where it is inside
906/// both extents, through its exact pcurves; a curve wholly outside either
907/// extent is dropped, or the boolean above would see a phantom edge on a
908/// region the face does not have.
909///
910/// A *closed* curve partially outside an extent is kept whole: cutting it into
911/// arcs is the restriction problem, and the restriction that matters is the
912/// face's trim, which is the boolean's job. The extent here is only the
913/// surface's parameterization window.
914fn exact_section(
915    curve: Curve,
916    a: &SurfaceGeometry,
917    b: &SurfaceGeometry,
918    tol: Tolerances,
919) -> Option<SectionCurve> {
920    let closed = match &curve {
921        Curve::Circle(_) | Curve::Ellipse(_) => true,
922        _ => curve.is_closed(tol),
923    };
924    let range = curve.domain();
925    let on_a = exact_pcurve(&curve, range, a, tol);
926    let on_b = exact_pcurve(&curve, range, b, tol);
927
928    if let Curve::Line(_) = &curve {
929        // Clip through whichever pcurves exist; a missing pcurve leaves that
930        // surface's extent unenforced, which errs long rather than wrong.
931        let mut interval = curve.domain();
932        if let Some(p) = &on_a {
933            interval = intersect_intervals(interval, inside_box(p, a))?;
934        }
935        if let Some(p) = &on_b {
936            interval = intersect_intervals(interval, inside_box(p, b))?;
937        }
938        let (lo, hi) = interval;
939        let Curve::Line(line) = &curve else {
940            unreachable!()
941        };
942        let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
943            .ok()?
944            .into();
945        let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
946            let PlanarCurve::Line(l) = p else {
947                return Some(p.clone());
948            };
949            Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
950        };
951        let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
952        let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
953        return Some(SectionCurve {
954            on_a: ca,
955            on_b: cb,
956            tolerance: 0.0,
957            exact: true,
958            closed: false,
959            tangential,
960            curve: clipped,
961        });
962    }
963
964    // A closed curve: dropped only when wholly outside an extent it has a
965    // pcurve to check against.
966    for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
967        if let Some(p) = pcurve
968            && !touches_box(p, surface, tol)
969        {
970            return None;
971        }
972    }
973    let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
974    Some(SectionCurve {
975        on_a,
976        on_b,
977        tolerance: 0.0,
978        exact: true,
979        closed,
980        tangential,
981        curve,
982    })
983}
984
985/// Whether the surfaces touch along an exact curve rather than crossing it:
986/// their normals parallel at stations along its length.
987///
988/// Decided through the curve's own pcurves, which is where the normals can
989/// be read without inverting anything. A curve missing a pcurve on either
990/// surface is reported as a crossing, the honest default, since a section
991/// nobody can place in a chart is one nothing can classify as contact
992/// either.
993fn touching_along(
994    curve: &Curve,
995    on_a: Option<&PlanarCurve>,
996    on_b: Option<&PlanarCurve>,
997    a: &SurfaceGeometry,
998    b: &SurfaceGeometry,
999    tol: Tolerances,
1000) -> bool {
1001    // The chart position of a sample: through the pcurve where one exists,
1002    // through the surface's own closed-form inversion where not. A meridian
1003    // through a sphere's poles has no pcurve (its longitude jumps half a
1004    // turn at each pole), but every *point* of it inverts fine, and a
1005    // tangency that would be missed for want of a pcurve becomes a crossing
1006    // section lying along a face's own boundary, which is the worst thing a
1007    // section can be.
1008    let sample_uv = |pc: Option<&PlanarCurve>,
1009                     surface: &SurfaceGeometry,
1010                     t: f64|
1011     -> Option<ogeom_math::Point2> {
1012        if let Some(pc) = pc {
1013            return pc.point_at(t, tol).ok();
1014        }
1015        let p = curve.point_at(t, tol).ok()?;
1016        chart_inversion(surface, p, tol)
1017    };
1018    let (lo, hi) = curve.domain();
1019    // Offsets chosen off the round fractions, so a curve through a chart
1020    // degeneracy (a meridian's poles sit at quarters of its turn) is
1021    // sampled beside the degenerate points rather than on them. A sample
1022    // whose inversion still fails is skipped: the point says nothing,
1023    // not that the surfaces cross.
1024    let mut judged = 0_usize;
1025    for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
1026        let t = (hi - lo).mul_add(f, lo);
1027        let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
1028            continue;
1029        };
1030        let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
1031            continue;
1032        };
1033        if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
1034            return false;
1035        }
1036        judged += 1;
1037    }
1038    judged >= 3
1039}
1040
1041/// A point's chart position on an analytic surface, by closed form.
1042fn chart_inversion(
1043    surface: &SurfaceGeometry,
1044    p: ogeom_math::Point,
1045    tol: Tolerances,
1046) -> Option<ogeom_math::Point2> {
1047    use ogeom_math::elementary;
1048    let (u, v) = match surface {
1049        SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
1050        SurfaceGeometry::Cylinder(s) => {
1051            elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
1052        }
1053        SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
1054        SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
1055        SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
1056        _ => return None,
1057    };
1058    Some(ogeom_math::Point2::new(u, v))
1059}
1060
1061/// The parameter interval over which a 2D line stays inside a surface's
1062/// parameter box. `None` when it never enters.
1063fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
1064    // The pcurve as a point and a rate along its own parameter: a line, or
1065    // a degree-one spline of two points (a cone's ruling), linear in it.
1066    let (o, d) = match pcurve {
1067        PlanarCurve::Line(line) => {
1068            let axis = line.axis();
1069            (axis.location, axis.direction.vector())
1070        }
1071        PlanarCurve::BSpline(spline)
1072            if spline.knots().degree() == 1 && spline.control_points().len() == 2 =>
1073        {
1074            let (t0, t1) = spline.knots().domain();
1075            let (p0, p1) = (
1076                spline.control_points()[0].point(),
1077                spline.control_points()[1].point(),
1078            );
1079            if t1 <= t0 {
1080                return None;
1081            }
1082            let rate = (p1 - p0) / (t1 - t0);
1083            (p0 - rate * t0, rate)
1084        }
1085        _ => return None,
1086    };
1087    let ((ua, ub), (va, vb)) = surface.domain();
1088
1089    // The slab test, one axis at a time.
1090    let mut lo = f64::NEG_INFINITY;
1091    let mut hi = f64::INFINITY;
1092    for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
1093        if direction.abs() <= f64::MIN_POSITIVE {
1094            if origin < low || origin > high {
1095                return None;
1096            }
1097            continue;
1098        }
1099        let (a, b) = ((low - origin) / direction, (high - origin) / direction);
1100        let (near, far) = if a < b { (a, b) } else { (b, a) };
1101        lo = lo.max(near);
1102        hi = hi.min(far);
1103    }
1104    if lo >= hi {
1105        return None;
1106    }
1107    Some((lo, hi))
1108}
1109
1110/// Whether a closed pcurve may pass through the surface's box.
1111fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
1112    use ogeom_geom::Curve2d;
1113    let ((ua, ub), (va, vb)) = surface.domain();
1114    let (lo, hi) = pcurve.domain();
1115    // Asked of the spans between samples, not the samples alone: a plane all
1116    // but parallel to a cylinder's axis meets it in an ellipse kilometres
1117    // long, whose image on the cylinder's chart sweeps through a window a few
1118    // millimetres tall in a sliver of its turn, between any two samples.
1119    // Each span is taken as its chord's box widened by the chord's length,
1120    // which holds the curve between them wherever it bends no tighter than
1121    // the samples are apart. Kept wrongly, a curve costs a section the trim
1122    // then cuts to nothing; dropped wrongly, the faces never split.
1123    const SPANS: u32 = 64;
1124    let points: Vec<Option<ogeom_math::Point2>> = (0..=SPANS)
1125        .map(|i| {
1126            pcurve
1127                .point_at(lo + (hi - lo) * f64::from(i) / f64::from(SPANS), tol)
1128                .ok()
1129        })
1130        .collect();
1131    points.windows(2).any(|pair| {
1132        let (Some(p), Some(q)) = (pair[0], pair[1]) else {
1133            return false;
1134        };
1135        let pad = p.distance(q);
1136        // Periodic directions always contain; only a bounded one excludes.
1137        let u_ok =
1138            surface.is_periodic_u() || (p.x.max(q.x) + pad >= ua && p.x.min(q.x) - pad <= ub);
1139        let v_ok =
1140            surface.is_periodic_v() || (p.y.max(q.y) + pad >= va && p.y.min(q.y) - pad <= vb);
1141        u_ok && v_ok
1142    })
1143}
1144
1145/// The overlap of two intervals. `None` when they miss.
1146fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
1147    let b = b?;
1148    let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
1149    if lo >= hi {
1150        return None;
1151    }
1152    Some((lo, hi))
1153}
1154
1155/// The general path: seed, trace, fit.
1156fn marched(
1157    a: &SurfaceGeometry,
1158    b: &SurfaceGeometry,
1159    options: IntersectOptions,
1160    tol: Tolerances,
1161) -> OgeomResult<SurfaceIntersection> {
1162    let traced = branches(a, b, options.marching, tol)?;
1163    if traced.is_empty() {
1164        return Ok(SurfaceIntersection::Apart);
1165    }
1166    let mut out = Vec::with_capacity(traced.len());
1167    let mut contacts: Vec<crate::march::Traced> = Vec::new();
1168    for branch in &traced {
1169        // A branch along which the two surfaces share their normal is a
1170        // tangency, not a crossing: the marcher's seeding cannot tell the
1171        // noise floor of a tangential valley from a genuine sign change, and
1172        // what it traces there is a stalled fragment of the valley, not a
1173        // section. The valley is still a curve, though, and the tangential
1174        // walker is the one that can follow it, so the fragment becomes a
1175        // seed rather than a discard, and what comes back is marked as
1176        // contact so nobody classifies by it.
1177        if branch_is_tangential(a, b, branch, tol)? {
1178            if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
1179                contacts.push(contact);
1180            }
1181            continue;
1182        }
1183        if branch.stopped == crate::march::Stopped::RanOut {
1184            ogeom_bail!(
1185                NotDone,
1186                "a marched section ran out of its point budget before \
1187                 finishing; the seam is longer than the chord affords and \
1188                 fitting the truncation would state a curve that is not there"
1189            );
1190        }
1191        // A fit past its budget is still honest data: the error it reached
1192        // is carried on the record and every consumer widens by it: an
1193        // imported part's ragged pair can trace branches nothing fits, and
1194        // those sections fall outside every trim downstream. Only a trace
1195        // cut off by the point budget, refused above, states a curve that
1196        // is not there. (A boolean marching an *exact* pair whose image has
1197        // no closed form holds its own marched sections to a budget, in
1198        // its own fallback, where a miss is a miss.)
1199        for fitted in fitted_in_pieces(a, b, branch, options.tolerance, tol)? {
1200            out.push(SectionCurve {
1201                curve: fitted.curve.into(),
1202                on_a: Some(fitted.on_a.into()),
1203                on_b: Some(fitted.on_b.into()),
1204                // The sum of the stated parts: the trace is within its chord of
1205                // the truth, the fit within its error of the trace.
1206                tolerance: options.marching.chord + fitted.fit_error,
1207                exact: false,
1208                closed: fitted.closed,
1209                tangential: false,
1210            });
1211        }
1212    }
1213    for contact in &contacts {
1214        let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
1215        out.push(SectionCurve {
1216            curve: fitted.curve.into(),
1217            on_a: Some(fitted.on_a.into()),
1218            on_b: Some(fitted.on_b.into()),
1219            tolerance: options.marching.chord + fitted.fit_error,
1220            exact: false,
1221            closed: fitted.closed,
1222            tangential: true,
1223        });
1224    }
1225    if out.is_empty() {
1226        return Ok(SurfaceIntersection::Apart);
1227    }
1228    Ok(SurfaceIntersection::Along(out))
1229}
1230
1231/// A traced branch fitted, in pieces where whole it will not fit.
1232///
1233/// A trace winding several turns round a drum (a thread's flank meeting a
1234/// bore) is long and turns the same way throughout, and one fit of it can
1235/// run out of room and come back with an error of the drum's size. An open
1236/// branch whose fit strays farther from the trace than the trace's own
1237/// step, and so is no longer the curve traced, is split at its middle
1238/// sample and each half fitted the same way, down to a floor of samples
1239/// and depth; the pieces meet at the shared sample. A fit that misses its
1240/// tolerance by less stands whole, its error stated: a caller takes one
1241/// curve per branch where it can, and a few microns do not warrant more.
1242/// So does a closed branch, or one no split helps.
1243fn fitted_in_pieces(
1244    a: &SurfaceGeometry,
1245    b: &SurfaceGeometry,
1246    branch: &crate::march::Traced,
1247    tolerance: f64,
1248    tol: Tolerances,
1249) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
1250    const DEPTH: u32 = 6;
1251    const FLOOR: usize = 16;
1252    fn go(
1253        a: &SurfaceGeometry,
1254        b: &SurfaceGeometry,
1255        branch: &crate::march::Traced,
1256        tolerance: f64,
1257        depth: u32,
1258        tol: Tolerances,
1259    ) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
1260        let whole = approximate_branch(a, b, branch, tolerance, tol)?;
1261        let step = branch
1262            .points
1263            .windows(2)
1264            .map(|w| w[0].distance(w[1]))
1265            .fold(0.0_f64, f64::max);
1266        if whole.met
1267            || whole.fit_error <= step
1268            || branch.closed()
1269            || depth == 0
1270            || branch.points.len() < 2 * FLOOR
1271        {
1272            return Ok(vec![whole]);
1273        }
1274        let middle = branch.points.len() / 2;
1275        let half = |range: core::ops::RangeInclusive<usize>| crate::march::Traced {
1276            points: branch.points[range.clone()].to_vec(),
1277            on_a: branch.on_a[range.clone()].to_vec(),
1278            on_b: branch.on_b[range].to_vec(),
1279            stopped: branch.stopped,
1280        };
1281        let mut pieces = go(a, b, &half(0..=middle), tolerance, depth - 1, tol)?;
1282        pieces.extend(go(
1283            a,
1284            b,
1285            &half(middle..=branch.points.len() - 1),
1286            tolerance,
1287            depth - 1,
1288            tol,
1289        )?);
1290        // Worse in pieces than whole (a trace that is noise, not length):
1291        // the whole stands.
1292        let worst = pieces.iter().map(|p| p.fit_error).fold(0.0_f64, f64::max);
1293        Ok(if worst < whole.fit_error {
1294            pieces
1295        } else {
1296            vec![whole]
1297        })
1298    }
1299    go(a, b, branch, tolerance, DEPTH, tol)
1300}
1301
1302/// Follow the contact a tangential fragment sits on, unless one already
1303/// traced covers it.
1304///
1305/// A tangential valley hands the crossing marcher several stalled fragments
1306/// (the seeds converge onto the contact from wherever they started and
1307/// wander there), so the fragments are candidates for *one* curve, not
1308/// several. A fragment whose middle already lies on a traced contact is one
1309/// of those repeats.
1310fn walk_contact(
1311    a: &SurfaceGeometry,
1312    b: &SurfaceGeometry,
1313    fragment: &crate::march::Traced,
1314    already: &[crate::march::Traced],
1315    marching: Marching,
1316    tol: Tolerances,
1317) -> OgeomResult<Option<crate::march::Traced>> {
1318    let middle = fragment.points.len() / 2;
1319    let Some(point) = fragment.points.get(middle).copied() else {
1320        return Ok(None);
1321    };
1322    for traced in already {
1323        // Traced points sit a step apart, so "on this curve" has to allow
1324        // half a step of gap to the nearest sample plus the chord budget.
1325        let spacing = traced
1326            .points
1327            .windows(2)
1328            .map(|w| w[0].distance(w[1]))
1329            .fold(0.0f64, f64::max);
1330        let near = traced
1331            .points
1332            .iter()
1333            .map(|p| p.distance(point))
1334            .fold(f64::INFINITY, f64::min);
1335        if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
1336            return Ok(None);
1337        }
1338    }
1339    let seed = crate::march::Contact {
1340        point,
1341        on_a: fragment.on_a[middle],
1342        on_b: fragment.on_b[middle],
1343    };
1344    // The walker refuses a seed that is not a contact; that refusal is an
1345    // answer, not a failure: the fragment simply had nothing to follow.
1346    // A walk that stalls where it started says the same thing in points:
1347    // too few to fit, so there is no contact curve to report here.
1348    Ok(trace_tangential(a, b, seed, marching, tol)
1349        .ok()
1350        .filter(|traced| traced.points.len() >= 4))
1351}
1352
1353/// Whether a traced branch runs along a tangency of the two surfaces:
1354/// their normals parallel, sampled along its length.
1355fn branch_is_tangential(
1356    a: &SurfaceGeometry,
1357    b: &SurfaceGeometry,
1358    branch: &crate::march::Traced,
1359    tol: Tolerances,
1360) -> OgeomResult<bool> {
1361    use ogeom_geom::Surface as _;
1362    let count = branch.points.len();
1363    if count == 0 {
1364        return Ok(true);
1365    }
1366    for k in 0..5 {
1367        let i = (k * (count - 1)) / 4;
1368        let (ua, va) = branch.on_a[i.min(count - 1)];
1369        let (ub, vb) = branch.on_b[i.min(count - 1)];
1370        let (dau, dav) = a.d1_at(ua, va, tol)?;
1371        let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
1372        let na = dau.cross(dav);
1373        let nb = dbu.cross(dbv);
1374        let (ma, mb) = (na.magnitude(), nb.magnitude());
1375        if ma <= tol.confusion() || mb <= tol.confusion() {
1376            continue;
1377        }
1378        // The threshold carries the fitted world: a blend surface within a
1379        // fit tolerance of true tangency crosses its host at an angle that
1380        // grows as the square root of that tolerance, and calling such a
1381        // graze transversal splits faces along slivers no classifier can
1382        // hold. Genuinely transversal analytic pairs meeting under two
1383        // degrees are the pathology, not the rule.
1384        if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
1385            return Ok(false);
1386        }
1387    }
1388    Ok(true)
1389}
1390
1391/// The exact pcurve of a curve lying on a surface, where the projection has
1392/// a closed form; `None` where it does not.
1393///
1394/// Public because the boolean's same-domain handling needs it: two faces on
1395/// one geometric surface may still carry different charts, and the other
1396/// face's boundary edges have to be spoken in this face's parameters before
1397/// they can split it.
1398#[must_use]
1399pub fn exact_pcurve_of(
1400    curve: &Curve,
1401    surface: &SurfaceGeometry,
1402    tol: Tolerances,
1403) -> Option<PlanarCurve> {
1404    exact_pcurve(curve, curve.domain(), surface, tol)
1405}
1406
1407/// As [`exact_pcurve_of`], with the parameter range the caller actually
1408/// uses.
1409///
1410/// A curve's chart image can depend on *which part* of the curve is meant: a
1411/// ruling on a cone crosses the apex, and its angle on the far nappe is half
1412/// a turn from its angle on the near one. The curve's own domain may span
1413/// both (an imported line's usually does), so a caller that knows its edge's
1414/// range must say so, or the exact projection may answer for the wrong side.
1415#[must_use]
1416pub fn exact_pcurve_over(
1417    curve: &Curve,
1418    range: (f64, f64),
1419    surface: &SurfaceGeometry,
1420    tol: Tolerances,
1421) -> Option<PlanarCurve> {
1422    exact_pcurve(curve, range, surface, tol)
1423}
1424
1425/// The exact pcurve of an analytic curve on an analytic surface, where the
1426/// projection has a closed form.
1427///
1428/// Same-parameter by construction: each 2D curve inherits the 3D curve's own
1429/// parameterization, so the two evaluate to the same point of the intersection
1430/// at the same `t`. The cases are the ones where that inheritance is exact;
1431/// anything else returns `None` rather than a fit, because an *exact* result
1432/// with a fitted pcurve would be a curve whose descriptions disagree by an
1433/// amount nothing on it records.
1434fn exact_pcurve(
1435    curve: &Curve,
1436    range: (f64, f64),
1437    surface: &SurfaceGeometry,
1438    tol: Tolerances,
1439) -> Option<PlanarCurve> {
1440    // A trim is a statement about *where* on a curve, not about what it is:
1441    // the basis carries the shape and the trim shares its parameter, so the
1442    // pcurve is the basis's own pcurve trimmed the same way. Answered here
1443    // rather than in every surface's own case, because the answer does not
1444    // depend on the surface at all. A *reversed* trim renumbers, and is left
1445    // alone rather than mis-read.
1446    if let Curve::Trimmed(trimmed) = curve
1447        && !trimmed.is_reversed()
1448    {
1449        let window = ogeom_geom::Curve3d::domain(&**trimmed);
1450        let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
1451        return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
1452            .ok()
1453            .map(Into::into);
1454    }
1455    match surface {
1456        SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
1457        SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
1458        SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
1459        SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
1460        SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
1461        _ => None,
1462    }
1463}
1464
1465/// The pcurve of a curve on a cone, for the two straight-line families.
1466///
1467/// A ruling (through the apex, on the surface) runs at constant `u`; a
1468/// circle perpendicular to the axis, centred on it, with the radius the cone
1469/// has at that height, runs at constant `v`. Both inherit the 3D curve's own
1470/// parameter, the circle with phase and winding exactly as the cylinder case.
1471/// The ruling's angle is measured over `range`, because the same line has
1472/// the opposite angle on the other side of the apex.
1473fn on_cone(
1474    curve: &Curve,
1475    range: (f64, f64),
1476    cone: ogeom_math::Cone,
1477    tol: Tolerances,
1478) -> Option<PlanarCurve> {
1479    let frame = cone.frame();
1480    let axis_z = frame.z().vector();
1481    let tau = core::f64::consts::TAU;
1482    match curve {
1483        Curve::Circle(c) => {
1484            let circle = c.circle();
1485            if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1486                return None;
1487            }
1488            let local = frame.to_local(circle.centre());
1489            if local.x.hypot(local.y) > tol.confusion() {
1490                return None;
1491            }
1492            // The cone's radius at the circle's height must be the circle's,
1493            // or (past the apex, on the far nappe, where the radius runs
1494            // negative) its negative: the same parallel half a turn round.
1495            let expected = cone
1496                .half_angle()
1497                .tan()
1498                .mul_add(local.z, cone.reference_radius());
1499            let turned = if (expected - circle.radius()).abs() <= tol.confusion() * 10.0 {
1500                0.0
1501            } else if (expected + circle.radius()).abs() <= tol.confusion() * 10.0 {
1502                core::f64::consts::PI
1503            } else {
1504                return None;
1505            };
1506            let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1507            let at = frame.to_local(start);
1508            let phase = at.y.atan2(at.x) + turned;
1509            let winding = circle.frame().z().vector().dot(axis_z).signum();
1510            let towards =
1511                ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1512            Some(
1513                Line2d::over(
1514                    ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1515                    0.0,
1516                    tau,
1517                )
1518                .ok()?
1519                .into(),
1520            )
1521        }
1522        Curve::Line(line) => {
1523            // A ruling: verified by sample, not assumed: three points on
1524            // the surface pin a line to it.
1525            let axis = line.axis();
1526            let on = |t: f64| {
1527                let p = axis.location + axis.direction.vector() * t;
1528                cone.distance_to(p) <= tol.confusion() * 10.0
1529            };
1530            if !on(0.0) || !on(1.0) || !on(-1.0) {
1531                return None;
1532            }
1533            // A ruling reaching the tip may be *stated* from the apex
1534            // itself (where the angle is atan2(0, 0), garbage) and its
1535            // own domain usually spans both nappes, where the angles differ
1536            // by half a turn. Measure the angle at whichever end of the
1537            // *used* range stands farthest from the axis: that is the side
1538            // the caller means.
1539            let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
1540                range
1541            } else {
1542                line.domain()
1543            };
1544            // Only the used range votes. The line's own origin is stated
1545            // wherever the file likes (some writers park it hundreds of
1546            // kilometres down the infinite line, past the apex on the other
1547            // nappe), and letting it compete reads the angle half a turn
1548            // from the side the edge actually uses.
1549            let mut local: Option<ogeom_math::Point> = None;
1550            for t in [lo, hi] {
1551                if !t.is_finite() {
1552                    continue;
1553                }
1554                let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
1555                if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
1556                    local = Some(candidate);
1557                }
1558            }
1559            let local = local?;
1560            if local.x.hypot(local.y) <= tol.confusion() {
1561                return None;
1562            }
1563            let u = local.y.atan2(local.x).rem_euclid(tau);
1564            // Same-parameter exactly: a degree-one spline over the used
1565            // range maps t linearly onto the chart column, whatever rate
1566            // the slant climbs at.
1567            let v_at = |t: f64| {
1568                frame
1569                    .to_local(axis.location + axis.direction.vector() * t)
1570                    .z
1571            };
1572            let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
1573            Some(
1574                ogeom_geom::BSpline2d::new(
1575                    knots,
1576                    vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
1577                    tol,
1578                )
1579                .ok()?
1580                .into(),
1581            )
1582        }
1583        _ => None,
1584    }
1585}
1586
1587/// The pcurve of a circle on a torus, for the two families that are straight
1588/// lines in `(u, v)`.
1589///
1590/// A *parallel* (centred on the axis, in a plane perpendicular to it) runs
1591/// at constant `v`; a *tube circle* (minor radius, centred on the tube's
1592/// spine, in a plane through the axis) runs at constant `u`. Both inherit
1593/// the circle's own angle, phase and winding included, exactly as the
1594/// cylinder case does. Fillet faces are tori more often than not, so the
1595/// STEP reader is the chief consumer.
1596fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
1597    let Curve::Circle(c) = curve else {
1598        return None;
1599    };
1600    let circle = c.circle();
1601    let frame = torus.frame();
1602    let axis_z = frame.z().vector();
1603    let normal = circle.frame().z().vector();
1604    let local = frame.to_local(circle.centre());
1605    let tau = core::f64::consts::TAU;
1606
1607    // A parallel of the sweep.
1608    if normal.cross(axis_z).magnitude() <= tol.angular()
1609        && local.x.hypot(local.y) <= tol.confusion()
1610    {
1611        let sin_v = local.z / torus.minor_radius();
1612        // On its own side of the axis, or (on a spindle, whose tube swallows
1613        // the axis) on the tube's folded half past it, where the sweep's
1614        // radius runs negative: the same parallel half a turn round.
1615        let (cos_v, turned) = [
1616            (circle.radius() - torus.major_radius(), 0.0),
1617            (
1618                -circle.radius() - torus.major_radius(),
1619                core::f64::consts::PI,
1620            ),
1621        ]
1622        .into_iter()
1623        .map(|(reach, turned)| (reach / torus.minor_radius(), turned))
1624        .find(|(cos_v, _)| (sin_v.hypot(*cos_v) - 1.0).abs() <= tol.confusion())?;
1625        let v = sin_v.atan2(cos_v);
1626        let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1627        let at = frame.to_local(start);
1628        let phase = at.y.atan2(at.x) + turned;
1629        let winding = normal.dot(axis_z).signum();
1630        let towards =
1631            ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1632        return Some(
1633            Line2d::over(
1634                ogeom_math::Axis2::new(Point2::new(phase, v), towards),
1635                0.0,
1636                tau,
1637            )
1638            .ok()?
1639            .into(),
1640        );
1641    }
1642
1643    // A circle of the tube.
1644    if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
1645        && normal.dot(axis_z).abs() <= tol.angular()
1646        && (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
1647        && local.z.abs() <= tol.confusion()
1648    {
1649        let u = local.y.atan2(local.x);
1650        let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
1651        let xc = circle.frame().x().vector();
1652        let phase = xc.dot(axis_z).atan2(xc.dot(radial));
1653        let winding = normal.dot(radial.cross(axis_z)).signum();
1654        let towards =
1655            ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
1656        return Some(
1657            Line2d::over(
1658                ogeom_math::Axis2::new(Point2::new(u, phase), towards),
1659                0.0,
1660                tau,
1661            )
1662            .ok()?
1663            .into(),
1664        );
1665    }
1666    None
1667}
1668
1669/// Project a curve lying in a plane into the plane's own coordinates.
1670///
1671/// Exact for a line, a circle and an ellipse: the plane's frame is orthonormal,
1672/// so lengths and the curves' own parameterizations survive the projection
1673/// unchanged.
1674fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
1675    let frame = plane.frame();
1676    let flat = |p: Point| {
1677        let local = frame.to_local(p);
1678        Point2::new(local.x, local.y)
1679    };
1680    let flat_direction = |d: ogeom_math::Direction| {
1681        let tip = flat(frame.origin() + d.vector());
1682        ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
1683    };
1684    match curve {
1685        Curve::Line(line) => {
1686            let axis = line.axis();
1687            let through = flat(axis.location);
1688            let direction = flat_direction(axis.direction)?;
1689            let (lo, hi) = line.domain();
1690            Some(
1691                Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
1692                    .ok()?
1693                    .into(),
1694            )
1695        }
1696        Curve::Circle(c) => {
1697            let circle = c.circle();
1698            let frame2 = Frame2::from_axes(
1699                flat(circle.centre()),
1700                flat_direction(circle.frame().x())?,
1701                flat_direction(circle.frame().y())?,
1702                tol,
1703            )
1704            .ok()?;
1705            Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
1706        }
1707        Curve::Ellipse(e) => {
1708            let ellipse = e.ellipse();
1709            let frame2 = Frame2::from_axes(
1710                flat(ellipse.centre()),
1711                flat_direction(ellipse.frame().x())?,
1712                flat_direction(ellipse.frame().y())?,
1713                tol,
1714            )
1715            .ok()?;
1716            Some(
1717                Ellipse2d::new(
1718                    Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
1719                        .ok()?,
1720                )
1721                .into(),
1722            )
1723        }
1724        Curve::BSpline(b) => {
1725            // Affine invariance: a (rational) B-spline in the plane projects
1726            // into the plane's own coordinates control point by control
1727            // point, knots and weights untouched: exact, and same-parameter
1728            // by construction.
1729            let control = b
1730                .control_points()
1731                .iter()
1732                .map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
1733                .collect::<Result<Vec<_>, _>>()
1734                .ok()?;
1735            Some(
1736                ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
1737                    .ok()?
1738                    .into(),
1739            )
1740        }
1741        _ => None,
1742    }
1743}
1744
1745/// The pcurve of a curve on a cylinder, where it is a straight line in
1746/// parameter space.
1747///
1748/// A line along the axis runs at constant `u`; a full circle around it runs at
1749/// constant `v`. Both are lines in `(u, v)`, exactly, and both inherit the 3D
1750/// curve's own parameter: height for the line, angle for the circle.
1751fn on_cylinder(
1752    curve: &Curve,
1753    range: (f64, f64),
1754    cylinder: ogeom_math::Cylinder,
1755    tol: Tolerances,
1756) -> Option<PlanarCurve> {
1757    let axis = cylinder.axis();
1758    let frame = cylinder.frame();
1759    match curve {
1760        Curve::Line(line) => {
1761            // Parallel to the axis, on the surface.
1762            let direction = line.axis().direction;
1763            let along = direction.dot(axis.direction);
1764            if !direction.is_parallel(axis.direction, tol) {
1765                return None;
1766            }
1767            let through = line.axis().location;
1768            if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
1769                return None;
1770            }
1771            let local = frame.to_local(through);
1772            let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
1773            // The 3D line's parameter is length from its origin; at constant u
1774            // the pcurve's `v` runs at the same rate, signed by whether the
1775            // line runs with the axis or against it.
1776            let (lo, hi) = line.domain();
1777            let start = Point2::new(u, local.z);
1778            let towards =
1779                ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
1780                    .ok()?;
1781            Some(
1782                Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
1783                    .ok()?
1784                    .into(),
1785            )
1786        }
1787        Curve::Circle(c) => {
1788            let circle = c.circle();
1789            // Perpendicular to the axis, centred on it, of the same radius.
1790            if circle
1791                .frame()
1792                .z()
1793                .cross_with(axis.direction.vector())
1794                .magnitude()
1795                > tol.angular()
1796            {
1797                return None;
1798            }
1799            if axis.distance_to(circle.centre()) > tol.confusion() {
1800                return None;
1801            }
1802            if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
1803                return None;
1804            }
1805            let local = frame.to_local(circle.centre());
1806            // Where the circle's own angle zero sits in the cylinder's angle,
1807            // and which way its parameter runs around the axis. A section
1808            // circle inherits its winding from the pair that made it, and one
1809            // wound against the cylinder's `u` (a circle cut by a plane whose
1810            // normal opposes the axis) runs its pcurve in `-u`. Written `+u`
1811            // unconditionally, the pcurve evaluates half a turn away from the
1812            // curve, and the face's arrangement tears along a seam that is
1813            // not there.
1814            let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1815            let at = frame.to_local(start);
1816            let phase = at.y.atan2(at.x);
1817            let winding = circle.frame().z().dot(axis.direction).signum();
1818            let towards =
1819                ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1820            Some(
1821                Line2d::over(
1822                    ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1823                    0.0,
1824                    core::f64::consts::TAU,
1825                )
1826                .ok()?
1827                .into(),
1828            )
1829        }
1830        Curve::Ellipse(_) => {
1831            // An oblique plane's section: its plan projection is the
1832            // cylinder's own cross-section circle traced *uniformly*, so
1833            // the chart trace is u = sยทt + ฯ†, v = cโ‚€ + aยทcos t + bยทsin t:
1834            // the trig-affine family. Derived from the curve's own
1835            // evaluations and verified by sample, never assumed.
1836            use ogeom_geom::Curve3d as _;
1837            let tau = core::f64::consts::TAU;
1838            let local = |t: f64| -> Option<ogeom_math::Point> {
1839                Some(frame.to_local(curve.point_at(t, tol).ok()?))
1840            };
1841            let l0 = local(0.0)?;
1842            let lq = local(tau / 4.0)?;
1843            let lh = local(tau / 2.0)?;
1844            // On the surface at all: plan radius must be the cylinder's.
1845            let r = cylinder.radius();
1846            for l in [&l0, &lq, &lh] {
1847                if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
1848                    return None;
1849                }
1850            }
1851            let phase = l0.y.atan2(l0.x);
1852            // Winding from the quarter-turn sample: uniform tracing puts it
1853            // a quarter turn away, one side or the other.
1854            let uq = lq.y.atan2(lq.x);
1855            let step = (uq - phase).rem_euclid(tau);
1856            let winding = if (step - tau / 4.0).abs() < 1e-6 {
1857                1.0
1858            } else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
1859                -1.0
1860            } else {
1861                return None;
1862            };
1863            // Height coefficients from three samples.
1864            let c0 = f64::midpoint(l0.z, lh.z);
1865            let a = (l0.z - lh.z) / 2.0;
1866            let b = lq.z - c0;
1867            // The trig formula is global (cosine wraps, the linear angle
1868            // unwraps the chart), so the pcurve lives on whatever range the
1869            // edge actually spans, a loop crossing the period included.
1870            let candidate = ogeom_geom::Trig2d::new(
1871                Point2::new(phase, c0),
1872                ogeom_math::Vector2::new(winding, 0.0),
1873                ogeom_math::Vector2::new(0.0, a),
1874                ogeom_math::Vector2::new(0.0, b),
1875                range,
1876            )
1877            .ok()?;
1878            // The same-parameter law, verified at points the derivation
1879            // never touched, inside the range the edge will use.
1880            use ogeom_geom::Curve2d as _;
1881            for i in 0..7 {
1882                let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
1883                let l = local(t)?;
1884                let chart = candidate.point_at(t, tol).ok()?;
1885                let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
1886                if du.min(tau - du) > 1e-9 {
1887                    return None;
1888                }
1889                if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
1890                    return None;
1891                }
1892            }
1893            Some(PlanarCurve::Trig(candidate))
1894        }
1895        _ => None,
1896    }
1897}
1898
1899/// The pcurve of half a meridian: a great circle through both poles,
1900/// restricted to one side of them.
1901///
1902/// The whole circle has no chart image a single curve can carry (its
1903/// longitude jumps by half a turn at each pole), but each *half* does, and it
1904/// is a straight line. Writing the circle's own parameter as `t` and the
1905/// sphere's axis as `Z = cos ฮฑยทX + sin ฮฑยทY` in the circle's own frame, the
1906/// point's height above the equator is `rยทcos(t โˆ’ ฮฑ)`, so the latitude is
1907/// `asin(cos(t โˆ’ ฮฑ))`, which on `t โˆ’ ฮฑ โˆˆ [0, ฯ€]` is exactly `ฯ€/2 โˆ’ (t โˆ’ ฮฑ)`,
1908/// affine in `t`, with slope one. The longitude is constant on that half and
1909/// half a turn away on the other. So the pcurve is a vertical line in the
1910/// chart, sharing the circle's parameter exactly, and the caller's `range` is
1911/// what says which half is meant.
1912///
1913/// The half is not assumed: the returned line is lifted back through the
1914/// sphere at stations along the range and compared against the circle, so a
1915/// misread orientation is caught here rather than downstream.
1916fn on_meridian(
1917    curve: &ogeom_geom::CircleCurve,
1918    range: (f64, f64),
1919    sphere: ogeom_math::Sphere,
1920    tol: Tolerances,
1921) -> Option<PlanarCurve> {
1922    let circle = curve.circle();
1923    // A reversed circle runs its own angle backwards, and the shifted angle
1924    // below is measured in the *curve's* parameter, so the sign travels with
1925    // it: the sweep flips and so do both the latitude's slope and which half
1926    // of the circle a range names.
1927    let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
1928    let frame = sphere.frame();
1929    let z = frame.z().vector();
1930    // A great circle: the sphere's own centre and radius, in a plane holding
1931    // the axis. Anything else is not a meridian.
1932    if circle.centre().distance(sphere.centre()) > tol.confusion() {
1933        return None;
1934    }
1935    if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
1936        return None;
1937    }
1938    let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
1939    let (xz, yz) = (cx.dot(z), cy.dot(z));
1940    // The axis must lie *in* the circle's plane, or the circle is neither a
1941    // parallel nor a meridian and has no closed-form chart image at all.
1942    if xz.hypot(yz) < 1.0 - tol.angular() {
1943        return None;
1944    }
1945    let raw_alpha = yz.atan2(xz);
1946    // `w` is the circle's own horizontal direction: the axis turned a quarter
1947    // turn within the circle's plane.
1948    let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
1949    let local = frame.to_local(sphere.centre() + w);
1950    let longitude = local.y.atan2(local.x);
1951
1952    let half = core::f64::consts::PI;
1953    let mid = f64::midpoint(range.0, range.1);
1954    // Where the range sits relative to the poles, in the shifted angle
1955    // `x = sweepยทt โˆ’ ฮฑ` that measures the descent from the north pole.
1956    let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
1957    let x_mid = if x_mid > half {
1958        x_mid - core::f64::consts::TAU
1959    } else {
1960        x_mid
1961    };
1962    let span = sweep * (range.1 - range.0);
1963    let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
1964    if x0 > x1 {
1965        core::mem::swap(&mut x0, &mut x1);
1966    }
1967    // The turn count `ฮฑ` was written with is what decides whether the
1968    // latitude comes out inside the chart or a whole turn away from it, so
1969    // the branch the range actually sits on is the one the line is built
1970    // from.
1971    let alpha = sweep.mul_add(mid, -x_mid);
1972    let slack = tol.parametric().max(1e-9);
1973    let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
1974        // The descending half: latitude ฯ€/2 โˆ’ (sweepยทt โˆ’ ฮฑ), longitude
1975        // constant.
1976        (
1977            Point2::new(longitude, half.mul_add(0.5, alpha)),
1978            ogeom_math::Vector2::new(0.0, -sweep),
1979        )
1980    } else if x0 >= -half - slack && x1 <= slack {
1981        // The ascending half, half a turn round the chart.
1982        (
1983            Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
1984            ogeom_math::Vector2::new(0.0, sweep),
1985        )
1986    } else {
1987        // The range straddles a pole: no one line covers it.
1988        return None;
1989    };
1990    let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
1991    let margin = (range.1 - range.0) * 0.25;
1992    let line: PlanarCurve = Line2d::over(
1993        ogeom_math::Axis2::new(axis_point, towards),
1994        range.0 - margin,
1995        range.1 + margin,
1996    )
1997    .ok()?
1998    .into();
1999
2000    // Measured, not assumed: the chart line lifted back through the sphere is
2001    // the circle it claims to be.
2002    for k in 0..=4 {
2003        let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
2004        let uv = line.point_at(t, tol).ok()?;
2005        let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
2006        let want = curve.point_at(t, tol).ok()?;
2007        if lifted.distance(want) > tol.confusion() {
2008            return None;
2009        }
2010    }
2011    Some(line)
2012}
2013
2014/// The pcurve of a circle on a sphere: a parallel of latitude, or one half of
2015/// a meridian.
2016fn on_sphere(
2017    curve: &Curve,
2018    range: (f64, f64),
2019    sphere: ogeom_math::Sphere,
2020    tol: Tolerances,
2021) -> Option<PlanarCurve> {
2022    let Curve::Circle(c) = curve else {
2023        return None;
2024    };
2025    let circle = c.circle();
2026    let frame = sphere.frame();
2027    // Perpendicular to the sphere's axis and centred on it: a parallel of
2028    // latitude, which is a horizontal line in (longitude, latitude).
2029    if circle
2030        .frame()
2031        .z()
2032        .cross_with(frame.z().vector())
2033        .magnitude()
2034        > tol.angular()
2035    {
2036        return on_meridian(c, range, sphere, tol);
2037    }
2038    let local = frame.to_local(circle.centre());
2039    if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
2040        return None;
2041    }
2042    let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
2043    // Sanity: the circle's radius must be the parallel's.
2044    if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
2045        return None;
2046    }
2047    let start = circle.centre() + circle.frame().x().vector() * circle.radius();
2048    let at = frame.to_local(start);
2049    let phase = at.y.atan2(at.x);
2050    // Phase and winding exactly as the cylinder case: a parallel whose own
2051    // axis opposes the sphere's marches its angle *down* the longitude.
2052    let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
2053    let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
2054    Some(
2055        Line2d::over(
2056            ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
2057            0.0,
2058            core::f64::consts::TAU,
2059        )
2060        .ok()?
2061        .into(),
2062    )
2063}
2064
2065#[cfg(test)]
2066#[allow(clippy::unwrap_used, clippy::expect_used)]
2067mod tests {
2068    use super::*;
2069    use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
2070    use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
2071
2072    const T: Tolerances = Tolerances::millimetres();
2073
2074    fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
2075        SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
2076    }
2077
2078    fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
2079        let frame = Frame::new(
2080            Point::ORIGIN,
2081            Direction::new(axis, T).unwrap(),
2082            Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
2083            T,
2084        )
2085        .unwrap();
2086        CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
2087            .unwrap()
2088            .into()
2089    }
2090
2091    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
2092        PlaneSurface::over(
2093            Plane::through(origin, Direction::new(normal, T).unwrap()),
2094            (-6.0, 6.0),
2095            (-6.0, 6.0),
2096        )
2097        .unwrap()
2098        .into()
2099    }
2100
2101    /// Same-parameter: pcurve lifted through its surface equals the 3D curve,
2102    /// at the same parameter, everywhere sampled.
2103    fn assert_same_parameter(
2104        section: &SectionCurve,
2105        surface: &SurfaceGeometry,
2106        pcurve: &PlanarCurve,
2107        samples: usize,
2108    ) {
2109        let (lo, hi) = section.curve.domain();
2110        let (plo, phi) = pcurve.domain();
2111        assert!(
2112            (lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
2113            "domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
2114        );
2115        for i in 0..=samples {
2116            #[allow(clippy::cast_precision_loss)]
2117            let t = lo + (hi - lo) * i as f64 / samples as f64;
2118            let on_curve = section.curve.point_at(t, T).unwrap();
2119            let at = pcurve.point_at(t, T).unwrap();
2120            let lifted = surface.point_at(at.x, at.y, T).unwrap();
2121            assert!(
2122                on_curve.is_equal(lifted, T),
2123                "at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
2124            );
2125        }
2126    }
2127
2128    #[test]
2129    fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
2130        // A plane through a cylinder's axis: two lines, and every description
2131        // agrees at the same parameter, which is the claim edges carry and
2132        // booleans rely on.
2133        let drum = cylinder(Vector::Z, 2.0);
2134        let cut = plane(Point::ORIGIN, Vector::X);
2135        let SurfaceIntersection::Along(curves) =
2136            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2137        else {
2138            panic!("a plane through a cylinder meets it along curves");
2139        };
2140        assert_eq!(curves.len(), 2);
2141        for section in &curves {
2142            assert!(section.exact);
2143            assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
2144            let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
2145            let on_b = section.on_b.as_ref().expect("and a plane pcurve");
2146            assert_same_parameter(section, &drum, on_a, 50);
2147            assert_same_parameter(section, &cut, on_b, 50);
2148        }
2149    }
2150
2151    #[test]
2152    fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
2153        // The oblique ellipse's pcurve runs linearly in the chart angle and
2154        // sinusoidally in height (the trig-affine family), exactly,
2155        // same-parameter, both sides.
2156        let drum = cylinder(Vector::Z, 2.0);
2157        let angle: f64 = 0.5;
2158        let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
2159        let SurfaceIntersection::Along(curves) =
2160            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2161        else {
2162            panic!("an oblique plane meets the cylinder along its ellipse");
2163        };
2164        assert_eq!(curves.len(), 1);
2165        let section = &curves[0];
2166        assert!(section.exact);
2167        assert!(matches!(section.curve, Curve::Ellipse(_)));
2168        let on_drum = section
2169            .on_a
2170            .as_ref()
2171            .expect("the oblique ellipse now carries its cylinder pcurve");
2172        assert!(
2173            matches!(on_drum, PlanarCurve::Trig(_)),
2174            "the chart trace is trig-affine: {on_drum:?}"
2175        );
2176        assert_same_parameter(section, &drum, on_drum, 60);
2177        let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
2178        assert_same_parameter(section, &cut, on_plane, 60);
2179    }
2180
2181    #[test]
2182    fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
2183        let drum = cylinder(Vector::Z, 2.0);
2184        let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
2185        let SurfaceIntersection::Along(curves) =
2186            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2187        else {
2188            panic!("expected curves");
2189        };
2190        assert_eq!(curves.len(), 1);
2191        let section = &curves[0];
2192        assert!(section.closed);
2193        assert!(matches!(section.curve, Curve::Circle(_)));
2194        // On the cylinder the circle is a horizontal line in (u, v).
2195        assert!(matches!(
2196            section.on_a.as_ref().unwrap(),
2197            PlanarCurve::Line(_)
2198        ));
2199        assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
2200        assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
2201    }
2202
2203    #[test]
2204    fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
2205        let drum = cylinder(Vector::Z, 1.5);
2206        let ball = sphere(Point::ORIGIN, 3.0);
2207        let SurfaceIntersection::Along(curves) =
2208            intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
2209        else {
2210            panic!("expected curves");
2211        };
2212        assert_eq!(curves.len(), 2);
2213        for section in &curves {
2214            assert!(section.exact);
2215            assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
2216            assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
2217        }
2218    }
2219
2220    fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
2221        let frame = Frame::new(
2222            origin,
2223            Direction::new(axis, T).unwrap(),
2224            Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
2225            T,
2226        )
2227        .unwrap();
2228        ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
2229            .into()
2230    }
2231
2232    #[test]
2233    fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
2234        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2235        let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
2236        let SurfaceIntersection::Along(curves) =
2237            intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
2238        else {
2239            panic!("an axis-normal plane through the tube meets it along curves");
2240        };
2241        assert_eq!(curves.len(), 2);
2242        let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
2243        let mut radii: Vec<f64> = curves
2244            .iter()
2245            .map(|s| {
2246                let Curve::Circle(c) = &s.curve else {
2247                    panic!("a parallel is a circle");
2248                };
2249                c.circle().radius()
2250            })
2251            .collect();
2252        radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
2253        assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
2254        assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
2255        for section in &curves {
2256            assert!(section.exact);
2257            assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
2258            assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
2259        }
2260    }
2261
2262    #[test]
2263    fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
2264        // Tangency with length is reported as the curve it is (the way a
2265        // tangent plane reports its line on a cylinder), because the blend
2266        // machinery builds faces whose boundaries are exactly these circles,
2267        // and a Touching with no curve in it would read as a refusal upstream.
2268        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2269        let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
2270        let SurfaceIntersection::Along(curves) =
2271            intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
2272        else {
2273            panic!("the rolling plane touches along a circle, not at points");
2274        };
2275        assert_eq!(curves.len(), 1);
2276        let Curve::Circle(c) = &curves[0].curve else {
2277            panic!("the tangency is a circle");
2278        };
2279        assert!((c.circle().radius() - 2.0).abs() < 1e-12);
2280        assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
2281        assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
2282    }
2283
2284    #[test]
2285    fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
2286        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2287        let drum = cylinder(Vector::Z, 2.2);
2288        let SurfaceIntersection::Along(curves) =
2289            intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
2290        else {
2291            panic!("a coaxial cylinder through the tube meets it along curves");
2292        };
2293        assert_eq!(curves.len(), 2);
2294        for section in &curves {
2295            assert!(section.exact);
2296            let Curve::Circle(c) = &section.curve else {
2297                panic!("a parallel is a circle");
2298            };
2299            assert!((c.circle().radius() - 2.2).abs() < 1e-12);
2300            assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
2301            assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
2302        }
2303
2304        // Tangent at the tube's outer equator: one circle, with both pcurves.
2305        let grazing = cylinder(Vector::Z, 2.5);
2306        let SurfaceIntersection::Along(touch) =
2307            intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
2308        else {
2309            panic!("the grazing cylinder touches along the equator");
2310        };
2311        assert_eq!(touch.len(), 1);
2312        assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
2313        assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
2314    }
2315
2316    #[test]
2317    fn coaxial_tori_are_the_same_or_meet_in_parallels() {
2318        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2319        assert!(matches!(
2320            intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
2321            SurfaceIntersection::Same
2322        ));
2323
2324        // The same tube lifted half a radius: the profile circles cross
2325        // twice, and each crossing revolves into a parallel shared exactly.
2326        let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
2327        let SurfaceIntersection::Along(curves) =
2328            intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
2329        else {
2330            panic!("lifted coaxial tori meet along curves");
2331        };
2332        assert_eq!(curves.len(), 2);
2333        for section in &curves {
2334            assert!(section.exact);
2335            assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
2336            assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
2337        }
2338    }
2339
2340    #[test]
2341    fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
2342        // Crossed cylinders: the marched path, end to end through one call.
2343        let a = cylinder(Vector::Z, 1.0);
2344        let b = cylinder(Vector::X, 1.6);
2345        let options = IntersectOptions {
2346            tolerance: 1e-5,
2347            marching: Marching {
2348                chord: 1e-5,
2349                ..Marching::default()
2350            },
2351        };
2352        let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
2353        else {
2354            panic!("crossed cylinders meet along curves");
2355        };
2356        assert_eq!(curves.len(), 2);
2357        for section in &curves {
2358            assert!(!section.exact);
2359            assert!(section.closed);
2360            assert!(
2361                section.tolerance <= 1e-5 + 1e-4,
2362                "got {}",
2363                section.tolerance
2364            );
2365            assert!(section.on_a.is_some() && section.on_b.is_some());
2366
2367            // The fitted curve lies on both cylinders to its stated tolerance.
2368            let (lo, hi) = section.curve.domain();
2369            for i in 0..=200 {
2370                #[allow(clippy::cast_precision_loss)]
2371                let t = lo + (hi - lo) * f64::from(i) / 200.0;
2372                let p = section.curve.point_at(t, T).unwrap();
2373                let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
2374                    unreachable!()
2375                };
2376                let off = x
2377                    .cylinder()
2378                    .distance_to(p)
2379                    .abs()
2380                    .max(y.cylinder().distance_to(p).abs());
2381                assert!(
2382                    off <= section.tolerance * 2.0,
2383                    "at t = {t} the fitted curve is {off:e} off, tolerance {}",
2384                    section.tolerance
2385                );
2386            }
2387        }
2388    }
2389
2390    /// A plane all but parallel to a drum's axis meets it in an ellipse ten
2391    /// metres long, which crosses the drum's few units of height only in a
2392    /// sliver of its turn. It is still a section of the two.
2393    #[test]
2394    fn a_plane_all_but_along_the_axis_still_meets_a_short_drum() {
2395        let drum = cylinder(Vector::Z, 1.0);
2396        let wall: SurfaceGeometry = PlaneSurface::over(
2397            Plane::through(
2398                Point::new(0.0, 0.6, 0.0),
2399                Direction::new(Vector::new(0.0, 1.0, 1e-4), T).unwrap(),
2400            ),
2401            (-1e9, 1e9),
2402            (-1e9, 1e9),
2403        )
2404        .unwrap()
2405        .into();
2406        let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
2407        let SurfaceIntersection::Along(sections) = met else {
2408            panic!("the wall crosses the drum: {met:?}");
2409        };
2410        assert_eq!(sections.len(), 1);
2411        let curve = &sections[0].curve;
2412        let (lo, hi) = curve.domain();
2413        let inside = (0..=100_000).any(|k| {
2414            let p = curve
2415                .point_at(lo + (hi - lo) * f64::from(k) / 100_000.0, T)
2416                .unwrap();
2417            p.z.abs() <= 4.0
2418        });
2419        assert!(inside, "and the section runs through the drum's height");
2420    }
2421
2422    /// Every point of a section within its stated tolerance of both
2423    /// surfaces, sampled along it.
2424    fn on_both(section: &SectionCurve, a: &SurfaceGeometry, b: &SurfaceGeometry) {
2425        let (lo, hi) = section.curve.domain();
2426        for k in 0..=64 {
2427            let p = section
2428                .curve
2429                .point_at(lo + (hi - lo) * f64::from(k) / 64.0, T)
2430                .unwrap();
2431            for surface in [a, b] {
2432                let off = match surface {
2433                    SurfaceGeometry::Plane(plane) => plane.plane().signed_distance_to(p).abs(),
2434                    SurfaceGeometry::Cylinder(drum) => {
2435                        let axis = drum.cylinder().axis();
2436                        let rel = p - axis.location;
2437                        let d = axis.direction.vector();
2438                        ((rel - d * rel.dot(d)).magnitude() - drum.cylinder().radius()).abs()
2439                    }
2440                    _ => unreachable!("planes and drums only"),
2441                };
2442                assert!(
2443                    off <= section.tolerance + 1e-9,
2444                    "{p:?} is {off:e} off, stated {:e}",
2445                    section.tolerance
2446                );
2447            }
2448        }
2449    }
2450
2451    /// A plane leaning two hundred-thousandths off a drum's axis, grazing
2452    /// it: the closed form's ellipse is fifty metres long, its parameter
2453    /// too coarse for the drum's eight units of height. The two sections
2454    /// come back as curves along that height, within their stated
2455    /// tolerance of both surfaces.
2456    #[test]
2457    fn a_plane_all_but_along_a_drums_axis_meets_it_in_two_near_lines() {
2458        let drum = cylinder(Vector::Z, 1.0);
2459        let wall: SurfaceGeometry = PlaneSurface::over(
2460            Plane::through(
2461                Point::new(0.0, 0.99, 0.0),
2462                Direction::new(Vector::new(0.0, 1.0, 2e-5), T).unwrap(),
2463            ),
2464            (-1e9, 1e9),
2465            (-1e9, 1e9),
2466        )
2467        .unwrap()
2468        .into();
2469        let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
2470        let SurfaceIntersection::Along(sections) = met else {
2471            panic!("the wall crosses the drum: {met:?}");
2472        };
2473        assert_eq!(sections.len(), 2);
2474        for section in &sections {
2475            assert!(section.tolerance > 0.0 && section.tolerance <= 1e-5);
2476            on_both(section, &wall, &drum);
2477        }
2478    }
2479
2480    /// Two drums whose axes lean five hundred-thousandths apart meet in two
2481    /// curves all but straight, returned as such over the height they share
2482    /// rather than marched.
2483    #[test]
2484    fn drums_all_but_parallel_meet_in_two_near_lines() {
2485        let drill = cylinder(Vector::Z, 1.0);
2486        let frame = Frame::new(
2487            Point::new(1.5, 0.0, 0.0),
2488            Direction::new(Vector::new(5e-5, 0.0, 1.0), T).unwrap(),
2489            Direction::X,
2490            T,
2491        )
2492        .unwrap();
2493        let bore: SurfaceGeometry =
2494            CylinderSurface::new(Cylinder::new(frame, 1.0, T).unwrap(), (-3.0, 3.0))
2495                .unwrap()
2496                .into();
2497        let met = intersect_surfaces(&drill, &bore, IntersectOptions::default(), T).unwrap();
2498        let SurfaceIntersection::Along(sections) = met else {
2499            panic!("the drums cross: {met:?}");
2500        };
2501        assert_eq!(sections.len(), 2);
2502        for section in &sections {
2503            assert!(!section.exact && section.tolerance <= 1e-5);
2504            let (lo, hi) = section.curve.domain();
2505            let (p, q) = (
2506                section.curve.point_at(lo, T).unwrap(),
2507                section.curve.point_at(hi, T).unwrap(),
2508            );
2509            assert!(
2510                (p.z - q.z).abs() > 5.9,
2511                "over the shared height: {p:?} {q:?}"
2512            );
2513            on_both(section, &drill, &bore);
2514        }
2515    }
2516
2517    #[test]
2518    fn exact_lines_are_clipped_to_the_surfaces_extents() {
2519        // The analytic layer answers for the unbounded geometry; the surfaces
2520        // are finite. A section line a billion units long is not something an
2521        // edge can be built on, and one wholly outside the extents is a
2522        // phantom.
2523        let drum = cylinder(Vector::Z, 2.0);
2524        let cut = plane(Point::ORIGIN, Vector::X);
2525        let SurfaceIntersection::Along(curves) =
2526            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2527        else {
2528            panic!("expected curves");
2529        };
2530        for section in &curves {
2531            let (lo, hi) = section.curve.domain();
2532            // Bounded by the cylinder's height, not by LINE_EXTENT.
2533            assert!(
2534                hi - lo <= 8.0 + 1e-9,
2535                "the line was not clipped: [{lo}, {hi}]"
2536            );
2537            let start = section.curve.point_at(lo, T).unwrap();
2538            let end = section.curve.point_at(hi, T).unwrap();
2539            assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
2540        }
2541
2542        // A circle at a height the bounded cylinder does not reach is not an
2543        // intersection of these surfaces, however truly the unbounded ones
2544        // meet there.
2545        let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
2546        assert_eq!(
2547            intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
2548            SurfaceIntersection::Apart
2549        );
2550    }
2551
2552    #[test]
2553    fn the_degenerate_answers_pass_through() {
2554        assert_eq!(
2555            intersect_surfaces(
2556                &sphere(Point::ORIGIN, 1.0),
2557                &sphere(Point::new(5.0, 0.0, 0.0), 1.0),
2558                IntersectOptions::default(),
2559                T
2560            )
2561            .unwrap(),
2562            SurfaceIntersection::Apart
2563        );
2564        assert_eq!(
2565            intersect_surfaces(
2566                &sphere(Point::ORIGIN, 1.0),
2567                &sphere(Point::ORIGIN, 1.0),
2568                IntersectOptions::default(),
2569                T
2570            )
2571            .unwrap(),
2572            SurfaceIntersection::Same
2573        );
2574        assert!(matches!(
2575            intersect_surfaces(
2576                &plane(Point::ORIGIN, Vector::Z),
2577                &sphere(Point::new(0.0, 0.0, 2.0), 2.0),
2578                IntersectOptions::default(),
2579                T
2580            )
2581            .unwrap(),
2582            SurfaceIntersection::Touching(ref p) if p.len() == 1
2583        ));
2584    }
2585
2586    #[test]
2587    fn unusable_options_are_refused() {
2588        let a = sphere(Point::ORIGIN, 1.0);
2589        let b = plane(Point::ORIGIN, Vector::Z);
2590        for tolerance in [0.0, -1.0, f64::NAN] {
2591            let options = IntersectOptions {
2592                tolerance,
2593                ..IntersectOptions::default()
2594            };
2595            assert!(intersect_surfaces(&a, &b, options, T).is_err());
2596        }
2597    }
2598
2599    #[test]
2600    fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
2601        // A plane whose normal opposes the cylinder's axis cuts a circle
2602        // wound against the cylinder's `u`, and the pcurve must run in `-u`
2603        // with it. Written `+u` unconditionally, the pcurve evaluates half a
2604        // turn away from the curve and every face built on the section tears
2605        // in parameter space. Both windings are pinned by lifting the pcurve
2606        // through the surface and demanding the curve's own point back.
2607        let drum: SurfaceGeometry = CylinderSurface::new(
2608            Cylinder::new(
2609                Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
2610                0.5,
2611                T,
2612            )
2613            .unwrap(),
2614            (0.0, 3.0),
2615        )
2616        .unwrap()
2617        .into();
2618        for normal in [Direction::Z, -Direction::Z] {
2619            let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
2620            let ground: SurfaceGeometry =
2621                PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
2622                    .unwrap()
2623                    .into();
2624            let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
2625            let SurfaceIntersection::Along(curves) = met else {
2626                panic!("a plane through a cylinder sections it");
2627            };
2628            for sc in &curves {
2629                let pcurve = sc
2630                    .on_b
2631                    .as_ref()
2632                    .expect("a circle on its cylinder has a pcurve");
2633                let (lo, hi) = sc.curve.domain();
2634                for i in 0..8 {
2635                    let t = lo + (hi - lo) * f64::from(i) / 8.0;
2636                    let p3 = sc.curve.point_at(t, T).unwrap();
2637                    let uv = pcurve.point_at(t, T).unwrap();
2638                    let lifted = drum
2639                        .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2640                        .unwrap();
2641                    assert!(
2642                        p3.distance(lifted) < 1e-9,
2643                        "normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
2644                    );
2645                }
2646            }
2647        }
2648    }
2649
2650    /// A plane through a ball's own axis cuts a meridian. The whole circle has
2651    /// no chart image (its longitude jumps half a turn at each pole), but
2652    /// each half is a straight line in the chart, exactly, at the circle's own
2653    /// parameter. Pinned by lifting the line back through the sphere and
2654    /// demanding the circle's point, on every half of every orientation.
2655    #[test]
2656    fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
2657        use ogeom_geom::Surface as _;
2658        let half = core::f64::consts::PI;
2659        for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
2660            let ball = sphere(centre, radius);
2661            let SurfaceGeometry::Sphere(s) = &ball else {
2662                panic!("a sphere surface");
2663            };
2664            // Three planes through the axis, at different azimuths, so the
2665            // constant longitude is not accidentally zero.
2666            for azimuth in [0.0_f64, 0.7, 2.4] {
2667                let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
2668                let cut = plane(centre, normal);
2669                let SurfaceIntersection::Along(curves) =
2670                    intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
2671                else {
2672                    panic!("a plane through the centre meets the ball along a circle");
2673                };
2674                assert_eq!(curves.len(), 1, "one great circle");
2675                let circle = &curves[0].curve;
2676                assert!(curves[0].exact);
2677                // The whole circle has no chart image; each half does.
2678                assert!(
2679                    exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
2680                    "the whole meridian has no single chart image"
2681                );
2682                for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
2683                    let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
2684                        .expect("half a meridian has an exact pcurve");
2685                    assert!(
2686                        matches!(pcurve, PlanarCurve::Line(_)),
2687                        "and it is a straight line in the chart"
2688                    );
2689                    for i in 0..=16 {
2690                        let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
2691                        let want = circle.point_at(t, T).unwrap();
2692                        let uv = pcurve.point_at(t, T).unwrap();
2693                        assert!(
2694                            uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
2695                            "the latitude stays inside the chart: {}",
2696                            uv.y
2697                        );
2698                        let lifted = ball
2699                            .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2700                            .unwrap();
2701                        assert!(
2702                            want.distance(lifted) < 1e-9,
2703                            "azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
2704                        );
2705                    }
2706                }
2707                // A range straddling a pole has none, and says so rather than
2708                // answering for one side.
2709                assert!(
2710                    exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
2711                    "a range across a pole has no one line"
2712                );
2713                let _ = s;
2714            }
2715        }
2716    }
2717
2718    /// A trim says *where* on a curve, not what it is. The basis carries the
2719    /// shape and the trim shares its parameter, so a trimmed curve's pcurve is
2720    /// the basis's own pcurve trimmed the same way, on every surface, since
2721    /// the answer does not depend on the surface at all.
2722    ///
2723    /// A fillet's own end cap is a plane and the edges bounding it are trimmed
2724    /// curves. Without this, the boolean refuses the coincidence because it
2725    /// cannot put a trimmed curve into a chart it plainly lies in.
2726    #[test]
2727    fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
2728        use ogeom_geom::TrimmedCurve;
2729        let drum = cylinder(Vector::Z, 2.0);
2730        let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
2731        // The circle where they meet, and a quarter of it.
2732        let SurfaceIntersection::Along(curves) =
2733            intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
2734        else {
2735            panic!("a plane across a cylinder meets it in a circle");
2736        };
2737        let whole = curves[0].curve.clone();
2738        let (lo, hi) = whole.domain();
2739        let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
2740            .unwrap()
2741            .into();
2742
2743        for surface in [&drum, &ground] {
2744            let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
2745            let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
2746            // Same parameter, same point: the trim changed the range and
2747            // nothing else.
2748            let (a, b) = quarter.domain();
2749            for i in 0..=8 {
2750                let t = (b - a).mul_add(f64::from(i) / 8.0, a);
2751                let (whole_at, part_at) =
2752                    (full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
2753                assert!(
2754                    whole_at.distance(part_at) < 1e-12,
2755                    "the trim carries the basis: {whole_at:?} against {part_at:?}"
2756                );
2757                // And it lifts back onto the curve it came from.
2758                let lifted = surface
2759                    .point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
2760                    .or_else(|_| surface.point_at(part_at.x, part_at.y, T))
2761                    .unwrap();
2762                assert!(
2763                    lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
2764                    "same-parameter, still"
2765                );
2766            }
2767        }
2768    }
2769    /// A plane through a cone's apex: tangent, it touches along one ruling;
2770    /// steeper, it holds two; shallower, it meets the apex alone. Every
2771    /// ruling lies on both surfaces and stays within the cone's window.
2772    #[test]
2773    fn a_plane_through_a_cones_apex_holds_its_rulings() {
2774        use ogeom_geom::ConeSurface;
2775        let frame = Frame::new(
2776            Point::new(100.0, 200.0, 300.0),
2777            Direction::Z,
2778            Direction::X,
2779            T,
2780        )
2781        .unwrap();
2782        let cone = ogeom_math::Cone::new(frame, 10.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2783        let surface: SurfaceGeometry = ConeSurface::new(cone, (-5.0, 50.0)).unwrap().into();
2784        let apex = Point::new(100.0, 200.0, 290.0);
2785        let plane = |normal: Vector| -> SurfaceGeometry {
2786            PlaneSurface::new(Plane::through(apex, Direction::new(normal, T).unwrap())).into()
2787        };
2788        let cases = [
2789            (Vector::new(1.0, 0.0, -1.0), 1, true),
2790            (Vector::new(1.0, 0.0, 0.0), 2, false),
2791        ];
2792        for (normal, count, tangent) in cases {
2793            let cut = plane(normal);
2794            let SurfaceIntersection::Along(sections) =
2795                intersect_surfaces(&surface, &cut, IntersectOptions::default(), T).unwrap()
2796            else {
2797                panic!("{normal:?}: rulings");
2798            };
2799            assert_eq!(sections.len(), count, "{normal:?}");
2800            for section in &sections {
2801                assert_eq!(section.tangential, tangent, "{normal:?}");
2802                let (lo, hi) = section.curve.domain();
2803                for k in 0..=4 {
2804                    let p = section
2805                        .curve
2806                        .point_at(lo + (hi - lo) * f64::from(k) / 4.0, T)
2807                        .unwrap();
2808                    assert!(cone.distance_to(p) < 1e-9, "{p:?} on the cone");
2809                    let height = p.z - 300.0;
2810                    assert!(
2811                        (-5.0 - 1e-9..=50.0 + 1e-9).contains(&height),
2812                        "{p:?} in the window"
2813                    );
2814                }
2815            }
2816        }
2817        let shallow = plane(Vector::new(0.2, 0.0, 1.0));
2818        assert!(matches!(
2819            intersect_surfaces(&surface, &shallow, IntersectOptions::default(), T).unwrap(),
2820            SurfaceIntersection::Touching(_) | SurfaceIntersection::Apart
2821        ));
2822    }
2823
2824    #[test]
2825    fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
2826        use ogeom_geom::ConeSurface;
2827        // A 45-degree cone opening along +z, reference radius 24 at the
2828        // frame's origin; a ruling at chart angle 0.01, exactly as a real
2829        // file states it: the line's own origin parked seven hundred
2830        // kilometres down the infinite line, past the apex on the other
2831        // nappe. Only the used range may vote on the angle, or the pcurve
2832        // lands half a turn away and the face triangulates as a fan across
2833        // the whole chart.
2834        let cone =
2835            ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2836        let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
2837        let u_true = 0.01_f64;
2838        let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
2839        // The ruling climbs outward at 45 degrees; its stated origin sits
2840        // far beyond the apex (z = -24 on this cone), on the other nappe.
2841        let direction =
2842            Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
2843        let far = -7.0e5;
2844        let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
2845        let line = ogeom_geom::LineCurve::over(
2846            ogeom_math::Axis::new(origin, direction),
2847            far.abs() - 1.0,
2848            far.abs() + 1.0,
2849        )
2850        .unwrap();
2851        let curve: Curve = line.into();
2852        let range = ogeom_geom::Curve3d::domain(&curve);
2853        let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
2854        let at = pcurve.point_at(range.0, T).unwrap();
2855        let tau = core::f64::consts::TAU;
2856        let gap = (at.x - u_true)
2857            .rem_euclid(tau)
2858            .min(tau - (at.x - u_true).rem_euclid(tau));
2859        assert!(
2860            gap < 1e-6,
2861            "the ruling's chart angle must be the used side's: got u {} against {u_true}",
2862            at.x
2863        );
2864    }
2865}