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    match surface_surface(a, b, tol) {
137        Ok(Meeting::Apart) => Ok(SurfaceIntersection::Apart),
138        Ok(Meeting::Same) => Ok(SurfaceIntersection::Same),
139        Ok(Meeting::Touching(points)) => Ok(SurfaceIntersection::Touching(points)),
140        Ok(Meeting::Along(curves)) => {
141            let sections: Vec<SectionCurve> = curves
142                .into_iter()
143                .filter_map(|curve| exact_section(curve, a, b, tol))
144                .collect();
145            Ok(if sections.is_empty() {
146                // Every curve fell outside the surfaces' stated extents: the
147                // unbounded geometries meet, the surfaces as given do not.
148                SurfaceIntersection::Apart
149            } else {
150                SurfaceIntersection::Along(sections)
151            })
152        }
153        // No closed form for this pair: the statement that sends us marching.
154        Err(_) => marched(a, b, options, tol),
155    }
156}
157
158/// An exact curve dressed as a section, clipped to the surfaces it lies on.
159///
160/// The analytic layer works on the unbounded geometry (a plane and a cylinder
161/// meet in unbounded lines), but the *surfaces* carry finite extents, and a
162/// section running a billion units past both is not something an edge can be
163/// built on. A line is clipped to the parameter interval where it is inside
164/// both extents, through its exact pcurves; a curve wholly outside either
165/// extent is dropped, or the boolean above would see a phantom edge on a
166/// region the face does not have.
167///
168/// A *closed* curve partially outside an extent is kept whole: cutting it into
169/// arcs is the restriction problem, and the restriction that matters is the
170/// face's trim, which is §8's job; the extent here is only the surface's
171/// parameterization window.
172fn exact_section(
173    curve: Curve,
174    a: &SurfaceGeometry,
175    b: &SurfaceGeometry,
176    tol: Tolerances,
177) -> Option<SectionCurve> {
178    let closed = match &curve {
179        Curve::Circle(_) | Curve::Ellipse(_) => true,
180        _ => curve.is_closed(tol),
181    };
182    let range = curve.domain();
183    let on_a = exact_pcurve(&curve, range, a, tol);
184    let on_b = exact_pcurve(&curve, range, b, tol);
185
186    if let Curve::Line(_) = &curve {
187        // Clip through whichever pcurves exist; a missing pcurve leaves that
188        // surface's extent unenforced, which errs long rather than wrong.
189        let mut interval = curve.domain();
190        if let Some(p) = &on_a {
191            interval = intersect_intervals(interval, inside_box(p, a))?;
192        }
193        if let Some(p) = &on_b {
194            interval = intersect_intervals(interval, inside_box(p, b))?;
195        }
196        let (lo, hi) = interval;
197        let Curve::Line(line) = &curve else {
198            unreachable!()
199        };
200        let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
201            .ok()?
202            .into();
203        let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
204            let PlanarCurve::Line(l) = p else {
205                return Some(p.clone());
206            };
207            Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
208        };
209        let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
210        let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
211        return Some(SectionCurve {
212            on_a: ca,
213            on_b: cb,
214            tolerance: 0.0,
215            exact: true,
216            closed: false,
217            tangential,
218            curve: clipped,
219        });
220    }
221
222    // A closed curve: dropped only when wholly outside an extent it has a
223    // pcurve to check against.
224    for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
225        if let Some(p) = pcurve
226            && !touches_box(p, surface, tol)
227        {
228            return None;
229        }
230    }
231    let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
232    Some(SectionCurve {
233        on_a,
234        on_b,
235        tolerance: 0.0,
236        exact: true,
237        closed,
238        tangential,
239        curve,
240    })
241}
242
243/// Whether the surfaces touch along an exact curve rather than crossing it:
244/// their normals parallel at stations along its length.
245///
246/// Decided through the curve's own pcurves, which is where the normals can
247/// be read without inverting anything. A curve missing a pcurve on either
248/// surface is reported as a crossing, the honest default, since a section
249/// nobody can place in a chart is one nothing can classify as contact
250/// either.
251fn touching_along(
252    curve: &Curve,
253    on_a: Option<&PlanarCurve>,
254    on_b: Option<&PlanarCurve>,
255    a: &SurfaceGeometry,
256    b: &SurfaceGeometry,
257    tol: Tolerances,
258) -> bool {
259    // The chart position of a sample: through the pcurve where one exists,
260    // through the surface's own closed-form inversion where not. A meridian
261    // through a sphere's poles has no pcurve (its longitude jumps half a
262    // turn at each pole), but every *point* of it inverts fine, and a
263    // tangency that would be missed for want of a pcurve becomes a crossing
264    // section lying along a face's own boundary, which is the worst thing a
265    // section can be.
266    let sample_uv = |pc: Option<&PlanarCurve>,
267                     surface: &SurfaceGeometry,
268                     t: f64|
269     -> Option<ogeom_math::Point2> {
270        if let Some(pc) = pc {
271            return pc.point_at(t, tol).ok();
272        }
273        let p = curve.point_at(t, tol).ok()?;
274        chart_inversion(surface, p, tol)
275    };
276    let (lo, hi) = curve.domain();
277    // Offsets chosen off the round fractions, so a curve through a chart
278    // degeneracy (a meridian's poles sit at quarters of its turn) is
279    // sampled beside the degenerate points rather than on them. A sample
280    // whose inversion still fails is skipped: the point tells us nothing,
281    // not that the surfaces cross.
282    let mut judged = 0_usize;
283    for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
284        let t = (hi - lo).mul_add(f, lo);
285        let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
286            continue;
287        };
288        let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
289            continue;
290        };
291        if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
292            return false;
293        }
294        judged += 1;
295    }
296    judged >= 3
297}
298
299/// A point's chart position on an analytic surface, by closed form.
300fn chart_inversion(
301    surface: &SurfaceGeometry,
302    p: ogeom_math::Point,
303    tol: Tolerances,
304) -> Option<ogeom_math::Point2> {
305    use ogeom_math::elementary;
306    let (u, v) = match surface {
307        SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
308        SurfaceGeometry::Cylinder(s) => {
309            elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
310        }
311        SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
312        SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
313        SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
314        _ => return None,
315    };
316    Some(ogeom_math::Point2::new(u, v))
317}
318
319/// The parameter interval over which a 2D line stays inside a surface's
320/// parameter box. `None` when it never enters.
321fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
322    let PlanarCurve::Line(line) = pcurve else {
323        return None;
324    };
325    let ((ua, ub), (va, vb)) = surface.domain();
326    let axis = line.axis();
327    let (o, d) = (axis.location, axis.direction.vector());
328
329    // The slab test, one axis at a time.
330    let mut lo = f64::NEG_INFINITY;
331    let mut hi = f64::INFINITY;
332    for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
333        if direction.abs() <= f64::MIN_POSITIVE {
334            if origin < low || origin > high {
335                return None;
336            }
337            continue;
338        }
339        let (a, b) = ((low - origin) / direction, (high - origin) / direction);
340        let (near, far) = if a < b { (a, b) } else { (b, a) };
341        lo = lo.max(near);
342        hi = hi.min(far);
343    }
344    if lo >= hi {
345        return None;
346    }
347    Some((lo, hi))
348}
349
350/// Whether any of a closed pcurve's samples lies inside the surface's box.
351fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
352    use ogeom_geom::Curve2d;
353    let ((ua, ub), (va, vb)) = surface.domain();
354    let (lo, hi) = pcurve.domain();
355    (0..=16).any(|i| {
356        let t = lo + (hi - lo) * f64::from(i) / 16.0;
357        pcurve.point_at(t, tol).is_ok_and(|p| {
358            // Periodic directions always contain; only a bounded one excludes.
359            let u_ok = surface.is_periodic_u() || (p.x >= ua && p.x <= ub);
360            let v_ok = surface.is_periodic_v() || (p.y >= va && p.y <= vb);
361            u_ok && v_ok
362        })
363    })
364}
365
366/// The overlap of two intervals. `None` when they miss.
367fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
368    let b = b?;
369    let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
370    if lo >= hi {
371        return None;
372    }
373    Some((lo, hi))
374}
375
376/// The general path: seed, trace, fit.
377fn marched(
378    a: &SurfaceGeometry,
379    b: &SurfaceGeometry,
380    options: IntersectOptions,
381    tol: Tolerances,
382) -> OgeomResult<SurfaceIntersection> {
383    let traced = branches(a, b, options.marching, tol)?;
384    if traced.is_empty() {
385        return Ok(SurfaceIntersection::Apart);
386    }
387    let mut out = Vec::with_capacity(traced.len());
388    let mut contacts: Vec<crate::march::Traced> = Vec::new();
389    for branch in &traced {
390        // A branch along which the two surfaces share their normal is a
391        // tangency, not a crossing: the marcher's seeding cannot tell the
392        // noise floor of a tangential valley from a genuine sign change, and
393        // what it traces there is a stalled fragment of the valley, not a
394        // section. The valley is still a curve, though, and the tangential
395        // walker is the one that can follow it, so the fragment becomes a
396        // seed rather than a discard, and what comes back is marked as
397        // contact so nobody classifies by it.
398        if branch_is_tangential(a, b, branch, tol)? {
399            if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
400                contacts.push(contact);
401            }
402            continue;
403        }
404        if branch.stopped == crate::march::Stopped::RanOut {
405            ogeom_bail!(
406                NotDone,
407                "a marched section ran out of its point budget before \
408                 finishing; the seam is longer than the chord affords and \
409                 fitting the truncation would state a curve that is not there"
410            );
411        }
412        // A fit past its budget is still honest data: the error it reached
413        // is carried on the record and every consumer widens by it: an
414        // imported part's ragged pair can trace branches nothing fits, and
415        // those sections fall outside every trim downstream. Only a trace
416        // cut off by the point budget, refused above, states a curve that
417        // is not there. (A boolean marching an *exact* pair whose image has
418        // no closed form holds its own marched sections to a budget, in
419        // its own fallback, where a miss is a miss.)
420        let fitted = approximate_branch(a, b, branch, options.tolerance, tol)?;
421        out.push(SectionCurve {
422            curve: fitted.curve.into(),
423            on_a: Some(fitted.on_a.into()),
424            on_b: Some(fitted.on_b.into()),
425            // The sum of the stated parts: the trace is within its chord of
426            // the truth, the fit within its error of the trace.
427            tolerance: options.marching.chord + fitted.fit_error,
428            exact: false,
429            closed: fitted.closed,
430            tangential: false,
431        });
432    }
433    for contact in &contacts {
434        let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
435        out.push(SectionCurve {
436            curve: fitted.curve.into(),
437            on_a: Some(fitted.on_a.into()),
438            on_b: Some(fitted.on_b.into()),
439            tolerance: options.marching.chord + fitted.fit_error,
440            exact: false,
441            closed: fitted.closed,
442            tangential: true,
443        });
444    }
445    if out.is_empty() {
446        return Ok(SurfaceIntersection::Apart);
447    }
448    Ok(SurfaceIntersection::Along(out))
449}
450
451/// Follow the contact a tangential fragment sits on, unless one already
452/// traced covers it.
453///
454/// A tangential valley hands the crossing marcher several stalled fragments
455/// (the seeds converge onto the contact from wherever they started and
456/// wander there), so the fragments are candidates for *one* curve, not
457/// several. A fragment whose middle already lies on a traced contact is one
458/// of those repeats.
459fn walk_contact(
460    a: &SurfaceGeometry,
461    b: &SurfaceGeometry,
462    fragment: &crate::march::Traced,
463    already: &[crate::march::Traced],
464    marching: Marching,
465    tol: Tolerances,
466) -> OgeomResult<Option<crate::march::Traced>> {
467    let middle = fragment.points.len() / 2;
468    let Some(point) = fragment.points.get(middle).copied() else {
469        return Ok(None);
470    };
471    for traced in already {
472        // Traced points sit a step apart, so "on this curve" has to allow
473        // half a step of gap to the nearest sample plus the chord budget.
474        let spacing = traced
475            .points
476            .windows(2)
477            .map(|w| w[0].distance(w[1]))
478            .fold(0.0f64, f64::max);
479        let near = traced
480            .points
481            .iter()
482            .map(|p| p.distance(point))
483            .fold(f64::INFINITY, f64::min);
484        if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
485            return Ok(None);
486        }
487    }
488    let seed = crate::march::Contact {
489        point,
490        on_a: fragment.on_a[middle],
491        on_b: fragment.on_b[middle],
492    };
493    // The walker refuses a seed that is not a contact; that refusal is an
494    // answer, not a failure: the fragment simply had nothing to follow.
495    // A walk that stalls where it started says the same thing in points:
496    // too few to fit, so there is no contact curve to report here.
497    Ok(trace_tangential(a, b, seed, marching, tol)
498        .ok()
499        .filter(|traced| traced.points.len() >= 4))
500}
501
502/// Whether a traced branch runs along a tangency of the two surfaces:
503/// their normals parallel, sampled along its length.
504fn branch_is_tangential(
505    a: &SurfaceGeometry,
506    b: &SurfaceGeometry,
507    branch: &crate::march::Traced,
508    tol: Tolerances,
509) -> OgeomResult<bool> {
510    use ogeom_geom::Surface as _;
511    let count = branch.points.len();
512    if count == 0 {
513        return Ok(true);
514    }
515    for k in 0..5 {
516        let i = (k * (count - 1)) / 4;
517        let (ua, va) = branch.on_a[i.min(count - 1)];
518        let (ub, vb) = branch.on_b[i.min(count - 1)];
519        let (dau, dav) = a.d1_at(ua, va, tol)?;
520        let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
521        let na = dau.cross(dav);
522        let nb = dbu.cross(dbv);
523        let (ma, mb) = (na.magnitude(), nb.magnitude());
524        if ma <= tol.confusion() || mb <= tol.confusion() {
525            continue;
526        }
527        // The threshold carries the fitted world: a blend surface within a
528        // fit tolerance of true tangency crosses its host at an angle that
529        // grows as the square root of that tolerance, and calling such a
530        // graze transversal splits faces along slivers no classifier can
531        // hold. Genuinely transversal analytic pairs meeting under two
532        // degrees are the pathology, not the rule.
533        if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
534            return Ok(false);
535        }
536    }
537    Ok(true)
538}
539
540/// The exact pcurve of a curve lying on a surface, where the projection has
541/// a closed form; `None` where it does not.
542///
543/// Public because the boolean's same-domain handling needs it: two faces on
544/// one geometric surface may still carry different charts, and the other
545/// face's boundary edges have to be spoken in this face's parameters before
546/// they can split it.
547#[must_use]
548pub fn exact_pcurve_of(
549    curve: &Curve,
550    surface: &SurfaceGeometry,
551    tol: Tolerances,
552) -> Option<PlanarCurve> {
553    exact_pcurve(curve, curve.domain(), surface, tol)
554}
555
556/// As [`exact_pcurve_of`], with the parameter range the caller actually
557/// uses.
558///
559/// A curve's chart image can depend on *which part* of the curve is meant: a
560/// ruling on a cone crosses the apex, and its angle on the far nappe is half
561/// a turn from its angle on the near one. The curve's own domain may span
562/// both (an imported line's usually does), so a caller that knows its edge's
563/// range must say so, or the exact projection may answer for the wrong side.
564#[must_use]
565pub fn exact_pcurve_over(
566    curve: &Curve,
567    range: (f64, f64),
568    surface: &SurfaceGeometry,
569    tol: Tolerances,
570) -> Option<PlanarCurve> {
571    exact_pcurve(curve, range, surface, tol)
572}
573
574/// The exact pcurve of an analytic curve on an analytic surface, where the
575/// projection has a closed form.
576///
577/// Same-parameter by construction: each 2D curve inherits the 3D curve's own
578/// parameterization, so the two evaluate to the same point of the intersection
579/// at the same `t`. The cases are the ones where that inheritance is exact;
580/// anything else returns `None` rather than a fit, because an *exact* result
581/// with a fitted pcurve would be a curve whose descriptions disagree by an
582/// amount nothing on it records.
583fn exact_pcurve(
584    curve: &Curve,
585    range: (f64, f64),
586    surface: &SurfaceGeometry,
587    tol: Tolerances,
588) -> Option<PlanarCurve> {
589    // A trim is a statement about *where* on a curve, not about what it is:
590    // the basis carries the shape and the trim shares its parameter, so the
591    // pcurve is the basis's own pcurve trimmed the same way. Answered here
592    // rather than in every surface's own case, because the answer does not
593    // depend on the surface at all. A *reversed* trim renumbers, and is left
594    // alone rather than mis-read.
595    if let Curve::Trimmed(trimmed) = curve
596        && !trimmed.is_reversed()
597    {
598        let window = ogeom_geom::Curve3d::domain(&**trimmed);
599        let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
600        return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
601            .ok()
602            .map(Into::into);
603    }
604    match surface {
605        SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
606        SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
607        SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
608        SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
609        SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
610        _ => None,
611    }
612}
613
614/// The pcurve of a curve on a cone, for the two straight-line families.
615///
616/// A ruling (through the apex, on the surface) runs at constant `u`; a
617/// circle perpendicular to the axis, centred on it, with the radius the cone
618/// has at that height, runs at constant `v`. Both inherit the 3D curve's own
619/// parameter, the circle with phase and winding exactly as the cylinder case.
620/// The ruling's angle is measured over `range`, because the same line has
621/// the opposite angle on the other side of the apex.
622fn on_cone(
623    curve: &Curve,
624    range: (f64, f64),
625    cone: ogeom_math::Cone,
626    tol: Tolerances,
627) -> Option<PlanarCurve> {
628    let frame = cone.frame();
629    let axis_z = frame.z().vector();
630    let tau = core::f64::consts::TAU;
631    match curve {
632        Curve::Circle(c) => {
633            let circle = c.circle();
634            if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
635                return None;
636            }
637            let local = frame.to_local(circle.centre());
638            if local.x.hypot(local.y) > tol.confusion() {
639                return None;
640            }
641            // The cone's radius at the circle's height must be the circle's.
642            let expected = cone
643                .half_angle()
644                .tan()
645                .mul_add(local.z, cone.reference_radius());
646            if (expected - circle.radius()).abs() > tol.confusion() * 10.0 {
647                return None;
648            }
649            let start = circle.centre() + circle.frame().x().vector() * circle.radius();
650            let at = frame.to_local(start);
651            let phase = at.y.atan2(at.x);
652            let winding = circle.frame().z().vector().dot(axis_z).signum();
653            let towards =
654                ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
655            Some(
656                Line2d::over(
657                    ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
658                    0.0,
659                    tau,
660                )
661                .ok()?
662                .into(),
663            )
664        }
665        Curve::Line(line) => {
666            // A ruling: verified by sample, not assumed: three points on
667            // the surface pin a line to it.
668            let axis = line.axis();
669            let on = |t: f64| {
670                let p = axis.location + axis.direction.vector() * t;
671                cone.distance_to(p) <= tol.confusion() * 10.0
672            };
673            if !on(0.0) || !on(1.0) || !on(-1.0) {
674                return None;
675            }
676            // A ruling reaching the tip may be *stated* from the apex
677            // itself (where the angle is atan2(0, 0), garbage) and its
678            // own domain usually spans both nappes, where the angles differ
679            // by half a turn. Measure the angle at whichever end of the
680            // *used* range stands farthest from the axis: that is the side
681            // the caller means.
682            let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
683                range
684            } else {
685                line.domain()
686            };
687            // Only the used range votes. The line's own origin is stated
688            // wherever the file likes (some writers park it hundreds of
689            // kilometres down the infinite line, past the apex on the other
690            // nappe), and letting it compete reads the angle half a turn
691            // from the side the edge actually uses.
692            let mut local: Option<ogeom_math::Point> = None;
693            for t in [lo, hi] {
694                if !t.is_finite() {
695                    continue;
696                }
697                let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
698                if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
699                    local = Some(candidate);
700                }
701            }
702            let local = local?;
703            if local.x.hypot(local.y) <= tol.confusion() {
704                return None;
705            }
706            let u = local.y.atan2(local.x).rem_euclid(tau);
707            // Same-parameter exactly: a degree-one spline over the used
708            // range maps t linearly onto the chart column, whatever rate
709            // the slant climbs at.
710            let v_at = |t: f64| {
711                frame
712                    .to_local(axis.location + axis.direction.vector() * t)
713                    .z
714            };
715            let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
716            Some(
717                ogeom_geom::BSpline2d::new(
718                    knots,
719                    vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
720                    tol,
721                )
722                .ok()?
723                .into(),
724            )
725        }
726        _ => None,
727    }
728}
729
730/// The pcurve of a circle on a torus, for the two families that are straight
731/// lines in `(u, v)`.
732///
733/// A *parallel* (centred on the axis, in a plane perpendicular to it) runs
734/// at constant `v`; a *tube circle* (minor radius, centred on the tube's
735/// spine, in a plane through the axis) runs at constant `u`. Both inherit
736/// the circle's own angle, phase and winding included, exactly as the
737/// cylinder case does; the STEP reader is the consumer that forced the torus
738/// into this list, fillet faces being tori more often than not.
739fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
740    let Curve::Circle(c) = curve else {
741        return None;
742    };
743    let circle = c.circle();
744    let frame = torus.frame();
745    let axis_z = frame.z().vector();
746    let normal = circle.frame().z().vector();
747    let local = frame.to_local(circle.centre());
748    let tau = core::f64::consts::TAU;
749
750    // A parallel of the sweep.
751    if normal.cross(axis_z).magnitude() <= tol.angular()
752        && local.x.hypot(local.y) <= tol.confusion()
753    {
754        let sin_v = local.z / torus.minor_radius();
755        let cos_v = (circle.radius() - torus.major_radius()) / torus.minor_radius();
756        if (sin_v.hypot(cos_v) - 1.0).abs() > tol.confusion() {
757            return None;
758        }
759        let v = sin_v.atan2(cos_v);
760        let start = circle.centre() + circle.frame().x().vector() * circle.radius();
761        let at = frame.to_local(start);
762        let phase = at.y.atan2(at.x);
763        let winding = normal.dot(axis_z).signum();
764        let towards =
765            ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
766        return Some(
767            Line2d::over(
768                ogeom_math::Axis2::new(Point2::new(phase, v), towards),
769                0.0,
770                tau,
771            )
772            .ok()?
773            .into(),
774        );
775    }
776
777    // A circle of the tube.
778    if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
779        && normal.dot(axis_z).abs() <= tol.angular()
780        && (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
781        && local.z.abs() <= tol.confusion()
782    {
783        let u = local.y.atan2(local.x);
784        let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
785        let xc = circle.frame().x().vector();
786        let phase = xc.dot(axis_z).atan2(xc.dot(radial));
787        let winding = normal.dot(radial.cross(axis_z)).signum();
788        let towards =
789            ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
790        return Some(
791            Line2d::over(
792                ogeom_math::Axis2::new(Point2::new(u, phase), towards),
793                0.0,
794                tau,
795            )
796            .ok()?
797            .into(),
798        );
799    }
800    None
801}
802
803/// Project a curve lying in a plane into the plane's own coordinates.
804///
805/// Exact for a line, a circle and an ellipse: the plane's frame is orthonormal,
806/// so lengths and the curves' own parameterizations survive the projection
807/// unchanged.
808fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
809    let frame = plane.frame();
810    let flat = |p: Point| {
811        let local = frame.to_local(p);
812        Point2::new(local.x, local.y)
813    };
814    let flat_direction = |d: ogeom_math::Direction| {
815        let tip = flat(frame.origin() + d.vector());
816        ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
817    };
818    match curve {
819        Curve::Line(line) => {
820            let axis = line.axis();
821            let through = flat(axis.location);
822            let direction = flat_direction(axis.direction)?;
823            let (lo, hi) = line.domain();
824            Some(
825                Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
826                    .ok()?
827                    .into(),
828            )
829        }
830        Curve::Circle(c) => {
831            let circle = c.circle();
832            let frame2 = Frame2::from_axes(
833                flat(circle.centre()),
834                flat_direction(circle.frame().x())?,
835                flat_direction(circle.frame().y())?,
836                tol,
837            )
838            .ok()?;
839            Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
840        }
841        Curve::Ellipse(e) => {
842            let ellipse = e.ellipse();
843            let frame2 = Frame2::from_axes(
844                flat(ellipse.centre()),
845                flat_direction(ellipse.frame().x())?,
846                flat_direction(ellipse.frame().y())?,
847                tol,
848            )
849            .ok()?;
850            Some(
851                Ellipse2d::new(
852                    Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
853                        .ok()?,
854                )
855                .into(),
856            )
857        }
858        Curve::BSpline(b) => {
859            // Affine invariance: a (rational) B-spline in the plane projects
860            // into the plane's own coordinates control point by control
861            // point, knots and weights untouched: exact, and same-parameter
862            // by construction.
863            let control = b
864                .control_points()
865                .iter()
866                .map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
867                .collect::<Result<Vec<_>, _>>()
868                .ok()?;
869            Some(
870                ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
871                    .ok()?
872                    .into(),
873            )
874        }
875        _ => None,
876    }
877}
878
879/// The pcurve of a curve on a cylinder, where it is a straight line in
880/// parameter space.
881///
882/// A line along the axis runs at constant `u`; a full circle around it runs at
883/// constant `v`. Both are lines in `(u, v)`, exactly, and both inherit the 3D
884/// curve's own parameter: height for the line, angle for the circle.
885fn on_cylinder(
886    curve: &Curve,
887    range: (f64, f64),
888    cylinder: ogeom_math::Cylinder,
889    tol: Tolerances,
890) -> Option<PlanarCurve> {
891    let axis = cylinder.axis();
892    let frame = cylinder.frame();
893    match curve {
894        Curve::Line(line) => {
895            // Parallel to the axis, on the surface.
896            let direction = line.axis().direction;
897            let along = direction.dot(axis.direction);
898            if (along.abs() - 1.0).abs() > tol.angular() {
899                return None;
900            }
901            let through = line.axis().location;
902            if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
903                return None;
904            }
905            let local = frame.to_local(through);
906            let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
907            // The 3D line's parameter is length from its origin; at constant u
908            // the pcurve's `v` runs at the same rate, signed by whether the
909            // line runs with the axis or against it.
910            let (lo, hi) = line.domain();
911            let start = Point2::new(u, local.z);
912            let towards =
913                ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
914                    .ok()?;
915            Some(
916                Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
917                    .ok()?
918                    .into(),
919            )
920        }
921        Curve::Circle(c) => {
922            let circle = c.circle();
923            // Perpendicular to the axis, centred on it, of the same radius.
924            if circle
925                .frame()
926                .z()
927                .cross_with(axis.direction.vector())
928                .magnitude()
929                > tol.angular()
930            {
931                return None;
932            }
933            if axis.distance_to(circle.centre()) > tol.confusion() {
934                return None;
935            }
936            if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
937                return None;
938            }
939            let local = frame.to_local(circle.centre());
940            // Where the circle's own angle zero sits in the cylinder's angle,
941            // and which way its parameter runs around the axis. A section
942            // circle inherits its winding from the pair that made it, and one
943            // wound against the cylinder's `u` (a circle cut by a plane whose
944            // normal opposes the axis) runs its pcurve in `-u`. Writing `+u`
945            // unconditionally here was the bug the boolean's drill test found:
946            // the pcurve evaluated half a turn away from the curve, and the
947            // face's arrangement tore along a seam that was not there.
948            let start = circle.centre() + circle.frame().x().vector() * circle.radius();
949            let at = frame.to_local(start);
950            let phase = at.y.atan2(at.x);
951            let winding = circle.frame().z().dot(axis.direction).signum();
952            let towards =
953                ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
954            Some(
955                Line2d::over(
956                    ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
957                    0.0,
958                    core::f64::consts::TAU,
959                )
960                .ok()?
961                .into(),
962            )
963        }
964        Curve::Ellipse(_) => {
965            // An oblique plane's section: its plan projection is the
966            // cylinder's own cross-section circle traced *uniformly*, so
967            // the chart trace is u = s·t + φ, v = c₀ + a·cos t + b·sin t:
968            // the trig-affine family. Derived from the curve's own
969            // evaluations and verified by sample, never assumed.
970            use ogeom_geom::Curve3d as _;
971            let tau = core::f64::consts::TAU;
972            let local = |t: f64| -> Option<ogeom_math::Point> {
973                Some(frame.to_local(curve.point_at(t, tol).ok()?))
974            };
975            let l0 = local(0.0)?;
976            let lq = local(tau / 4.0)?;
977            let lh = local(tau / 2.0)?;
978            // On the surface at all: plan radius must be the cylinder's.
979            let r = cylinder.radius();
980            for l in [&l0, &lq, &lh] {
981                if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
982                    return None;
983                }
984            }
985            let phase = l0.y.atan2(l0.x);
986            // Winding from the quarter-turn sample: uniform tracing puts it
987            // a quarter turn away, one side or the other.
988            let uq = lq.y.atan2(lq.x);
989            let step = (uq - phase).rem_euclid(tau);
990            let winding = if (step - tau / 4.0).abs() < 1e-6 {
991                1.0
992            } else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
993                -1.0
994            } else {
995                return None;
996            };
997            // Height coefficients from three samples.
998            let c0 = f64::midpoint(l0.z, lh.z);
999            let a = (l0.z - lh.z) / 2.0;
1000            let b = lq.z - c0;
1001            // The trig formula is global (cosine wraps, the linear angle
1002            // unwraps the chart), so the pcurve lives on whatever range the
1003            // edge actually spans, a loop crossing the period included.
1004            let candidate = ogeom_geom::Trig2d::new(
1005                Point2::new(phase, c0),
1006                ogeom_math::Vector2::new(winding, 0.0),
1007                ogeom_math::Vector2::new(0.0, a),
1008                ogeom_math::Vector2::new(0.0, b),
1009                range,
1010            )
1011            .ok()?;
1012            // The same-parameter law, verified at points the derivation
1013            // never touched, inside the range the edge will use.
1014            use ogeom_geom::Curve2d as _;
1015            for i in 0..7 {
1016                let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
1017                let l = local(t)?;
1018                let chart = candidate.point_at(t, tol).ok()?;
1019                let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
1020                if du.min(tau - du) > 1e-9 {
1021                    return None;
1022                }
1023                if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
1024                    return None;
1025                }
1026            }
1027            Some(PlanarCurve::Trig(candidate))
1028        }
1029        _ => None,
1030    }
1031}
1032
1033/// The pcurve of half a meridian: a great circle through both poles,
1034/// restricted to one side of them.
1035///
1036/// The whole circle has no chart image a single curve can carry (its
1037/// longitude jumps by half a turn at each pole), but each *half* does, and it
1038/// is a straight line. Writing the circle's own parameter as `t` and the
1039/// sphere's axis as `Z = cos α·X + sin α·Y` in the circle's own frame, the
1040/// point's height above the equator is `r·cos(t − α)`, so the latitude is
1041/// `asin(cos(t − α))`, which on `t − α ∈ [0, π]` is exactly `π/2 − (t − α)`,
1042/// affine in `t`, with slope one. The longitude is constant on that half and
1043/// half a turn away on the other. So the pcurve is a vertical line in the
1044/// chart, sharing the circle's parameter exactly, and the caller's `range` is
1045/// what says which half is meant.
1046///
1047/// The half is not assumed: the returned line is lifted back through the
1048/// sphere at stations along the range and compared against the circle, so a
1049/// misread orientation is caught here rather than downstream.
1050fn on_meridian(
1051    curve: &ogeom_geom::CircleCurve,
1052    range: (f64, f64),
1053    sphere: ogeom_math::Sphere,
1054    tol: Tolerances,
1055) -> Option<PlanarCurve> {
1056    let circle = curve.circle();
1057    // A reversed circle runs its own angle backwards, and the shifted angle
1058    // below is measured in the *curve's* parameter, so the sign travels with
1059    // it: the sweep flips and so do both the latitude's slope and which half
1060    // of the circle a range names.
1061    let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
1062    let frame = sphere.frame();
1063    let z = frame.z().vector();
1064    // A great circle: the sphere's own centre and radius, in a plane holding
1065    // the axis. Anything else is not a meridian.
1066    if circle.centre().distance(sphere.centre()) > tol.confusion() {
1067        return None;
1068    }
1069    if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
1070        return None;
1071    }
1072    let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
1073    let (xz, yz) = (cx.dot(z), cy.dot(z));
1074    // The axis must lie *in* the circle's plane, or the circle is neither a
1075    // parallel nor a meridian and has no closed-form chart image at all.
1076    if xz.hypot(yz) < 1.0 - tol.angular() {
1077        return None;
1078    }
1079    let raw_alpha = yz.atan2(xz);
1080    // `w` is the circle's own horizontal direction: the axis turned a quarter
1081    // turn within the circle's plane.
1082    let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
1083    let local = frame.to_local(sphere.centre() + w);
1084    let longitude = local.y.atan2(local.x);
1085
1086    let half = core::f64::consts::PI;
1087    let mid = f64::midpoint(range.0, range.1);
1088    // Where the range sits relative to the poles, in the shifted angle
1089    // `x = sweep·t − α` that measures the descent from the north pole.
1090    let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
1091    let x_mid = if x_mid > half {
1092        x_mid - core::f64::consts::TAU
1093    } else {
1094        x_mid
1095    };
1096    let span = sweep * (range.1 - range.0);
1097    let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
1098    if x0 > x1 {
1099        core::mem::swap(&mut x0, &mut x1);
1100    }
1101    // The turn count `α` was written with is what decides whether the
1102    // latitude comes out inside the chart or a whole turn away from it, so
1103    // the branch the range actually sits on is the one the line is built
1104    // from.
1105    let alpha = sweep.mul_add(mid, -x_mid);
1106    let slack = tol.parametric().max(1e-9);
1107    let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
1108        // The descending half: latitude π/2 − (sweep·t − α), longitude
1109        // constant.
1110        (
1111            Point2::new(longitude, half.mul_add(0.5, alpha)),
1112            ogeom_math::Vector2::new(0.0, -sweep),
1113        )
1114    } else if x0 >= -half - slack && x1 <= slack {
1115        // The ascending half, half a turn round the chart.
1116        (
1117            Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
1118            ogeom_math::Vector2::new(0.0, sweep),
1119        )
1120    } else {
1121        // The range straddles a pole: no one line covers it.
1122        return None;
1123    };
1124    let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
1125    let margin = (range.1 - range.0) * 0.25;
1126    let line: PlanarCurve = Line2d::over(
1127        ogeom_math::Axis2::new(axis_point, towards),
1128        range.0 - margin,
1129        range.1 + margin,
1130    )
1131    .ok()?
1132    .into();
1133
1134    // Measured, not assumed: the chart line lifted back through the sphere is
1135    // the circle it claims to be.
1136    for k in 0..=4 {
1137        let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
1138        let uv = line.point_at(t, tol).ok()?;
1139        let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
1140        let want = curve.point_at(t, tol).ok()?;
1141        if lifted.distance(want) > tol.confusion() {
1142            return None;
1143        }
1144    }
1145    Some(line)
1146}
1147
1148/// The pcurve of a circle on a sphere: a parallel of latitude, or one half of
1149/// a meridian.
1150fn on_sphere(
1151    curve: &Curve,
1152    range: (f64, f64),
1153    sphere: ogeom_math::Sphere,
1154    tol: Tolerances,
1155) -> Option<PlanarCurve> {
1156    let Curve::Circle(c) = curve else {
1157        return None;
1158    };
1159    let circle = c.circle();
1160    let frame = sphere.frame();
1161    // Perpendicular to the sphere's axis and centred on it: a parallel of
1162    // latitude, which is a horizontal line in (longitude, latitude).
1163    if circle
1164        .frame()
1165        .z()
1166        .cross_with(frame.z().vector())
1167        .magnitude()
1168        > tol.angular()
1169    {
1170        return on_meridian(c, range, sphere, tol);
1171    }
1172    let local = frame.to_local(circle.centre());
1173    if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
1174        return None;
1175    }
1176    let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
1177    // Sanity: the circle's radius must be the parallel's.
1178    if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
1179        return None;
1180    }
1181    let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1182    let at = frame.to_local(start);
1183    let phase = at.y.atan2(at.x);
1184    // Phase and winding exactly as the cylinder case: a parallel whose own
1185    // axis opposes the sphere's marches its angle *down* the longitude.
1186    let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
1187    let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1188    Some(
1189        Line2d::over(
1190            ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
1191            0.0,
1192            core::f64::consts::TAU,
1193        )
1194        .ok()?
1195        .into(),
1196    )
1197}
1198
1199#[cfg(test)]
1200#[allow(clippy::unwrap_used, clippy::expect_used)]
1201mod tests {
1202    use super::*;
1203    use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
1204    use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
1205
1206    const T: Tolerances = Tolerances::millimetres();
1207
1208    fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
1209        SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
1210    }
1211
1212    fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
1213        let frame = Frame::new(
1214            Point::ORIGIN,
1215            Direction::new(axis, T).unwrap(),
1216            Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1217            T,
1218        )
1219        .unwrap();
1220        CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
1221            .unwrap()
1222            .into()
1223    }
1224
1225    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1226        PlaneSurface::over(
1227            Plane::through(origin, Direction::new(normal, T).unwrap()),
1228            (-6.0, 6.0),
1229            (-6.0, 6.0),
1230        )
1231        .unwrap()
1232        .into()
1233    }
1234
1235    /// Same-parameter: pcurve lifted through its surface equals the 3D curve,
1236    /// at the same parameter, everywhere sampled.
1237    fn assert_same_parameter(
1238        section: &SectionCurve,
1239        surface: &SurfaceGeometry,
1240        pcurve: &PlanarCurve,
1241        samples: usize,
1242    ) {
1243        let (lo, hi) = section.curve.domain();
1244        let (plo, phi) = pcurve.domain();
1245        assert!(
1246            (lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
1247            "domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
1248        );
1249        for i in 0..=samples {
1250            #[allow(clippy::cast_precision_loss)]
1251            let t = lo + (hi - lo) * i as f64 / samples as f64;
1252            let on_curve = section.curve.point_at(t, T).unwrap();
1253            let at = pcurve.point_at(t, T).unwrap();
1254            let lifted = surface.point_at(at.x, at.y, T).unwrap();
1255            assert!(
1256                on_curve.is_equal(lifted, T),
1257                "at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
1258            );
1259        }
1260    }
1261
1262    #[test]
1263    fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
1264        // A plane through a cylinder's axis: two lines, and every description
1265        // agrees at the same parameter, which is the claim edges carry and
1266        // booleans rely on.
1267        let drum = cylinder(Vector::Z, 2.0);
1268        let cut = plane(Point::ORIGIN, Vector::X);
1269        let SurfaceIntersection::Along(curves) =
1270            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1271        else {
1272            panic!("a plane through a cylinder meets it along curves");
1273        };
1274        assert_eq!(curves.len(), 2);
1275        for section in &curves {
1276            assert!(section.exact);
1277            assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
1278            let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
1279            let on_b = section.on_b.as_ref().expect("and a plane pcurve");
1280            assert_same_parameter(section, &drum, on_a, 50);
1281            assert_same_parameter(section, &cut, on_b, 50);
1282        }
1283    }
1284
1285    #[test]
1286    fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
1287        // The pcurve an earlier plan owed: the oblique ellipse runs
1288        // linearly in the chart angle and sinusoidally in height (the
1289        // trig-affine family), exactly, same-parameter, both sides.
1290        let drum = cylinder(Vector::Z, 2.0);
1291        let angle: f64 = 0.5;
1292        let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
1293        let SurfaceIntersection::Along(curves) =
1294            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1295        else {
1296            panic!("an oblique plane meets the cylinder along its ellipse");
1297        };
1298        assert_eq!(curves.len(), 1);
1299        let section = &curves[0];
1300        assert!(section.exact);
1301        assert!(matches!(section.curve, Curve::Ellipse(_)));
1302        let on_drum = section
1303            .on_a
1304            .as_ref()
1305            .expect("the oblique ellipse now carries its cylinder pcurve");
1306        assert!(
1307            matches!(on_drum, PlanarCurve::Trig(_)),
1308            "the chart trace is trig-affine: {on_drum:?}"
1309        );
1310        assert_same_parameter(section, &drum, on_drum, 60);
1311        let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
1312        assert_same_parameter(section, &cut, on_plane, 60);
1313    }
1314
1315    #[test]
1316    fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
1317        let drum = cylinder(Vector::Z, 2.0);
1318        let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
1319        let SurfaceIntersection::Along(curves) =
1320            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1321        else {
1322            panic!("expected curves");
1323        };
1324        assert_eq!(curves.len(), 1);
1325        let section = &curves[0];
1326        assert!(section.closed);
1327        assert!(matches!(section.curve, Curve::Circle(_)));
1328        // On the cylinder the circle is a horizontal line in (u, v).
1329        assert!(matches!(
1330            section.on_a.as_ref().unwrap(),
1331            PlanarCurve::Line(_)
1332        ));
1333        assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
1334        assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
1335    }
1336
1337    #[test]
1338    fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
1339        let drum = cylinder(Vector::Z, 1.5);
1340        let ball = sphere(Point::ORIGIN, 3.0);
1341        let SurfaceIntersection::Along(curves) =
1342            intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
1343        else {
1344            panic!("expected curves");
1345        };
1346        assert_eq!(curves.len(), 2);
1347        for section in &curves {
1348            assert!(section.exact);
1349            assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
1350            assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
1351        }
1352    }
1353
1354    fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
1355        let frame = Frame::new(
1356            origin,
1357            Direction::new(axis, T).unwrap(),
1358            Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1359            T,
1360        )
1361        .unwrap();
1362        ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
1363            .into()
1364    }
1365
1366    #[test]
1367    fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
1368        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1369        let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
1370        let SurfaceIntersection::Along(curves) =
1371            intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1372        else {
1373            panic!("an axis-normal plane through the tube meets it along curves");
1374        };
1375        assert_eq!(curves.len(), 2);
1376        let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
1377        let mut radii: Vec<f64> = curves
1378            .iter()
1379            .map(|s| {
1380                let Curve::Circle(c) = &s.curve else {
1381                    panic!("a parallel is a circle");
1382                };
1383                c.circle().radius()
1384            })
1385            .collect();
1386        radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
1387        assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
1388        assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
1389        for section in &curves {
1390            assert!(section.exact);
1391            assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1392            assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
1393        }
1394    }
1395
1396    #[test]
1397    fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
1398        // Tangency with length is reported as the curve it is (the way a
1399        // tangent plane reports its line on a cylinder), because the blend
1400        // machinery builds faces whose boundaries are exactly these circles,
1401        // and a Touching with no curve in it would read as a refusal upstream.
1402        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1403        let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
1404        let SurfaceIntersection::Along(curves) =
1405            intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1406        else {
1407            panic!("the rolling plane touches along a circle, not at points");
1408        };
1409        assert_eq!(curves.len(), 1);
1410        let Curve::Circle(c) = &curves[0].curve else {
1411            panic!("the tangency is a circle");
1412        };
1413        assert!((c.circle().radius() - 2.0).abs() < 1e-12);
1414        assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
1415        assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
1416    }
1417
1418    #[test]
1419    fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
1420        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1421        let drum = cylinder(Vector::Z, 2.2);
1422        let SurfaceIntersection::Along(curves) =
1423            intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
1424        else {
1425            panic!("a coaxial cylinder through the tube meets it along curves");
1426        };
1427        assert_eq!(curves.len(), 2);
1428        for section in &curves {
1429            assert!(section.exact);
1430            let Curve::Circle(c) = &section.curve else {
1431                panic!("a parallel is a circle");
1432            };
1433            assert!((c.circle().radius() - 2.2).abs() < 1e-12);
1434            assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
1435            assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
1436        }
1437
1438        // Tangent at the tube's outer equator: one circle, with both pcurves.
1439        let grazing = cylinder(Vector::Z, 2.5);
1440        let SurfaceIntersection::Along(touch) =
1441            intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
1442        else {
1443            panic!("the grazing cylinder touches along the equator");
1444        };
1445        assert_eq!(touch.len(), 1);
1446        assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
1447        assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
1448    }
1449
1450    #[test]
1451    fn coaxial_tori_are_the_same_or_meet_in_parallels() {
1452        let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1453        assert!(matches!(
1454            intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
1455            SurfaceIntersection::Same
1456        ));
1457
1458        // The same tube lifted half a radius: the profile circles cross
1459        // twice, and each crossing revolves into a parallel shared exactly.
1460        let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
1461        let SurfaceIntersection::Along(curves) =
1462            intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
1463        else {
1464            panic!("lifted coaxial tori meet along curves");
1465        };
1466        assert_eq!(curves.len(), 2);
1467        for section in &curves {
1468            assert!(section.exact);
1469            assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1470            assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
1471        }
1472    }
1473
1474    #[test]
1475    fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
1476        // Crossed cylinders: the marched path, end to end through one call.
1477        let a = cylinder(Vector::Z, 1.0);
1478        let b = cylinder(Vector::X, 1.6);
1479        let options = IntersectOptions {
1480            tolerance: 1e-5,
1481            marching: Marching {
1482                chord: 1e-5,
1483                ..Marching::default()
1484            },
1485        };
1486        let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
1487        else {
1488            panic!("crossed cylinders meet along curves");
1489        };
1490        assert_eq!(curves.len(), 2);
1491        for section in &curves {
1492            assert!(!section.exact);
1493            assert!(section.closed);
1494            assert!(
1495                section.tolerance <= 1e-5 + 1e-4,
1496                "got {}",
1497                section.tolerance
1498            );
1499            assert!(section.on_a.is_some() && section.on_b.is_some());
1500
1501            // The fitted curve lies on both cylinders to its stated tolerance.
1502            let (lo, hi) = section.curve.domain();
1503            for i in 0..=200 {
1504                #[allow(clippy::cast_precision_loss)]
1505                let t = lo + (hi - lo) * f64::from(i) / 200.0;
1506                let p = section.curve.point_at(t, T).unwrap();
1507                let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
1508                    unreachable!()
1509                };
1510                let off = x
1511                    .cylinder()
1512                    .distance_to(p)
1513                    .abs()
1514                    .max(y.cylinder().distance_to(p).abs());
1515                assert!(
1516                    off <= section.tolerance * 2.0,
1517                    "at t = {t} the fitted curve is {off:e} off, tolerance {}",
1518                    section.tolerance
1519                );
1520            }
1521        }
1522    }
1523
1524    #[test]
1525    fn exact_lines_are_clipped_to_the_surfaces_extents() {
1526        // The analytic layer answers for the unbounded geometry; the surfaces
1527        // are finite. A section line a billion units long is not something an
1528        // edge can be built on, and one wholly outside the extents is a
1529        // phantom.
1530        let drum = cylinder(Vector::Z, 2.0);
1531        let cut = plane(Point::ORIGIN, Vector::X);
1532        let SurfaceIntersection::Along(curves) =
1533            intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1534        else {
1535            panic!("expected curves");
1536        };
1537        for section in &curves {
1538            let (lo, hi) = section.curve.domain();
1539            // Bounded by the cylinder's height, not by LINE_EXTENT.
1540            assert!(
1541                hi - lo <= 8.0 + 1e-9,
1542                "the line was not clipped: [{lo}, {hi}]"
1543            );
1544            let start = section.curve.point_at(lo, T).unwrap();
1545            let end = section.curve.point_at(hi, T).unwrap();
1546            assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
1547        }
1548
1549        // A circle at a height the bounded cylinder does not reach is not an
1550        // intersection of these surfaces, however truly the unbounded ones
1551        // meet there.
1552        let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
1553        assert_eq!(
1554            intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
1555            SurfaceIntersection::Apart
1556        );
1557    }
1558
1559    #[test]
1560    fn the_degenerate_answers_pass_through() {
1561        assert_eq!(
1562            intersect_surfaces(
1563                &sphere(Point::ORIGIN, 1.0),
1564                &sphere(Point::new(5.0, 0.0, 0.0), 1.0),
1565                IntersectOptions::default(),
1566                T
1567            )
1568            .unwrap(),
1569            SurfaceIntersection::Apart
1570        );
1571        assert_eq!(
1572            intersect_surfaces(
1573                &sphere(Point::ORIGIN, 1.0),
1574                &sphere(Point::ORIGIN, 1.0),
1575                IntersectOptions::default(),
1576                T
1577            )
1578            .unwrap(),
1579            SurfaceIntersection::Same
1580        );
1581        assert!(matches!(
1582            intersect_surfaces(
1583                &plane(Point::ORIGIN, Vector::Z),
1584                &sphere(Point::new(0.0, 0.0, 2.0), 2.0),
1585                IntersectOptions::default(),
1586                T
1587            )
1588            .unwrap(),
1589            SurfaceIntersection::Touching(ref p) if p.len() == 1
1590        ));
1591    }
1592
1593    #[test]
1594    fn unusable_options_are_refused() {
1595        let a = sphere(Point::ORIGIN, 1.0);
1596        let b = plane(Point::ORIGIN, Vector::Z);
1597        for tolerance in [0.0, -1.0, f64::NAN] {
1598            let options = IntersectOptions {
1599                tolerance,
1600                ..IntersectOptions::default()
1601            };
1602            assert!(intersect_surfaces(&a, &b, options, T).is_err());
1603        }
1604    }
1605
1606    #[test]
1607    fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
1608        // The winding bug the boolean's drill test found: a plane whose
1609        // normal opposes the cylinder's axis cuts a circle wound against the
1610        // cylinder's `u`, and the pcurve must run in `-u` with it. Written
1611        // `+u` unconditionally, the pcurve evaluated half a turn away from
1612        // the curve and every face built on the section tore in parameter
1613        // space. Both windings are pinned by lifting the pcurve through the
1614        // surface and demanding the curve's own point back.
1615        let drum: SurfaceGeometry = CylinderSurface::new(
1616            Cylinder::new(
1617                Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
1618                0.5,
1619                T,
1620            )
1621            .unwrap(),
1622            (0.0, 3.0),
1623        )
1624        .unwrap()
1625        .into();
1626        for normal in [Direction::Z, -Direction::Z] {
1627            let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
1628            let ground: SurfaceGeometry =
1629                PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
1630                    .unwrap()
1631                    .into();
1632            let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
1633            let SurfaceIntersection::Along(curves) = met else {
1634                panic!("a plane through a cylinder sections it");
1635            };
1636            for sc in &curves {
1637                let pcurve = sc
1638                    .on_b
1639                    .as_ref()
1640                    .expect("a circle on its cylinder has a pcurve");
1641                let (lo, hi) = sc.curve.domain();
1642                for i in 0..8 {
1643                    let t = lo + (hi - lo) * f64::from(i) / 8.0;
1644                    let p3 = sc.curve.point_at(t, T).unwrap();
1645                    let uv = pcurve.point_at(t, T).unwrap();
1646                    let lifted = drum
1647                        .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
1648                        .unwrap();
1649                    assert!(
1650                        p3.distance(lifted) < 1e-9,
1651                        "normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
1652                    );
1653                }
1654            }
1655        }
1656    }
1657
1658    /// A plane through a ball's own axis cuts a meridian. The whole circle has
1659    /// no chart image (its longitude jumps half a turn at each pole), but
1660    /// each half is a straight line in the chart, exactly, at the circle's own
1661    /// parameter. Pinned by lifting the line back through the sphere and
1662    /// demanding the circle's point, on every half of every orientation.
1663    #[test]
1664    fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
1665        use ogeom_geom::Surface as _;
1666        let half = core::f64::consts::PI;
1667        for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
1668            let ball = sphere(centre, radius);
1669            let SurfaceGeometry::Sphere(s) = &ball else {
1670                panic!("a sphere surface");
1671            };
1672            // Three planes through the axis, at different azimuths, so the
1673            // constant longitude is not accidentally zero.
1674            for azimuth in [0.0_f64, 0.7, 2.4] {
1675                let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
1676                let cut = plane(centre, normal);
1677                let SurfaceIntersection::Along(curves) =
1678                    intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
1679                else {
1680                    panic!("a plane through the centre meets the ball along a circle");
1681                };
1682                assert_eq!(curves.len(), 1, "one great circle");
1683                let circle = &curves[0].curve;
1684                assert!(curves[0].exact);
1685                // The whole circle has no chart image; each half does.
1686                assert!(
1687                    exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
1688                    "the whole meridian has no single chart image"
1689                );
1690                for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
1691                    let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
1692                        .expect("half a meridian has an exact pcurve");
1693                    assert!(
1694                        matches!(pcurve, PlanarCurve::Line(_)),
1695                        "and it is a straight line in the chart"
1696                    );
1697                    for i in 0..=16 {
1698                        let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
1699                        let want = circle.point_at(t, T).unwrap();
1700                        let uv = pcurve.point_at(t, T).unwrap();
1701                        assert!(
1702                            uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
1703                            "the latitude stays inside the chart: {}",
1704                            uv.y
1705                        );
1706                        let lifted = ball
1707                            .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
1708                            .unwrap();
1709                        assert!(
1710                            want.distance(lifted) < 1e-9,
1711                            "azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
1712                        );
1713                    }
1714                }
1715                // A range straddling a pole has none, and says so rather than
1716                // answering for one side.
1717                assert!(
1718                    exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
1719                    "a range across a pole has no one line"
1720                );
1721                let _ = s;
1722            }
1723        }
1724    }
1725
1726    /// A trim says *where* on a curve, not what it is. The basis carries the
1727    /// shape and the trim shares its parameter, so a trimmed curve's pcurve is
1728    /// the basis's own pcurve trimmed the same way, on every surface, since
1729    /// the answer does not depend on the surface at all.
1730    ///
1731    /// Found by a corner blend: a fillet's own end cap is a plane, the edges
1732    /// bounding it are trimmed curves, and the boolean refused the coincidence
1733    /// because it could not put a trimmed curve into a chart it plainly lies in.
1734    #[test]
1735    fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
1736        use ogeom_geom::TrimmedCurve;
1737        let drum = cylinder(Vector::Z, 2.0);
1738        let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
1739        // The circle where they meet, and a quarter of it.
1740        let SurfaceIntersection::Along(curves) =
1741            intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
1742        else {
1743            panic!("a plane across a cylinder meets it in a circle");
1744        };
1745        let whole = curves[0].curve.clone();
1746        let (lo, hi) = whole.domain();
1747        let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
1748            .unwrap()
1749            .into();
1750
1751        for surface in [&drum, &ground] {
1752            let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
1753            let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
1754            // Same parameter, same point: the trim changed the range and
1755            // nothing else.
1756            let (a, b) = quarter.domain();
1757            for i in 0..=8 {
1758                let t = (b - a).mul_add(f64::from(i) / 8.0, a);
1759                let (whole_at, part_at) =
1760                    (full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
1761                assert!(
1762                    whole_at.distance(part_at) < 1e-12,
1763                    "the trim carries the basis: {whole_at:?} against {part_at:?}"
1764                );
1765                // And it lifts back onto the curve it came from.
1766                let lifted = surface
1767                    .point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
1768                    .or_else(|_| surface.point_at(part_at.x, part_at.y, T))
1769                    .unwrap();
1770                assert!(
1771                    lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
1772                    "same-parameter, still"
1773                );
1774            }
1775        }
1776    }
1777    #[test]
1778    fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
1779        use ogeom_geom::ConeSurface;
1780        // A 45-degree cone opening along +z, reference radius 24 at the
1781        // frame's origin; a ruling at chart angle 0.01, exactly as a real
1782        // file states it: the line's own origin parked seven hundred
1783        // kilometres down the infinite line, past the apex on the other
1784        // nappe. Only the used range may vote on the angle, or the pcurve
1785        // lands half a turn away and the face triangulates as a fan across
1786        // the whole chart.
1787        let cone =
1788            ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
1789        let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
1790        let u_true = 0.01_f64;
1791        let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
1792        // The ruling climbs outward at 45 degrees; its stated origin sits
1793        // far beyond the apex (z = -24 on this cone), on the other nappe.
1794        let direction =
1795            Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
1796        let far = -7.0e5;
1797        let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
1798        let line = ogeom_geom::LineCurve::over(
1799            ogeom_math::Axis::new(origin, direction),
1800            far.abs() - 1.0,
1801            far.abs() + 1.0,
1802        )
1803        .unwrap();
1804        let curve: Curve = line.into();
1805        let range = ogeom_geom::Curve3d::domain(&curve);
1806        let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
1807        let at = pcurve.point_at(range.0, T).unwrap();
1808        let tau = core::f64::consts::TAU;
1809        let gap = (at.x - u_true)
1810            .rem_euclid(tau)
1811            .min(tau - (at.x - u_true).rem_euclid(tau));
1812        assert!(
1813            gap < 1e-6,
1814            "the ruling's chart angle must be the used side's: got u {} against {u_true}",
1815            at.x
1816        );
1817    }
1818}