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. A plane through the axis: two meridians.
374///
375/// Those are the plane/torus configurations with a closed form worth the
376/// name: an oblique plane, or one parallel to the axis and off it, meets a
377/// torus in a quartic (with Villarceau's circles at exactly one magic
378/// tilt), and that is the marching intersector's business. A plane through
379/// the axis often holds the torus's seam, and a fitted section there never
380/// meets the seam's own vertices. The blend machinery lives on this case:
381/// a rolling ball's toroidal envelope is tangent to the plane it rolls on
382/// along a circle, and that tangency must be *reported as the circle it is*,
383/// the way a tangent plane reports its line on a cylinder; a tangential
384/// answer with no curve in it would send the boolean above into a refusal.
385fn axial_plane_torus(
386    plane: ogeom_math::Plane,
387    torus: ogeom_math::Torus,
388    tol: Tolerances,
389) -> OgeomResult<Meeting> {
390    let axis = torus.axis();
391    let along = plane.normal().dot(axis.direction);
392    if along.abs() <= tol.angular()
393        && plane.signed_distance_to(axis.location).abs() <= tol.confusion()
394    {
395        return Ok(meridians(plane, torus, tol));
396    }
397    if (along.abs() - 1.0).abs() > tol.angular() {
398        ogeom_bail!(
399            NotDone,
400            "a plane oblique to a torus's axis, or parallel to it and off it, \
401             meets it in a quartic, which needs the general marching \
402             intersector"
403        );
404    }
405    // The plane's height above the tube's centre plane.
406    let height = -plane.signed_distance_to(axis.location) * along.signum();
407    let minor = torus.minor_radius();
408    if height.abs() > minor + tol.confusion() {
409        return Ok(Meeting::Apart);
410    }
411    let centre = axis.location + axis.direction.vector() * height;
412    if (height.abs() - minor).abs() <= tol.confusion() {
413        // Tangent along the parallel at the tube's top or bottom.
414        return Ok(
415            match circle_on(centre, axis.direction, torus.major_radius(), tol) {
416                Some(circle) => Meeting::Along(vec![circle]),
417                None => Meeting::Apart,
418            },
419        );
420    }
421    // Two parallels, one either side of the tube, the inner one only where
422    // the tube does not swallow the axis.
423    let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
424    let circles: Vec<Curve> = [torus.major_radius() + spread, torus.major_radius() - spread]
425        .into_iter()
426        .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
427        .collect();
428    Ok(if circles.is_empty() {
429        Meeting::Apart
430    } else {
431        Meeting::Along(circles)
432    })
433}
434
435/// A plane through a torus's axis: the two tube circles either side of the
436/// axis, each starting on the outer equator as the torus's own meridians
437/// do, so a section lying on the torus's seam starts where the seam does.
438fn meridians(plane: ogeom_math::Plane, torus: ogeom_math::Torus, tol: Tolerances) -> Meeting {
439    let axis = torus.axis();
440    let normal = plane.normal();
441    let Ok(out) = Direction::from_cross(axis.direction.vector(), normal.vector(), tol) else {
442        return Meeting::Apart;
443    };
444    let circles: Vec<Curve> = [out.vector(), -out.vector()]
445        .into_iter()
446        .filter_map(|radial| {
447            let centre = axis.location + radial * torus.major_radius();
448            let x = Direction::new(radial, tol).ok()?;
449            let frame = Frame::new(centre, normal, x, tol).ok()?;
450            let circle = Circle::new(frame, torus.minor_radius(), tol).ok()?;
451            Some(ogeom_geom::CircleCurve::new(circle).into())
452        })
453        .collect();
454    Meeting::Along(circles)
455}
456
457/// A cylinder sharing a torus's axis: apart, one tangent circle, or two
458/// parallels at mirrored heights.
459fn coaxial_cylinder_torus(
460    cylinder: ogeom_math::Cylinder,
461    torus: ogeom_math::Torus,
462    tol: Tolerances,
463) -> OgeomResult<Meeting> {
464    if !cylinder.axis().is_coaxial(torus.axis(), tol) {
465        ogeom_bail!(
466            NotDone,
467            "a cylinder off a torus's axis meets it in a quartic space curve, \
468             which needs the general marching intersector"
469        );
470    }
471    let axis = torus.axis();
472    let reach = (cylinder.radius() - torus.major_radius()).abs();
473    let minor = torus.minor_radius();
474    if reach > minor + tol.confusion() {
475        return Ok(Meeting::Apart);
476    }
477    if (reach - minor).abs() <= tol.confusion() {
478        // Tangent along the tube's inner or outer equator.
479        return Ok(
480            match circle_on(axis.location, axis.direction, cylinder.radius(), tol) {
481                Some(circle) => Meeting::Along(vec![circle]),
482                None => Meeting::Apart,
483            },
484        );
485    }
486    let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
487    let circles: Vec<Curve> = [rise, -rise]
488        .into_iter()
489        .filter_map(|height| {
490            circle_on(
491                axis.location + axis.direction.vector() * height,
492                axis.direction,
493                cylinder.radius(),
494                tol,
495            )
496        })
497        .collect();
498    Ok(if circles.is_empty() {
499        Meeting::Apart
500    } else {
501        Meeting::Along(circles)
502    })
503}
504
505/// A plane square to a cone's axis: the parallel at that height, or the apex.
506///
507/// The perpendicular slice is the configuration the rebuilds lean on (a
508/// drafted wall's cap, a chamfer cone against the face it melts into), and
509/// the answer is a circle framed on the cone's own frame, so a caller
510/// re-deriving an edge finds its parameters where the old ones were. An
511/// oblique plane meets a cone in a conic, which is the marching
512/// intersector's business.
513fn plane_cone(
514    plane: ogeom_math::Plane,
515    cone: ogeom_math::Cone,
516    tol: Tolerances,
517) -> OgeomResult<Meeting> {
518    let axis = cone.axis();
519    let along = plane.normal().dot(axis.direction);
520    if (along.abs() - 1.0).abs() > tol.angular() {
521        ogeom_bail!(
522            NotDone,
523            "a plane oblique to a cone's axis meets it in a conic, which \
524             needs the general marching intersector"
525        );
526    }
527    // The plane's height along the axis, from the cone frame's origin.
528    let height = -plane.signed_distance_to(axis.location) * along.signum();
529    let radius = cone.radius_at(height);
530    if radius.abs() <= tol.confusion() {
531        // The plane passes through the apex, where the parallel has no
532        // length: a touch, not a curve.
533        return Ok(Meeting::Touching(vec![cone.apex()]));
534    }
535    if radius < 0.0 {
536        // Past the apex the chart runs mirrored (the same points sit half a
537        // turn out of phase), and a parallel reported there would carry the
538        // wrong parameters into everything downstream. Deferred, not guessed.
539        ogeom_bail!(
540            NotDone,
541            "the plane crosses the cone past its apex, where the chart runs \
542             mirrored; that configuration needs the general machinery"
543        );
544    }
545    let centre = axis.location + axis.direction.vector() * height;
546    Ok(match cone_parallel(&cone, centre, radius, tol) {
547        Some(circle) => Meeting::Along(vec![circle]),
548        None => Meeting::Apart,
549    })
550}
551
552/// A cylinder sharing a cone's axis: the parallel where the slant crosses
553/// the cylinder's radius.
554///
555/// The radius function is linear in height, so it crosses any radius exactly
556/// once on the chart's own nappe: the parallel reported here. The mirrored
557/// crossing past the apex is real geometry, but its parameters run half a
558/// turn out of phase and a curve carrying them would poison every consumer;
559/// a face reaching past its own apex is not a configuration this vocabulary
560/// builds.
561fn coaxial_cylinder_cone(
562    cylinder: ogeom_math::Cylinder,
563    cone: ogeom_math::Cone,
564    tol: Tolerances,
565) -> OgeomResult<Meeting> {
566    if !cylinder.axis().is_coaxial(cone.axis(), tol) {
567        ogeom_bail!(
568            NotDone,
569            "a cylinder off a cone's axis meets it in a curve only the \
570             general marching intersector can trace"
571        );
572    }
573    let axis = cone.axis();
574    let slope = cone.half_angle().tan();
575    let height = (cylinder.radius() - cone.reference_radius()) / slope;
576    Ok(
577        match cone_parallel(
578            &cone,
579            axis.location + axis.direction.vector() * height,
580            cylinder.radius(),
581            tol,
582        ) {
583            Some(circle) => Meeting::Along(vec![circle]),
584            None => Meeting::Apart,
585        },
586    )
587}
588
589/// Two cones sharing an axis: the same surface, the shared apex, or the
590/// parallel where the slants cross.
591///
592/// In height–radius coordinates along the shared axis each cone is a line,
593/// and the crossing is one linear equation; the parallel there is a circle
594/// unless it lands on the apex, which is a touch.
595fn coaxial_cones(
596    a: ogeom_math::Cone,
597    b: ogeom_math::Cone,
598    tol: Tolerances,
599) -> OgeomResult<Meeting> {
600    if !a.axis().is_coaxial(b.axis(), tol) {
601        ogeom_bail!(
602            NotDone,
603            "two cones that do not share an axis meet in a curve only the \
604             general marching intersector can trace"
605        );
606    }
607    let axis = a.axis();
608    // Both radius functions expressed against `a`'s height origin. The axes
609    // share a sense (`is_coaxial` checked), so the slopes compare directly.
610    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
611    let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
612    let (ref_a, ref_b) = (
613        a.reference_radius(),
614        slope_b.mul_add(-lift, b.reference_radius()),
615    );
616    if (slope_a - slope_b).abs() <= tol.angular() {
617        // Parallel slants: the same cone, or two that never meet.
618        return Ok(if (ref_a - ref_b).abs() <= tol.confusion() {
619            Meeting::Same
620        } else {
621            Meeting::Apart
622        });
623    }
624    // One linear equation: where the radius lines cross on the charts' own
625    // nappes. The mirrored-nappe crossings are real geometry with the wrong
626    // parameters (same reasoning as the cylinder) and stay deferred.
627    let height = (ref_b - ref_a) / (slope_a - slope_b);
628    let radius = a.radius_at(height);
629    if radius.abs() <= tol.confusion() {
630        // The radius lines cross at zero: a shared apex, a touch.
631        return Ok(Meeting::Touching(vec![a.apex()]));
632    }
633    if radius < 0.0 {
634        ogeom_bail!(
635            NotDone,
636            "two coaxial cones that meet only past their apexes, where the \
637             charts run mirrored, need the general machinery"
638        );
639    }
640    Ok(
641        match cone_parallel(
642            &a,
643            axis.location + axis.direction.vector() * height,
644            radius,
645            tol,
646        ) {
647            Some(circle) => Meeting::Along(vec![circle]),
648            None => Meeting::Apart,
649        },
650    )
651}
652
653/// A parallel of a cone, framed on the cone's own frame so parameters carry.
654fn cone_parallel(
655    cone: &ogeom_math::Cone,
656    centre: Point,
657    radius: f64,
658    tol: Tolerances,
659) -> Option<Curve> {
660    if radius <= tol.confusion() {
661        return None;
662    }
663    let frame = cone.frame();
664    let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
665    Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
666}
667
668/// Two tori sharing an axis: the same surface, apart, or circles where the
669/// tube profiles cross.
670///
671/// In the shared meridian half-plane the two tubes are two circles, and
672/// revolving their meetings gives the answer: radical-line algebra in the
673/// `(distance-from-axis, height)` plane, each solution a parallel.
674fn coaxial_tori(
675    a: ogeom_math::Torus,
676    b: ogeom_math::Torus,
677    tol: Tolerances,
678) -> OgeomResult<Meeting> {
679    if !a.axis().is_coaxial(b.axis(), tol) {
680        ogeom_bail!(
681            NotDone,
682            "two tori that do not share an axis meet in a curve only the \
683             general marching intersector can trace"
684        );
685    }
686    let axis = a.axis();
687    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
688    if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
689        && lift.abs() <= tol.confusion()
690        && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
691    {
692        return Ok(Meeting::Same);
693    }
694    // Profile circles in the meridian half-plane: centres at
695    // `(major, height)`, radii the minors.
696    let (ca, cb) = (
697        ogeom_math::Point2::new(a.major_radius(), 0.0),
698        ogeom_math::Point2::new(b.major_radius(), lift),
699    );
700    let between = cb - ca;
701    let distance = between.magnitude();
702    let (ra, rb) = (a.minor_radius(), b.minor_radius());
703    if distance <= tol.confusion() {
704        // Concentric profiles of different tube radii never meet; the same
705        // circle was the `Same` case above.
706        return Ok(Meeting::Apart);
707    }
708    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
709        return Ok(Meeting::Apart);
710    }
711    let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
712    let squared = ra.mul_add(ra, -(along * along));
713    let direction = between * (1.0 / distance);
714    let foot = ca + direction * along;
715    let mut profile_points = Vec::new();
716    if squared <= tol.confusion() * tol.confusion() {
717        profile_points.push(foot);
718    } else {
719        let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
720        profile_points.push(foot + offset);
721        profile_points.push(foot - offset);
722    }
723    let circles: Vec<Curve> = profile_points
724        .into_iter()
725        .filter_map(|p| {
726            circle_on(
727                axis.location + axis.direction.vector() * p.y,
728                axis.direction,
729                p.x,
730                tol,
731            )
732        })
733        .collect();
734    Ok(if circles.is_empty() {
735        Meeting::Apart
736    } else {
737        Meeting::Along(circles)
738    })
739}
740
741/// Where an axis crosses a plane.
742fn intersect_axis_plane(
743    axis: ogeom_math::Axis,
744    plane: ogeom_math::Plane,
745    tol: Tolerances,
746) -> OgeomResult<Point> {
747    let along = plane.normal().dot(axis.direction);
748    if along.abs() <= tol.angular() {
749        ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
750    }
751    let t = -plane.signed_distance_to(axis.location) / along;
752    Ok(axis.location + axis.direction.vector() * t)
753}
754
755/// A full circle in the plane through `centre` with the given normal.
756fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
757    if radius <= tol.confusion() {
758        return None;
759    }
760    // Any perpendicular will do for where the parameterization starts.
761    let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
762        Vector::X
763    } else {
764        Vector::Y
765    };
766    let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
767    let frame = Frame::new(centre, normal, x, tol).ok()?;
768    Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
769}
770
771/// An unbounded line through a point.
772fn line_through(through: Point, direction: Direction) -> Curve {
773    ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
774}