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