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(p.plane(), q.plane(), tol)),
76        (S::Plane(p), S::Sphere(s)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
77        (S::Sphere(s), S::Plane(p)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
78        (S::Plane(p), S::Cylinder(c)) => plane_cylinder(p.plane(), c.cylinder(), tol),
79        (S::Cylinder(c), S::Plane(p)) => plane_cylinder(p.plane(), c.cylinder(), tol),
80        (S::Sphere(x), S::Sphere(y)) => Ok(sphere_sphere(x.sphere(), y.sphere(), tol)),
81        (S::Cylinder(x), S::Cylinder(y)) => coaxial_cylinders(x.cylinder(), y.cylinder(), tol),
82        (S::Cylinder(c), S::Sphere(s)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
83        (S::Sphere(s), S::Cylinder(c)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
84        (S::Plane(p), S::Torus(t)) => axial_plane_torus(p.plane(), t.torus(), tol),
85        (S::Torus(t), S::Plane(p)) => axial_plane_torus(p.plane(), t.torus(), tol),
86        (S::Cylinder(c), S::Torus(t)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
87        (S::Torus(t), S::Cylinder(c)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
88        (S::Torus(x), S::Torus(y)) => coaxial_tori(x.torus(), y.torus(), tol),
89        (S::Plane(p), S::Cone(c)) => plane_cone(p.plane(), c.cone(), tol),
90        (S::Cone(c), S::Plane(p)) => plane_cone(p.plane(), c.cone(), tol),
91        (S::Cylinder(x), S::Cone(c)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
92        (S::Cone(c), S::Cylinder(x)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
93        (S::Cone(x), S::Cone(y)) => coaxial_cones(x.cone(), y.cone(), tol),
94        _ => ogeom_bail!(
95            NotDone,
96            "this pair of surfaces has no closed-form intersection; it needs \
97             the general marching intersector, which is gated on the benchmark \
98             these cases provide the ground truth for"
99        ),
100    }
101}
102
103/// Two planes: apart, the same, or a line.
104fn plane_plane(a: ogeom_math::Plane, b: ogeom_math::Plane, tol: Tolerances) -> Meeting {
105    let along = a.normal().dot(b.normal());
106    if (along.abs() - 1.0).abs() <= tol.angular() {
107        // Parallel. Either the same plane or two that never meet, decided by
108        // whether one contains the other's origin.
109        return if a.distance_to(b.origin()) <= tol.confusion() {
110            Meeting::Same
111        } else {
112            Meeting::Apart
113        };
114    }
115    // The line of intersection runs along both normals' cross product, and
116    // passes through the point nearest the origin that satisfies both planes.
117    let Ok(direction) = Direction::from_cross(a.normal().vector(), b.normal().vector(), tol) else {
118        return Meeting::Apart;
119    };
120    let (da, db) = (
121        a.normal().dot_vector(a.origin().to_vector()),
122        b.normal().dot_vector(b.origin().to_vector()),
123    );
124    let (na, nb) = (a.normal().vector(), b.normal().vector());
125    let dot = na.dot(nb);
126    let denominator = dot.mul_add(-dot, 1.0);
127    if denominator.abs() <= tol.angular() {
128        return Meeting::Apart;
129    }
130    let ca = da.mul_add(1.0, -(db * dot)) / denominator;
131    let cb = db.mul_add(1.0, -(da * dot)) / denominator;
132    let through = Point::from_vector(na * ca + nb * cb);
133    Meeting::Along(vec![line_through(through, direction)])
134}
135
136/// A plane and a sphere: apart, a point of tangency, or a circle.
137fn plane_sphere(plane: ogeom_math::Plane, sphere: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
138    let gap = plane.signed_distance_to(sphere.centre());
139    let reach = gap.abs();
140    if reach > sphere.radius() + tol.confusion() {
141        return Meeting::Apart;
142    }
143    let foot = plane.project(sphere.centre());
144    if (reach - sphere.radius()).abs() <= tol.confusion() {
145        return Meeting::Touching(vec![foot]);
146    }
147    // The chord half-length: the leg of a right triangle whose hypotenuse is
148    // the radius and whose other leg is the distance from the centre.
149    let radius = sphere
150        .radius()
151        .mul_add(sphere.radius(), -(gap * gap))
152        .max(0.0)
153        .sqrt();
154    match circle_on(foot, plane.normal(), radius, tol) {
155        Some(circle) => Meeting::Along(vec![circle]),
156        None => Meeting::Touching(vec![foot]),
157    }
158}
159
160/// A plane and a cylinder.
161///
162/// Three genuinely different answers depending on the angle between them, and
163/// the whole reason a closed form is worth having: a circle, an ellipse, or a
164/// pair of straight lines, each exact.
165fn plane_cylinder(
166    plane: ogeom_math::Plane,
167    cylinder: ogeom_math::Cylinder,
168    tol: Tolerances,
169) -> OgeomResult<Meeting> {
170    let axis = cylinder.axis();
171    let along = plane.normal().dot(axis.direction);
172
173    // The plane contains the axis direction: the section is straight lines,
174    // one for each side the plane cuts, or none if it misses.
175    if along.abs() <= tol.angular() {
176        let gap = plane.signed_distance_to(axis.location);
177        let reach = gap.abs();
178        if reach > cylinder.radius() + tol.confusion() {
179            return Ok(Meeting::Apart);
180        }
181        // How far along the plane, from the foot of the axis, each line sits.
182        let offset = cylinder
183            .radius()
184            .mul_add(cylinder.radius(), -(gap * gap))
185            .max(0.0)
186            .sqrt();
187        let foot = plane.project(axis.location);
188        let sideways =
189            Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
190        if offset <= tol.confusion() {
191            // Tangent along one line.
192            return Ok(Meeting::Along(vec![line_through(foot, axis.direction)]));
193        }
194        return Ok(Meeting::Along(vec![
195            line_through(foot + sideways.vector() * offset, axis.direction),
196            line_through(foot - sideways.vector() * offset, axis.direction),
197        ]));
198    }
199
200    // Perpendicular to the axis: a circle of the cylinder's own radius.
201    let centre = intersect_axis_plane(axis, plane, tol)?;
202    if (along.abs() - 1.0).abs() <= tol.angular() {
203        return Ok(
204            match circle_on(centre, plane.normal(), cylinder.radius(), tol) {
205                Some(circle) => Meeting::Along(vec![circle]),
206                None => Meeting::Apart,
207            },
208        );
209    }
210
211    // Oblique: an ellipse. Its minor axis is the cylinder's radius, across the
212    // slope; its major is that divided by the cosine of the tilt, along it.
213    let minor = cylinder.radius();
214    let major = minor / along.abs();
215    // The minor axis runs where the plane and a plane perpendicular to the axis
216    // agree — the cross of the two normals.
217    let minor_direction =
218        Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
219    let major_direction =
220        Direction::from_cross(minor_direction.vector(), plane.normal().vector(), tol)?;
221    let frame = Frame::from_axes(
222        centre,
223        major_direction,
224        minor_direction,
225        plane.normal(),
226        tol,
227    )?;
228    Ok(Meeting::Along(vec![
229        ogeom_geom::EllipseCurve::new(Ellipse::new(frame, major, minor, tol)?).into(),
230    ]))
231}
232
233/// Two spheres: apart, tangent at a point, the same, or a circle.
234fn sphere_sphere(a: ogeom_math::Sphere, b: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
235    let between = b.centre() - a.centre();
236    let distance = between.magnitude();
237    if distance <= tol.confusion() {
238        return if (a.radius() - b.radius()).abs() <= tol.confusion() {
239            Meeting::Same
240        } else {
241            // Concentric and different: one inside the other, never meeting.
242            Meeting::Apart
243        };
244    }
245    let (ra, rb) = (a.radius(), b.radius());
246    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
247        return Meeting::Apart;
248    }
249    let Ok(direction) = Direction::new(between, tol) else {
250        return Meeting::Apart;
251    };
252    // Where the plane of the intersection circle crosses the line of centres.
253    let reach = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
254    let centre = a.centre() + direction.vector() * reach;
255    let squared = ra.mul_add(ra, -(reach * reach));
256    if squared <= tol.confusion() * tol.confusion() {
257        return Meeting::Touching(vec![centre]);
258    }
259    match circle_on(centre, direction, squared.max(0.0).sqrt(), tol) {
260        Some(circle) => Meeting::Along(vec![circle]),
261        None => Meeting::Touching(vec![centre]),
262    }
263}
264
265/// Two cylinders sharing an axis.
266///
267/// The only cylinder pair with a closed form worth writing down. Two general
268/// cylinders meet in a quartic space curve, which is what the marching
269/// intersector is for.
270fn coaxial_cylinders(
271    a: ogeom_math::Cylinder,
272    b: ogeom_math::Cylinder,
273    tol: Tolerances,
274) -> OgeomResult<Meeting> {
275    if !a.axis().is_coaxial(b.axis(), tol) {
276        // Equal radii with intersecting axes: the one crossing whose quartic
277        // factors — into the two ellipses in the axes' bisector planes, each
278        // an oblique plane section the plane machinery already speaks. The
279        // ellipses cross at the two points where the cylinders are tangent;
280        // that is the crossing's geometry, stated exactly rather than
281        // marched through.
282        if (a.radius() - b.radius()).abs() <= tol.confusion() {
283            let (da, db) = (a.axis().direction.vector(), b.axis().direction.vector());
284            let normal = da.cross(db);
285            if normal.magnitude() > tol.angular() {
286                let (pa, pb) = (a.axis().location, b.axis().location);
287                // Closest points of the two axis lines; coincident when the
288                // axes genuinely intersect.
289                let w = pb - pa;
290                let dd = da.dot(db);
291                let denom = dd.mul_add(-dd, 1.0);
292                let s = dd.mul_add(-db.dot(w), da.dot(w)) / denom;
293                let t = dd.mul_add(da.dot(w), -db.dot(w)) / denom;
294                let on_a = pa + da * s;
295                let on_b = pb + db * t;
296                if on_a.distance(on_b) <= tol.confusion() {
297                    let centre = on_a;
298                    let mut curves = Vec::new();
299                    for m in [da - db, da + db] {
300                        if m.magnitude() <= tol.angular() {
301                            continue;
302                        }
303                        let plane =
304                            ogeom_math::Plane::through(centre, ogeom_math::Direction::new(m, tol)?);
305                        if let Meeting::Along(mut found) = plane_cylinder(plane, a, tol)? {
306                            curves.append(&mut found);
307                        }
308                    }
309                    if !curves.is_empty() {
310                        return Ok(Meeting::Along(curves));
311                    }
312                }
313            }
314        }
315        ogeom_bail!(
316            NotDone,
317            "two cylinders that do not share an axis meet in a quartic space \
318             curve, which needs the general marching intersector"
319        );
320    }
321    Ok(if (a.radius() - b.radius()).abs() <= tol.confusion() {
322        Meeting::Same
323    } else {
324        // Same axis, different radii: one inside the other, touching nowhere.
325        Meeting::Apart
326    })
327}
328
329/// A cylinder and a sphere whose centre is on the cylinder's axis.
330fn coaxial_cylinder_sphere(
331    cylinder: ogeom_math::Cylinder,
332    sphere: ogeom_math::Sphere,
333    tol: Tolerances,
334) -> OgeomResult<Meeting> {
335    let axis = cylinder.axis();
336    if axis.distance_to(sphere.centre()) > tol.confusion() {
337        ogeom_bail!(
338            NotDone,
339            "a sphere off a cylinder's axis meets it in a quartic space curve, \
340             which needs the general marching intersector"
341        );
342    }
343    let (r, radius) = (cylinder.radius(), sphere.radius());
344    if r > radius + tol.confusion() {
345        return Ok(Meeting::Apart);
346    }
347    if (r - radius).abs() <= tol.confusion() {
348        // The sphere's equator lies on the cylinder, and they are tangent
349        // along it rather than crossing.
350        let centre = sphere.centre();
351        return Ok(match circle_on(centre, axis.direction, r, tol) {
352            Some(circle) => Meeting::Along(vec![circle]),
353            None => Meeting::Apart,
354        });
355    }
356    // Two circles, symmetric about the sphere's centre.
357    let reach = radius.mul_add(radius, -(r * r)).max(0.0).sqrt();
358    let mut out = Vec::with_capacity(2);
359    for side in [reach, -reach] {
360        let centre = sphere.centre() + axis.direction.vector() * side;
361        if let Some(circle) = circle_on(centre, axis.direction, r, tol) {
362            out.push(circle);
363        }
364    }
365    Ok(if out.is_empty() {
366        Meeting::Apart
367    } else {
368        Meeting::Along(out)
369    })
370}
371
372/// A plane perpendicular to a torus's axis: apart, one tangent circle, or two
373/// parallels.
374///
375/// The perpendicular slice is the only plane/torus configuration with a
376/// closed form worth the name — an oblique plane meets a torus in a quartic
377/// (with Villarceau's circles at exactly one magic tilt), and that is the
378/// marching intersector's business. The blend machinery lives on this case:
379/// a rolling ball's toroidal envelope is tangent to the plane it rolls on
380/// along a circle, and that tangency must be *reported as the circle it is*,
381/// the way a tangent plane reports its line on a cylinder — a tangential
382/// answer with no curve in it would send the boolean above into a refusal.
383fn axial_plane_torus(
384    plane: ogeom_math::Plane,
385    torus: ogeom_math::Torus,
386    tol: Tolerances,
387) -> OgeomResult<Meeting> {
388    let axis = torus.axis();
389    let along = plane.normal().dot(axis.direction);
390    if (along.abs() - 1.0).abs() > tol.angular() {
391        ogeom_bail!(
392            NotDone,
393            "a plane oblique or parallel to a torus's axis meets it in a \
394             quartic, which needs the general marching intersector"
395        );
396    }
397    // The plane's height above the tube's centre plane.
398    let height = -plane.signed_distance_to(axis.location) * along.signum();
399    let minor = torus.minor_radius();
400    if height.abs() > minor + tol.confusion() {
401        return Ok(Meeting::Apart);
402    }
403    let centre = axis.location + axis.direction.vector() * height;
404    if (height.abs() - minor).abs() <= tol.confusion() {
405        // Tangent along the parallel at the tube's top or bottom.
406        return Ok(
407            match circle_on(centre, axis.direction, torus.major_radius(), tol) {
408                Some(circle) => Meeting::Along(vec![circle]),
409                None => Meeting::Apart,
410            },
411        );
412    }
413    // Two parallels, one either side of the tube — the inner one only where
414    // the tube does not swallow the axis.
415    let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
416    let circles: Vec<Curve> = [torus.major_radius() + spread, torus.major_radius() - spread]
417        .into_iter()
418        .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
419        .collect();
420    Ok(if circles.is_empty() {
421        Meeting::Apart
422    } else {
423        Meeting::Along(circles)
424    })
425}
426
427/// A cylinder sharing a torus's axis: apart, one tangent circle, or two
428/// parallels at mirrored heights.
429fn coaxial_cylinder_torus(
430    cylinder: ogeom_math::Cylinder,
431    torus: ogeom_math::Torus,
432    tol: Tolerances,
433) -> OgeomResult<Meeting> {
434    if !cylinder.axis().is_coaxial(torus.axis(), tol) {
435        ogeom_bail!(
436            NotDone,
437            "a cylinder off a torus's axis meets it in a quartic space curve, \
438             which needs the general marching intersector"
439        );
440    }
441    let axis = torus.axis();
442    let reach = (cylinder.radius() - torus.major_radius()).abs();
443    let minor = torus.minor_radius();
444    if reach > minor + tol.confusion() {
445        return Ok(Meeting::Apart);
446    }
447    if (reach - minor).abs() <= tol.confusion() {
448        // Tangent along the tube's inner or outer equator.
449        return Ok(
450            match circle_on(axis.location, axis.direction, cylinder.radius(), tol) {
451                Some(circle) => Meeting::Along(vec![circle]),
452                None => Meeting::Apart,
453            },
454        );
455    }
456    let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
457    let circles: Vec<Curve> = [rise, -rise]
458        .into_iter()
459        .filter_map(|height| {
460            circle_on(
461                axis.location + axis.direction.vector() * height,
462                axis.direction,
463                cylinder.radius(),
464                tol,
465            )
466        })
467        .collect();
468    Ok(if circles.is_empty() {
469        Meeting::Apart
470    } else {
471        Meeting::Along(circles)
472    })
473}
474
475/// A plane square to a cone's axis: the parallel at that height, or the apex.
476///
477/// The perpendicular slice is the configuration the rebuilds lean on — a
478/// drafted wall's cap, a chamfer cone against the face it melts into — and
479/// the answer is a circle framed on the cone's own frame, so a caller
480/// re-deriving an edge finds its parameters where the old ones were. An
481/// oblique plane meets a cone in a conic, which is the marching
482/// intersector's business.
483fn plane_cone(
484    plane: ogeom_math::Plane,
485    cone: ogeom_math::Cone,
486    tol: Tolerances,
487) -> OgeomResult<Meeting> {
488    let axis = cone.axis();
489    let along = plane.normal().dot(axis.direction);
490    if (along.abs() - 1.0).abs() > tol.angular() {
491        ogeom_bail!(
492            NotDone,
493            "a plane oblique to a cone's axis meets it in a conic, which \
494             needs the general marching intersector"
495        );
496    }
497    // The plane's height along the axis, from the cone frame's origin.
498    let height = -plane.signed_distance_to(axis.location) * along.signum();
499    let radius = cone.radius_at(height);
500    if radius.abs() <= tol.confusion() {
501        // The plane passes through the apex, where the parallel has no
502        // length: a touch, not a curve.
503        return Ok(Meeting::Touching(vec![cone.apex()]));
504    }
505    if radius < 0.0 {
506        // Past the apex the chart runs mirrored — the same points sit half a
507        // turn out of phase — and a parallel reported there would carry the
508        // wrong parameters into everything downstream. Deferred, not guessed.
509        ogeom_bail!(
510            NotDone,
511            "the plane crosses the cone past its apex, where the chart runs \
512             mirrored; that configuration needs the general machinery"
513        );
514    }
515    let centre = axis.location + axis.direction.vector() * height;
516    Ok(match cone_parallel(&cone, centre, radius, tol) {
517        Some(circle) => Meeting::Along(vec![circle]),
518        None => Meeting::Apart,
519    })
520}
521
522/// A cylinder sharing a cone's axis: the parallel where the slant crosses
523/// the cylinder's radius.
524///
525/// The radius function is linear in height, so it crosses any radius exactly
526/// once on the chart's own nappe — the parallel reported here. The mirrored
527/// crossing past the apex is real geometry, but its parameters run half a
528/// turn out of phase and a curve carrying them would poison every consumer;
529/// a face reaching past its own apex is not a configuration this vocabulary
530/// builds.
531fn coaxial_cylinder_cone(
532    cylinder: ogeom_math::Cylinder,
533    cone: ogeom_math::Cone,
534    tol: Tolerances,
535) -> OgeomResult<Meeting> {
536    if !cylinder.axis().is_coaxial(cone.axis(), tol) {
537        ogeom_bail!(
538            NotDone,
539            "a cylinder off a cone's axis meets it in a curve only the \
540             general marching intersector can trace"
541        );
542    }
543    let axis = cone.axis();
544    let slope = cone.half_angle().tan();
545    let height = (cylinder.radius() - cone.reference_radius()) / slope;
546    Ok(
547        match cone_parallel(
548            &cone,
549            axis.location + axis.direction.vector() * height,
550            cylinder.radius(),
551            tol,
552        ) {
553            Some(circle) => Meeting::Along(vec![circle]),
554            None => Meeting::Apart,
555        },
556    )
557}
558
559/// Two cones sharing an axis: the same surface, the shared apex, or the
560/// parallel where the slants cross.
561///
562/// In height–radius coordinates along the shared axis each cone is a line,
563/// and the crossing is one linear equation; the parallel there is a circle
564/// unless it lands on the apex, which is a touch.
565fn coaxial_cones(
566    a: ogeom_math::Cone,
567    b: ogeom_math::Cone,
568    tol: Tolerances,
569) -> OgeomResult<Meeting> {
570    if !a.axis().is_coaxial(b.axis(), tol) {
571        ogeom_bail!(
572            NotDone,
573            "two cones that do not share an axis meet in a curve only the \
574             general marching intersector can trace"
575        );
576    }
577    let axis = a.axis();
578    // Both radius functions expressed against `a`'s height origin. The axes
579    // share a sense — `is_coaxial` checked — so the slopes compare directly.
580    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
581    let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
582    let (ref_a, ref_b) = (
583        a.reference_radius(),
584        slope_b.mul_add(-lift, b.reference_radius()),
585    );
586    if (slope_a - slope_b).abs() <= tol.angular() {
587        // Parallel slants: the same cone, or two that never meet.
588        return Ok(if (ref_a - ref_b).abs() <= tol.confusion() {
589            Meeting::Same
590        } else {
591            Meeting::Apart
592        });
593    }
594    // One linear equation: where the radius lines cross on the charts' own
595    // nappes. The mirrored-nappe crossings are real geometry with the wrong
596    // parameters — same reasoning as the cylinder — and stay deferred.
597    let height = (ref_b - ref_a) / (slope_a - slope_b);
598    let radius = a.radius_at(height);
599    if radius.abs() <= tol.confusion() {
600        // The radius lines cross at zero: a shared apex, a touch.
601        return Ok(Meeting::Touching(vec![a.apex()]));
602    }
603    if radius < 0.0 {
604        ogeom_bail!(
605            NotDone,
606            "two coaxial cones that meet only past their apexes, where the \
607             charts run mirrored, need the general machinery"
608        );
609    }
610    Ok(
611        match cone_parallel(
612            &a,
613            axis.location + axis.direction.vector() * height,
614            radius,
615            tol,
616        ) {
617            Some(circle) => Meeting::Along(vec![circle]),
618            None => Meeting::Apart,
619        },
620    )
621}
622
623/// A parallel of a cone, framed on the cone's own frame so parameters carry.
624fn cone_parallel(
625    cone: &ogeom_math::Cone,
626    centre: Point,
627    radius: f64,
628    tol: Tolerances,
629) -> Option<Curve> {
630    if radius <= tol.confusion() {
631        return None;
632    }
633    let frame = cone.frame();
634    let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
635    Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
636}
637
638/// Two tori sharing an axis: the same surface, apart, or circles where the
639/// tube profiles cross.
640///
641/// In the shared meridian half-plane the two tubes are two circles, and
642/// revolving their meetings gives the answer: radical-line algebra in the
643/// `(distance-from-axis, height)` plane, each solution a parallel.
644fn coaxial_tori(
645    a: ogeom_math::Torus,
646    b: ogeom_math::Torus,
647    tol: Tolerances,
648) -> OgeomResult<Meeting> {
649    if !a.axis().is_coaxial(b.axis(), tol) {
650        ogeom_bail!(
651            NotDone,
652            "two tori that do not share an axis meet in a curve only the \
653             general marching intersector can trace"
654        );
655    }
656    let axis = a.axis();
657    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
658    if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
659        && lift.abs() <= tol.confusion()
660        && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
661    {
662        return Ok(Meeting::Same);
663    }
664    // Profile circles in the meridian half-plane: centres at
665    // `(major, height)`, radii the minors.
666    let (ca, cb) = (
667        ogeom_math::Point2::new(a.major_radius(), 0.0),
668        ogeom_math::Point2::new(b.major_radius(), lift),
669    );
670    let between = cb - ca;
671    let distance = between.magnitude();
672    let (ra, rb) = (a.minor_radius(), b.minor_radius());
673    if distance <= tol.confusion() {
674        // Concentric profiles of different tube radii never meet; the same
675        // circle was the `Same` case above.
676        return Ok(Meeting::Apart);
677    }
678    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
679        return Ok(Meeting::Apart);
680    }
681    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
682    let squared = ra.mul_add(ra, -(along * along));
683    let direction = between * (1.0 / distance);
684    let foot = ca + direction * along;
685    let mut profile_points = Vec::new();
686    if squared <= tol.confusion() * tol.confusion() {
687        profile_points.push(foot);
688    } else {
689        let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
690        profile_points.push(foot + offset);
691        profile_points.push(foot - offset);
692    }
693    let circles: Vec<Curve> = profile_points
694        .into_iter()
695        .filter_map(|p| {
696            circle_on(
697                axis.location + axis.direction.vector() * p.y,
698                axis.direction,
699                p.x,
700                tol,
701            )
702        })
703        .collect();
704    Ok(if circles.is_empty() {
705        Meeting::Apart
706    } else {
707        Meeting::Along(circles)
708    })
709}
710
711/// Where an axis crosses a plane.
712fn intersect_axis_plane(
713    axis: ogeom_math::Axis,
714    plane: ogeom_math::Plane,
715    tol: Tolerances,
716) -> OgeomResult<Point> {
717    let along = plane.normal().dot(axis.direction);
718    if along.abs() <= tol.angular() {
719        ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
720    }
721    let t = -plane.signed_distance_to(axis.location) / along;
722    Ok(axis.location + axis.direction.vector() * t)
723}
724
725/// A full circle in the plane through `centre` with the given normal.
726fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
727    if radius <= tol.confusion() {
728        return None;
729    }
730    // Any perpendicular will do for where the parameterization starts.
731    let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
732        Vector::X
733    } else {
734        Vector::Y
735    };
736    let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
737    let frame = Frame::new(centre, normal, x, tol).ok()?;
738    Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
739}
740
741/// An unbounded line through a point.
742fn line_through(through: Point, direction: Direction) -> Curve {
743    ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
744}