Skip to main content

ogeom_intersect/
surface.rs

1//! Surface/surface intersection: the analytic cases.
2//!
3//! Where two surfaces meet has a closed form for a specific and well-known set
4//! of pairs, and a general answer that needs a marching intersector with a
5//! fitting stage after it. This module is the first of those. It is deliberately
6//! *not* a partial implementation of the second: a pair it cannot solve exactly
7//! is reported as needing the general path, never approximated.
8//!
9//! # Why the exact cases come first, and separately
10//!
11//! Three reasons, and the third is the one that matters.
12//!
13//! They are common. Plane against plane, plane against cylinder, sphere against
14//! sphere: a mechanical part is mostly these, and running a marching
15//! intersector over a pair whose answer is a circle is slower and less accurate
16//! than writing down the circle.
17//!
18//! They are fast. No stepping, no refinement, no approximation stage.
19//!
20//! And they are *ground truth*. Every result here can be checked without
21//! reference to anything but the two surfaces themselves: sample the curve, ask
22//! each surface how far away it is, and the answer should be zero. That check is
23//! the instrument the intersection gate is measured with (`docs/PLAN.md`),
24//! and it only exists because these cases are exact. A benchmark whose reference
25//! answers came from the thing being benchmarked would measure nothing.
26//!
27//! # What it reports
28//!
29//! Not just curves. Two surfaces can miss, touch at a point, meet along curves,
30//! or be the same surface, and those are four different answers that downstream
31//! code has to distinguish. A boolean that treats coincidence as "no
32//! intersection" produces a solid with a face missing.
33
34use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::{Curve, SurfaceGeometry};
36use ogeom_math::{Circle, Direction, Ellipse, Frame, Point, Vector};
37
38/// What two surfaces do where they meet.
39#[derive(Debug, Clone, PartialEq)]
40pub enum Meeting {
41    /// They do not meet at all.
42    Apart,
43    /// They touch at isolated points, without crossing.
44    ///
45    /// A sphere resting on a plane. Distinguished from a curve because a
46    /// tangential contact has no length to walk along, and an algorithm that
47    /// treated it as a degenerate curve would divide by that length.
48    Touching(Vec<Point>),
49    /// They meet along these curves.
50    Along(Vec<Curve>),
51    /// They are the same surface wherever they overlap.
52    ///
53    /// A separate answer from every other, because it is the one where "the
54    /// intersection curve" does not exist: the overlap is two-dimensional. A
55    /// boolean has to detect this and unify the faces rather than look for a
56    /// seam between them.
57    Same,
58}
59
60/// Where two surfaces meet, when that has a closed form.
61///
62/// # Errors
63///
64/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if this pair has no closed
65/// form, which is a statement about the pair, not a failure to compute. The
66/// general marching intersector is what answers those, and it is gated on the
67/// benchmark this module makes possible.
68pub fn surface_surface(
69    a: &SurfaceGeometry,
70    b: &SurfaceGeometry,
71    tol: Tolerances,
72) -> OgeomResult<Meeting> {
73    use SurfaceGeometry as S;
74    match (a, b) {
75        (S::Plane(p), S::Plane(q)) => Ok(plane_plane(
76            p.plane(),
77            q.plane(),
78            window_reach(p).min(window_reach(q)),
79            tol,
80        )),
81        (S::Plane(p), S::Sphere(s)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
82        (S::Sphere(s), S::Plane(p)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
83        (S::Plane(p), S::Cylinder(c)) => plane_cylinder(p.plane(), c.cylinder(), tol),
84        (S::Cylinder(c), S::Plane(p)) => plane_cylinder(p.plane(), c.cylinder(), tol),
85        (S::Sphere(x), S::Sphere(y)) => Ok(sphere_sphere(x.sphere(), y.sphere(), tol)),
86        (S::Cylinder(x), S::Cylinder(y)) => coaxial_cylinders(x.cylinder(), y.cylinder(), tol),
87        (S::Cylinder(c), S::Sphere(s)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
88        (S::Sphere(s), S::Cylinder(c)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
89        (S::Plane(p), S::Torus(t)) => axial_plane_torus(p.plane(), t.torus(), tol),
90        (S::Torus(t), S::Plane(p)) => axial_plane_torus(p.plane(), t.torus(), tol),
91        (S::Cylinder(c), S::Torus(t)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
92        (S::Torus(t), S::Cylinder(c)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
93        (S::Torus(x), S::Torus(y)) => coaxial_tori(x.torus(), y.torus(), tol),
94        (S::Plane(p), S::Cone(c)) => plane_cone(p.plane(), c.cone(), tol),
95        (S::Cone(c), S::Plane(p)) => plane_cone(p.plane(), c.cone(), tol),
96        (S::Cylinder(x), S::Cone(c)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
97        (S::Cone(c), S::Cylinder(x)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
98        (S::Cone(x), S::Cone(y)) => coaxial_cones(x.cone(), y.cone(), tol),
99        _ => ogeom_bail!(
100            NotDone,
101            "this pair of surfaces has no closed-form intersection; it needs \
102             the general marching intersector, which is gated on the benchmark \
103             these cases provide the ground truth for"
104        ),
105    }
106}
107
108/// How far across a plane surface's window reaches: its diagonal, or a
109/// billion units for one stated unbounded, which leaves the angle alone to
110/// decide.
111fn window_reach(plane: &ogeom_geom::PlaneSurface) -> f64 {
112    let ((u0, u1), (v0, v1)) = ogeom_geom::Surface::domain(plane);
113    let reach = (u1 - u0).hypot(v1 - v0);
114    if reach.is_finite() && reach > 0.0 {
115        reach.min(1e9)
116    } else {
117        1e9
118    }
119}
120
121/// Whether a plane whose normal meets an axis at cosine `along` stands
122/// square to it, for a section of a surface of revolution about that axis.
123///
124/// Looser than an angle test on purpose. Tilted by an angle t, the section
125/// taken as square is off by the radius times t squared over two: a
126/// micro-radian tilt costs a millionth of a micron on a unit radius, while
127/// a plane a composed placement left a rounding error off square would
128/// otherwise have no closed form at all. Two planes are the opposite case,
129/// where the error grows with the extent, and [`plane_plane`] tests the
130/// angle itself.
131fn square_to_axis(along: f64, tol: Tolerances) -> bool {
132    (along.abs() - 1.0).abs() <= tol.angular()
133}
134
135/// Two planes: apart, the same, or a line.
136///
137/// Parallel is decided across `reach`, the size of the region the two
138/// stand over: planes whose normals differ by an angle t part by t times
139/// the distance, so across the region they are one plane (or two parallel
140/// ones) when that stays within the confusion distance. Planes built by
141/// different routes to be coplanar differ by rounding, a hundred-billionth
142/// of a radian, and across a part that is nothing; a microradian across a
143/// hundred millimetres is a thousand times the confusion, and a line.
144fn plane_plane(a: ogeom_math::Plane, b: ogeom_math::Plane, reach: f64, tol: Tolerances) -> Meeting {
145    let turn = a.normal().angle(b.normal());
146    let turn = turn.min(core::f64::consts::PI - turn);
147    if turn <= tol.angular().max(tol.confusion() / reach) {
148        // Parallel. Either the same plane or two that never meet, decided by
149        // whether one contains the other's origin.
150        return if a.distance_to(b.origin()) <= tol.confusion() {
151            Meeting::Same
152        } else {
153            Meeting::Apart
154        };
155    }
156    // The line of intersection runs along both normals' cross product, and
157    // passes through the point nearest the origin that satisfies both planes.
158    let Ok(direction) = Direction::from_cross(a.normal().vector(), b.normal().vector(), tol) else {
159        return Meeting::Apart;
160    };
161    let (da, db) = (
162        a.normal().dot_vector(a.origin().to_vector()),
163        b.normal().dot_vector(b.origin().to_vector()),
164    );
165    let (na, nb) = (a.normal().vector(), b.normal().vector());
166    let dot = na.dot(nb);
167    // sin² of the angle between the normals, from the cross product: one
168    // minus the dot squared loses every digit of a small angle.
169    let denominator = na.cross(nb).square_magnitude();
170    if denominator <= 0.0 {
171        return Meeting::Apart;
172    }
173    let ca = da.mul_add(1.0, -(db * dot)) / denominator;
174    let cb = db.mul_add(1.0, -(da * dot)) / denominator;
175    let through = Point::from_vector(na * ca + nb * cb);
176    Meeting::Along(vec![line_through(through, direction)])
177}
178
179/// A plane and a sphere: apart, a point of tangency, or a circle.
180fn plane_sphere(plane: ogeom_math::Plane, sphere: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
181    let gap = plane.signed_distance_to(sphere.centre());
182    let reach = gap.abs();
183    if reach > sphere.radius() + tol.confusion() {
184        return Meeting::Apart;
185    }
186    let foot = plane.project(sphere.centre());
187    if (reach - sphere.radius()).abs() <= tol.confusion() {
188        return Meeting::Touching(vec![foot]);
189    }
190    // The chord half-length: the leg of a right triangle whose hypotenuse is
191    // the radius and whose other leg is the distance from the centre.
192    let radius = sphere
193        .radius()
194        .mul_add(sphere.radius(), -(gap * gap))
195        .max(0.0)
196        .sqrt();
197    match circle_on(foot, plane.normal(), radius, tol) {
198        Some(circle) => Meeting::Along(vec![circle]),
199        None => Meeting::Touching(vec![foot]),
200    }
201}
202
203/// A plane and a cylinder.
204///
205/// Three genuinely different answers depending on the angle between them, and
206/// the whole reason a closed form is worth having: a circle, an ellipse, or a
207/// pair of straight lines, each exact.
208fn plane_cylinder(
209    plane: ogeom_math::Plane,
210    cylinder: ogeom_math::Cylinder,
211    tol: Tolerances,
212) -> OgeomResult<Meeting> {
213    let axis = cylinder.axis();
214    let along = plane.normal().dot(axis.direction);
215
216    // The plane contains the axis direction: the section is straight lines,
217    // one for each side the plane cuts, or none if it misses.
218    if along.abs() <= tol.angular() {
219        let gap = plane.signed_distance_to(axis.location);
220        let reach = gap.abs();
221        if reach > cylinder.radius() + tol.confusion() {
222            return Ok(Meeting::Apart);
223        }
224        // How far along the plane, from the foot of the axis, each line sits.
225        let offset = cylinder
226            .radius()
227            .mul_add(cylinder.radius(), -(gap * gap))
228            .max(0.0)
229            .sqrt();
230        let foot = plane.project(axis.location);
231        let sideways =
232            Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
233        if offset <= tol.confusion() {
234            // Tangent along one line.
235            return Ok(Meeting::Along(vec![line_through(foot, axis.direction)]));
236        }
237        return Ok(Meeting::Along(vec![
238            line_through(foot + sideways.vector() * offset, axis.direction),
239            line_through(foot - sideways.vector() * offset, axis.direction),
240        ]));
241    }
242
243    // Perpendicular to the axis: a circle of the cylinder's own radius.
244    let centre = intersect_axis_plane(axis, plane, tol)?;
245    if square_to_axis(along, tol) {
246        return Ok(
247            match circle_on(centre, plane.normal(), cylinder.radius(), tol) {
248                Some(circle) => Meeting::Along(vec![circle]),
249                None => Meeting::Apart,
250            },
251        );
252    }
253
254    // Oblique: an ellipse. Its minor axis is the cylinder's radius, across the
255    // slope; its major is that divided by the cosine of the tilt, along it.
256    let minor = cylinder.radius();
257    let major = minor / along.abs();
258    // The minor axis runs where the plane and a plane perpendicular to the axis
259    // agree: the cross of the two normals.
260    let minor_direction =
261        Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
262    let major_direction =
263        Direction::from_cross(minor_direction.vector(), plane.normal().vector(), tol)?;
264    let frame = Frame::from_axes(
265        centre,
266        major_direction,
267        minor_direction,
268        plane.normal(),
269        tol,
270    )?;
271    Ok(Meeting::Along(vec![
272        ogeom_geom::EllipseCurve::new(Ellipse::new(frame, major, minor, tol)?).into(),
273    ]))
274}
275
276/// Two spheres: apart, tangent at a point, the same, or a circle.
277fn sphere_sphere(a: ogeom_math::Sphere, b: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
278    let between = b.centre() - a.centre();
279    let distance = between.magnitude();
280    if distance <= tol.confusion() {
281        return if (a.radius() - b.radius()).abs() <= tol.confusion() {
282            Meeting::Same
283        } else {
284            // Concentric and different: one inside the other, never meeting.
285            Meeting::Apart
286        };
287    }
288    let (ra, rb) = (a.radius(), b.radius());
289    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
290        return Meeting::Apart;
291    }
292    let Ok(direction) = Direction::new(between, tol) else {
293        return Meeting::Apart;
294    };
295    // Where the plane of the intersection circle crosses the line of centres.
296    let reach = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
297    let centre = a.centre() + direction.vector() * reach;
298    let squared = ra.mul_add(ra, -(reach * reach));
299    if squared <= tol.confusion() * tol.confusion() {
300        return Meeting::Touching(vec![centre]);
301    }
302    match circle_on(centre, direction, squared.max(0.0).sqrt(), tol) {
303        Some(circle) => Meeting::Along(vec![circle]),
304        None => Meeting::Touching(vec![centre]),
305    }
306}
307
308/// Two cylinders sharing an axis.
309///
310/// The only cylinder pair with a closed form worth writing down. Two general
311/// cylinders meet in a quartic space curve, which is what the marching
312/// intersector is for.
313fn coaxial_cylinders(
314    a: ogeom_math::Cylinder,
315    b: ogeom_math::Cylinder,
316    tol: Tolerances,
317) -> OgeomResult<Meeting> {
318    if !a.axis().is_coaxial(b.axis(), tol) {
319        // Equal radii with intersecting axes: the one crossing whose quartic
320        // factors, into the two ellipses in the axes' bisector planes, each
321        // an oblique plane section the plane machinery already speaks. The
322        // ellipses cross at the two points where the cylinders are tangent;
323        // that is the crossing's geometry, stated exactly rather than
324        // marched through.
325        if (a.radius() - b.radius()).abs() <= tol.confusion() {
326            let (da, db) = (a.axis().direction.vector(), b.axis().direction.vector());
327            let normal = da.cross(db);
328            if normal.magnitude() > tol.angular() {
329                let (pa, pb) = (a.axis().location, b.axis().location);
330                // Closest points of the two axis lines; coincident when the
331                // axes genuinely intersect.
332                let w = pb - pa;
333                let dd = da.dot(db);
334                let denom = dd.mul_add(-dd, 1.0);
335                let s = dd.mul_add(-db.dot(w), da.dot(w)) / denom;
336                let t = dd.mul_add(da.dot(w), -db.dot(w)) / denom;
337                let on_a = pa + da * s;
338                let on_b = pb + db * t;
339                if on_a.distance(on_b) <= tol.confusion() {
340                    let centre = on_a;
341                    let mut curves = Vec::new();
342                    for m in [da - db, da + db] {
343                        if m.magnitude() <= tol.angular() {
344                            continue;
345                        }
346                        let plane =
347                            ogeom_math::Plane::through(centre, ogeom_math::Direction::new(m, tol)?);
348                        if let Meeting::Along(mut found) = plane_cylinder(plane, a, tol)? {
349                            curves.append(&mut found);
350                        }
351                    }
352                    if !curves.is_empty() {
353                        return Ok(Meeting::Along(curves));
354                    }
355                }
356            }
357        }
358        ogeom_bail!(
359            NotDone,
360            "two cylinders that do not share an axis meet in a quartic space \
361             curve, which needs the general marching intersector"
362        );
363    }
364    Ok(if (a.radius() - b.radius()).abs() <= tol.confusion() {
365        Meeting::Same
366    } else {
367        // Same axis, different radii: one inside the other, touching nowhere.
368        Meeting::Apart
369    })
370}
371
372/// A cylinder and a sphere whose centre is on the cylinder's axis.
373fn coaxial_cylinder_sphere(
374    cylinder: ogeom_math::Cylinder,
375    sphere: ogeom_math::Sphere,
376    tol: Tolerances,
377) -> OgeomResult<Meeting> {
378    let axis = cylinder.axis();
379    if axis.distance_to(sphere.centre()) > tol.confusion() {
380        ogeom_bail!(
381            NotDone,
382            "a sphere off a cylinder's axis meets it in a quartic space curve, \
383             which needs the general marching intersector"
384        );
385    }
386    let (r, radius) = (cylinder.radius(), sphere.radius());
387    if r > radius + tol.confusion() {
388        return Ok(Meeting::Apart);
389    }
390    if (r - radius).abs() <= tol.confusion() {
391        // The sphere's equator lies on the cylinder, and they are tangent
392        // along it rather than crossing.
393        let centre = sphere.centre();
394        return Ok(match circle_on(centre, axis.direction, r, tol) {
395            Some(circle) => Meeting::Along(vec![circle]),
396            None => Meeting::Apart,
397        });
398    }
399    // Two circles, symmetric about the sphere's centre.
400    let reach = radius.mul_add(radius, -(r * r)).max(0.0).sqrt();
401    let mut out = Vec::with_capacity(2);
402    for side in [reach, -reach] {
403        let centre = sphere.centre() + axis.direction.vector() * side;
404        if let Some(circle) = circle_on(centre, axis.direction, r, tol) {
405            out.push(circle);
406        }
407    }
408    Ok(if out.is_empty() {
409        Meeting::Apart
410    } else {
411        Meeting::Along(out)
412    })
413}
414
415/// A plane perpendicular to a torus's axis: apart, one tangent circle, or two
416/// parallels. A plane through the axis: two meridians.
417///
418/// Those are the plane/torus configurations with a closed form worth the
419/// name: an oblique plane, or one parallel to the axis and off it, meets a
420/// torus in a quartic (with Villarceau's circles at exactly one magic
421/// tilt), and that is the marching intersector's business. A plane through
422/// the axis often holds the torus's seam, and a fitted section there never
423/// meets the seam's own vertices. The blend machinery lives on this case:
424/// a rolling ball's toroidal envelope is tangent to the plane it rolls on
425/// along a circle, and that tangency must be *reported as the circle it is*,
426/// the way a tangent plane reports its line on a cylinder; a tangential
427/// answer with no curve in it would send the boolean above into a refusal.
428fn axial_plane_torus(
429    plane: ogeom_math::Plane,
430    torus: ogeom_math::Torus,
431    tol: Tolerances,
432) -> OgeomResult<Meeting> {
433    let axis = torus.axis();
434    let along = plane.normal().dot(axis.direction);
435    if along.abs() <= tol.angular()
436        && plane.signed_distance_to(axis.location).abs() <= tol.confusion()
437    {
438        return Ok(meridians(plane, torus, tol));
439    }
440    if !square_to_axis(along, tol) {
441        ogeom_bail!(
442            NotDone,
443            "a plane oblique to a torus's axis, or parallel to it and off it, \
444             meets it in a quartic, which needs the general marching \
445             intersector"
446        );
447    }
448    // The plane's height above the tube's centre plane.
449    let height = -plane.signed_distance_to(axis.location) * along.signum();
450    let minor = torus.minor_radius();
451    if height.abs() > minor + tol.confusion() {
452        return Ok(Meeting::Apart);
453    }
454    let centre = axis.location + axis.direction.vector() * height;
455    if (height.abs() - minor).abs() <= tol.confusion() {
456        // Tangent along the parallel at the tube's top or bottom.
457        return Ok(
458            match circle_on(centre, axis.direction, torus.major_radius(), tol) {
459                Some(circle) => Meeting::Along(vec![circle]),
460                None => Meeting::Apart,
461            },
462        );
463    }
464    // Two parallels, one either side of the tube, the inner one only where
465    // the tube does not swallow the axis.
466    let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
467    let circles: Vec<Curve> = [torus.major_radius() + spread, torus.major_radius() - spread]
468        .into_iter()
469        .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
470        .collect();
471    Ok(if circles.is_empty() {
472        Meeting::Apart
473    } else {
474        Meeting::Along(circles)
475    })
476}
477
478/// A plane through a torus's axis: the two tube circles either side of the
479/// axis, each starting on the outer equator as the torus's own meridians
480/// do, so a section lying on the torus's seam starts where the seam does.
481fn meridians(plane: ogeom_math::Plane, torus: ogeom_math::Torus, tol: Tolerances) -> Meeting {
482    let axis = torus.axis();
483    let normal = plane.normal();
484    let Ok(out) = Direction::from_cross(axis.direction.vector(), normal.vector(), tol) else {
485        return Meeting::Apart;
486    };
487    let circles: Vec<Curve> = [out.vector(), -out.vector()]
488        .into_iter()
489        .filter_map(|radial| {
490            let centre = axis.location + radial * torus.major_radius();
491            let x = Direction::new(radial, tol).ok()?;
492            let frame = Frame::new(centre, normal, x, tol).ok()?;
493            let circle = Circle::new(frame, torus.minor_radius(), tol).ok()?;
494            Some(ogeom_geom::CircleCurve::new(circle).into())
495        })
496        .collect();
497    Meeting::Along(circles)
498}
499
500/// A cylinder sharing a torus's axis: apart, one tangent circle, or two
501/// parallels at mirrored heights.
502fn coaxial_cylinder_torus(
503    cylinder: ogeom_math::Cylinder,
504    torus: ogeom_math::Torus,
505    tol: Tolerances,
506) -> OgeomResult<Meeting> {
507    if !cylinder.axis().is_coaxial(torus.axis(), tol) {
508        ogeom_bail!(
509            NotDone,
510            "a cylinder off a torus's axis meets it in a quartic space curve, \
511             which needs the general marching intersector"
512        );
513    }
514    let axis = torus.axis();
515    let reach = (cylinder.radius() - torus.major_radius()).abs();
516    let minor = torus.minor_radius();
517    if reach > minor + tol.confusion() {
518        return Ok(Meeting::Apart);
519    }
520    if (reach - minor).abs() <= tol.confusion() {
521        // Tangent along the tube's inner or outer equator.
522        return Ok(
523            match circle_on(axis.location, axis.direction, cylinder.radius(), tol) {
524                Some(circle) => Meeting::Along(vec![circle]),
525                None => Meeting::Apart,
526            },
527        );
528    }
529    let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
530    let circles: Vec<Curve> = [rise, -rise]
531        .into_iter()
532        .filter_map(|height| {
533            circle_on(
534                axis.location + axis.direction.vector() * height,
535                axis.direction,
536                cylinder.radius(),
537                tol,
538            )
539        })
540        .collect();
541    Ok(if circles.is_empty() {
542        Meeting::Apart
543    } else {
544        Meeting::Along(circles)
545    })
546}
547
548/// A plane square to a cone's axis: the parallel at that height, or the apex.
549///
550/// The perpendicular slice is the configuration the rebuilds lean on (a
551/// drafted wall's cap, a chamfer cone against the face it melts into), and
552/// the answer is a circle framed on the cone's own frame, so a caller
553/// re-deriving an edge finds its parameters where the old ones were. An
554/// oblique plane meets a cone in a conic, which is the marching
555/// intersector's business.
556fn plane_cone(
557    plane: ogeom_math::Plane,
558    cone: ogeom_math::Cone,
559    tol: Tolerances,
560) -> OgeomResult<Meeting> {
561    let axis = cone.axis();
562    let along = plane.normal().dot(axis.direction);
563    if !square_to_axis(along, tol) {
564        ogeom_bail!(
565            NotDone,
566            "a plane oblique to a cone's axis meets it in a conic, which \
567             needs the general marching intersector"
568        );
569    }
570    // The plane's height along the axis, from the cone frame's origin.
571    let height = -plane.signed_distance_to(axis.location) * along.signum();
572    let radius = cone.radius_at(height);
573    if radius.abs() <= tol.confusion() {
574        // The plane passes through the apex, where the parallel has no
575        // length: a touch, not a curve.
576        return Ok(Meeting::Touching(vec![cone.apex()]));
577    }
578    if radius < 0.0 {
579        // Past the apex the chart runs mirrored (the same points sit half a
580        // turn out of phase), and a parallel reported there would carry the
581        // wrong parameters into everything downstream. Deferred, not guessed.
582        ogeom_bail!(
583            NotDone,
584            "the plane crosses the cone past its apex, where the chart runs \
585             mirrored; that configuration needs the general machinery"
586        );
587    }
588    let centre = axis.location + axis.direction.vector() * height;
589    Ok(match cone_parallel(&cone, centre, radius, tol) {
590        Some(circle) => Meeting::Along(vec![circle]),
591        None => Meeting::Apart,
592    })
593}
594
595/// A cylinder sharing a cone's axis: the parallel where the slant crosses
596/// the cylinder's radius.
597///
598/// The radius function is linear in height, so it crosses any radius exactly
599/// once on the chart's own nappe: the parallel reported here. The mirrored
600/// crossing past the apex is real geometry, but its parameters run half a
601/// turn out of phase and a curve carrying them would poison every consumer;
602/// a face reaching past its own apex is not a configuration this vocabulary
603/// builds.
604fn coaxial_cylinder_cone(
605    cylinder: ogeom_math::Cylinder,
606    cone: ogeom_math::Cone,
607    tol: Tolerances,
608) -> OgeomResult<Meeting> {
609    if !cylinder.axis().is_coaxial(cone.axis(), tol) {
610        ogeom_bail!(
611            NotDone,
612            "a cylinder off a cone's axis meets it in a curve only the \
613             general marching intersector can trace"
614        );
615    }
616    let axis = cone.axis();
617    let slope = cone.half_angle().tan();
618    let height = (cylinder.radius() - cone.reference_radius()) / slope;
619    Ok(
620        match cone_parallel(
621            &cone,
622            axis.location + axis.direction.vector() * height,
623            cylinder.radius(),
624            tol,
625        ) {
626            Some(circle) => Meeting::Along(vec![circle]),
627            None => Meeting::Apart,
628        },
629    )
630}
631
632/// Two cones sharing an axis: the same surface, the shared apex, or the
633/// parallel where the slants cross.
634///
635/// In height–radius coordinates along the shared axis each cone is a line,
636/// and the crossing is one linear equation; the parallel there is a circle
637/// unless it lands on the apex, which is a touch.
638fn coaxial_cones(
639    a: ogeom_math::Cone,
640    b: ogeom_math::Cone,
641    tol: Tolerances,
642) -> OgeomResult<Meeting> {
643    if !a.axis().is_coaxial(b.axis(), tol) {
644        ogeom_bail!(
645            NotDone,
646            "two cones that do not share an axis meet in a curve only the \
647             general marching intersector can trace"
648        );
649    }
650    let axis = a.axis();
651    // Both radius functions expressed against `a`'s height origin. The axes
652    // share a sense (`is_coaxial` checked), so the slopes compare directly.
653    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
654    let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
655    let (ref_a, ref_b) = (
656        a.reference_radius(),
657        slope_b.mul_add(-lift, b.reference_radius()),
658    );
659    if (slope_a - slope_b).abs() <= tol.angular() {
660        // Parallel slants: the same cone, or two that never meet.
661        return Ok(if (ref_a - ref_b).abs() <= tol.confusion() {
662            Meeting::Same
663        } else {
664            Meeting::Apart
665        });
666    }
667    // One linear equation: where the radius lines cross on the charts' own
668    // nappes. The mirrored-nappe crossings are real geometry with the wrong
669    // parameters (same reasoning as the cylinder) and stay deferred.
670    let height = (ref_b - ref_a) / (slope_a - slope_b);
671    let radius = a.radius_at(height);
672    if radius.abs() <= tol.confusion() {
673        // The radius lines cross at zero: a shared apex, a touch.
674        return Ok(Meeting::Touching(vec![a.apex()]));
675    }
676    if radius < 0.0 {
677        ogeom_bail!(
678            NotDone,
679            "two coaxial cones that meet only past their apexes, where the \
680             charts run mirrored, need the general machinery"
681        );
682    }
683    Ok(
684        match cone_parallel(
685            &a,
686            axis.location + axis.direction.vector() * height,
687            radius,
688            tol,
689        ) {
690            Some(circle) => Meeting::Along(vec![circle]),
691            None => Meeting::Apart,
692        },
693    )
694}
695
696/// A parallel of a cone, framed on the cone's own frame so parameters carry.
697fn cone_parallel(
698    cone: &ogeom_math::Cone,
699    centre: Point,
700    radius: f64,
701    tol: Tolerances,
702) -> Option<Curve> {
703    if radius <= tol.confusion() {
704        return None;
705    }
706    let frame = cone.frame();
707    let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
708    Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
709}
710
711/// Two tori sharing an axis: the same surface, apart, or circles where the
712/// tube profiles cross.
713///
714/// In the shared meridian half-plane the two tubes are two circles, and
715/// revolving their meetings gives the answer: radical-line algebra in the
716/// `(distance-from-axis, height)` plane, each solution a parallel.
717fn coaxial_tori(
718    a: ogeom_math::Torus,
719    b: ogeom_math::Torus,
720    tol: Tolerances,
721) -> OgeomResult<Meeting> {
722    if !a.axis().is_coaxial(b.axis(), tol) {
723        ogeom_bail!(
724            NotDone,
725            "two tori that do not share an axis meet in a curve only the \
726             general marching intersector can trace"
727        );
728    }
729    let axis = a.axis();
730    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
731    if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
732        && lift.abs() <= tol.confusion()
733        && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
734    {
735        return Ok(Meeting::Same);
736    }
737    // Profile circles in the meridian half-plane: centres at
738    // `(major, height)`, radii the minors.
739    let (ca, cb) = (
740        ogeom_math::Point2::new(a.major_radius(), 0.0),
741        ogeom_math::Point2::new(b.major_radius(), lift),
742    );
743    let between = cb - ca;
744    let distance = between.magnitude();
745    let (ra, rb) = (a.minor_radius(), b.minor_radius());
746    if distance <= tol.confusion() {
747        // Concentric profiles of different tube radii never meet; the same
748        // circle was the `Same` case above.
749        return Ok(Meeting::Apart);
750    }
751    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
752        return Ok(Meeting::Apart);
753    }
754    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
755    let squared = ra.mul_add(ra, -(along * along));
756    let direction = between * (1.0 / distance);
757    let foot = ca + direction * along;
758    let mut profile_points = Vec::new();
759    if squared <= tol.confusion() * tol.confusion() {
760        profile_points.push(foot);
761    } else {
762        let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
763        profile_points.push(foot + offset);
764        profile_points.push(foot - offset);
765    }
766    let circles: Vec<Curve> = profile_points
767        .into_iter()
768        .filter_map(|p| {
769            circle_on(
770                axis.location + axis.direction.vector() * p.y,
771                axis.direction,
772                p.x,
773                tol,
774            )
775        })
776        .collect();
777    Ok(if circles.is_empty() {
778        Meeting::Apart
779    } else {
780        Meeting::Along(circles)
781    })
782}
783
784/// Where an axis crosses a plane.
785fn intersect_axis_plane(
786    axis: ogeom_math::Axis,
787    plane: ogeom_math::Plane,
788    tol: Tolerances,
789) -> OgeomResult<Point> {
790    let along = plane.normal().dot(axis.direction);
791    if along.abs() <= tol.angular() {
792        ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
793    }
794    let t = -plane.signed_distance_to(axis.location) / along;
795    Ok(axis.location + axis.direction.vector() * t)
796}
797
798/// A full circle in the plane through `centre` with the given normal.
799fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
800    if radius <= tol.confusion() {
801        return None;
802    }
803    // Any perpendicular will do for where the parameterization starts.
804    let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
805        Vector::X
806    } else {
807        Vector::Y
808    };
809    let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
810    let frame = Frame::new(centre, normal, x, tol).ok()?;
811    Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
812}
813
814/// An unbounded line through a point.
815fn line_through(through: Point, direction: Direction) -> Curve {
816    ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
817}