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