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//! # The exact cases
10//!
11//! They are common: plane against plane, plane against cylinder, sphere
12//! against sphere. A mechanical part is mostly these, and running a marching
13//! intersector over a pair whose answer is a circle is slower and less accurate
14//! than writing down the circle.
15//!
16//! They are fast. No stepping, no refinement, no approximation stage.
17//!
18//! And they are exact. Every result here can be checked without reference to
19//! anything but the two surfaces themselves: sample the curve, ask each
20//! surface how far away it is, and the answer should be zero. That check is
21//! the accuracy instrument in `tests/support/`.
22//!
23//! # What it reports
24//!
25//! Not just curves. Two surfaces can miss, touch at a point, meet along curves,
26//! or be the same surface, and those are four different answers that downstream
27//! code has to distinguish. A boolean that treats coincidence as "no
28//! intersection" produces a solid with a face missing.
29
30use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve, SurfaceGeometry};
32use ogeom_math::{Circle, Direction, Ellipse, Frame, Point, Vector};
33
34/// What two surfaces do where they meet.
35#[derive(Debug, Clone, PartialEq)]
36pub enum Meeting {
37    /// They do not meet at all.
38    Apart,
39    /// They touch at isolated points, without crossing.
40    ///
41    /// A sphere resting on a plane. Distinguished from a curve because a
42    /// tangential contact has no length to walk along, and an algorithm that
43    /// treated it as a degenerate curve would divide by that length.
44    Touching(Vec<Point>),
45    /// They meet along these curves.
46    Along(Vec<Curve>),
47    /// They are the same surface wherever they overlap.
48    ///
49    /// A separate answer from every other, because it is the one where "the
50    /// intersection curve" does not exist: the overlap is two-dimensional. A
51    /// boolean has to detect this and unify the faces rather than look for a
52    /// seam between them.
53    Same,
54}
55
56/// Where two surfaces meet, when that has a closed form.
57///
58/// # Errors
59///
60/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if this pair has no closed
61/// form, which is a statement about the pair, not a failure to compute. The
62/// general marching intersector is what answers those.
63pub fn surface_surface(
64    a: &SurfaceGeometry,
65    b: &SurfaceGeometry,
66    tol: Tolerances,
67) -> OgeomResult<Meeting> {
68    use SurfaceGeometry as S;
69    match (a, b) {
70        (S::Plane(p), S::Plane(q)) => Ok(plane_plane(
71            p.plane(),
72            q.plane(),
73            window_reach(p).min(window_reach(q)),
74            tol,
75        )),
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)) => cylinder_sphere(c, s.sphere(), tol),
83        (S::Sphere(s), S::Cylinder(c)) => cylinder_sphere(c, 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::Sphere(s), S::Torus(t)) => axial_sphere_torus(s.sphere(), t.torus(), tol),
90        (S::Torus(t), S::Sphere(s)) => axial_sphere_torus(s.sphere(), t.torus(), tol),
91        (S::Plane(p), S::Cone(c)) => plane_cone(p.plane(), c.cone(), heights(c), tol),
92        (S::Cone(c), S::Plane(p)) => plane_cone(p.plane(), c.cone(), heights(c), tol),
93        (S::Cylinder(x), S::Cone(c)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
94        (S::Cone(c), S::Cylinder(x)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
95        (S::Cone(x), S::Cone(y)) => coaxial_cones(x.cone(), y.cone(), tol),
96        _ => ogeom_bail!(
97            NotDone,
98            "this pair of surfaces has no closed-form intersection; it needs \
99             the general marching intersector, which is gated on the benchmark \
100             these cases provide the ground truth for"
101        ),
102    }
103}
104
105/// How far across a plane surface's window reaches: its diagonal, or a
106/// billion units for one stated unbounded, which leaves the angle alone to
107/// decide.
108fn window_reach(plane: &ogeom_geom::PlaneSurface) -> f64 {
109    let ((u0, u1), (v0, v1)) = ogeom_geom::Surface::domain(plane);
110    let reach = (u1 - u0).hypot(v1 - v0);
111    if reach.is_finite() && reach > 0.0 {
112        reach.min(1e9)
113    } else {
114        1e9
115    }
116}
117
118/// Whether a plane whose normal meets an axis at cosine `along` stands
119/// square to it, for a section of a surface of revolution about that axis.
120///
121/// Looser than an angle test on purpose. Tilted by an angle t, the section
122/// taken as square is off by the radius times t squared over two: a
123/// micro-radian tilt costs a millionth of a micron on a unit radius, while
124/// a plane a composed placement left a rounding error off square would
125/// otherwise have no closed form at all. Two planes are the opposite case,
126/// where the error grows with the extent, and [`plane_plane`] tests the
127/// angle itself.
128fn square_to_axis(along: f64, tol: Tolerances) -> bool {
129    (along.abs() - 1.0).abs() <= tol.angular()
130}
131
132/// Two planes: apart, the same, or a line.
133///
134/// Parallel is decided across `reach`, the size of the region the two
135/// stand over: planes whose normals differ by an angle t part by t times
136/// the distance, so across the region they are one plane (or two parallel
137/// ones) when that stays within the confusion distance. Planes built by
138/// different routes to be coplanar differ by rounding, a hundred-billionth
139/// of a radian, and across a part that is nothing; a microradian across a
140/// hundred millimetres is a thousand times the confusion, and a line.
141fn plane_plane(a: ogeom_math::Plane, b: ogeom_math::Plane, reach: f64, tol: Tolerances) -> Meeting {
142    let turn = a.normal().angle(b.normal());
143    let turn = turn.min(core::f64::consts::PI - turn);
144    if turn <= tol.angular().max(tol.confusion() / reach) {
145        // Parallel. Either the same plane or two that never meet, decided by
146        // whether one contains the other's origin.
147        return if a.distance_to(b.origin()) <= tol.confusion() {
148            Meeting::Same
149        } else {
150            Meeting::Apart
151        };
152    }
153    // The line of intersection runs along both normals' cross product, and
154    // passes through the point nearest the first plane's origin that
155    // satisfies both. Measured from that origin rather than from the world's:
156    // the planes' own offsets from the world origin cancel to a few digits
157    // when divided by the square of a small angle between them (two faces a
158    // few hundredths of a milliradian apart would meet a third of a micron
159    // off their true line), where the second plane's distance from a point
160    // on the first is known to rounding.
161    let Ok(direction) = Direction::from_cross(a.normal().vector(), b.normal().vector(), tol) else {
162        return Meeting::Apart;
163    };
164    let (na, nb) = (a.normal().vector(), b.normal().vector());
165    let dot = na.dot(nb);
166    // sin² of the angle between the normals, from the cross product: one
167    // minus the dot squared loses every digit of a small angle.
168    let denominator = na.cross(nb).square_magnitude();
169    if denominator <= 0.0 {
170        return Meeting::Apart;
171    }
172    let origin = a.origin();
173    let off = b.signed_distance_to(origin);
174    let through = origin + (na * (off * dot) - nb * off) / denominator;
175    Meeting::Along(vec![line_through(through, direction)])
176}
177
178/// A plane and a sphere: apart, a point of tangency, or a circle.
179fn plane_sphere(plane: ogeom_math::Plane, sphere: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
180    let gap = plane.signed_distance_to(sphere.centre());
181    let reach = gap.abs();
182    if reach > sphere.radius() + tol.confusion() {
183        return Meeting::Apart;
184    }
185    let foot = plane.project(sphere.centre());
186    if (reach - sphere.radius()).abs() <= tol.confusion() {
187        return Meeting::Touching(vec![foot]);
188    }
189    // The chord half-length: the leg of a right triangle whose hypotenuse is
190    // the radius and whose other leg is the distance from the centre.
191    let radius = sphere
192        .radius()
193        .mul_add(sphere.radius(), -(gap * gap))
194        .max(0.0)
195        .sqrt();
196    match circle_on(foot, plane.normal(), radius, tol) {
197        Some(circle) => Meeting::Along(vec![circle]),
198        None => Meeting::Touching(vec![foot]),
199    }
200}
201
202/// The move across a drum's axis that lays its wall on a plane, where the
203/// two touch along a line to within the confusion distance: the axis
204/// parallel to the plane, and its distance from the plane within the
205/// confusion distance of the radius, either side, to the rounding that
206/// distance is read with. `None` otherwise.
207///
208/// Within that distance the drum and the plane are one configuration, the
209/// tangent one: the wall either side of the touching line stands off the
210/// plane by no more than the drum's own depth past it, so a drum raised
211/// through the plane by a hair and one lowered short of it by a hair are
212/// told apart by nothing the tolerance can see. Every decision about the
213/// line reads this one answer: [`surface_surface`] meets the two along the
214/// one line, and a boolean computes the drum's other sections on the drum
215/// moved by it, so that they end where the line does.
216#[must_use]
217pub fn drum_onto_plane(
218    plane: &ogeom_math::Plane,
219    cylinder: &ogeom_math::Cylinder,
220    tol: Tolerances,
221) -> Option<Vector> {
222    let axis = cylinder.axis();
223    if plane.normal().dot(axis.direction).abs() > tol.angular() {
224        return None;
225    }
226    let gap = plane.signed_distance_to(axis.location);
227    let r = cylinder.radius();
228    // The distance is a difference of coordinates: a drum placed a
229    // confusion distance off the plane reads that far to their rounding.
230    let rounding = 4.0
231        * f64::EPSILON
232        * (axis.location.to_vector().magnitude() + plane.origin().to_vector().magnitude() + r);
233    if (gap.abs() - r).abs() > tol.confusion() + rounding {
234        return None;
235    }
236    Some(plane.normal().vector() * (r.copysign(gap) - gap))
237}
238
239/// A plane and a cylinder.
240///
241/// Three genuinely different answers depending on the angle between them, and
242/// the whole reason a closed form is worth having: a circle, an ellipse, or a
243/// pair of straight lines, each exact.
244fn plane_cylinder(
245    plane: ogeom_math::Plane,
246    cylinder: ogeom_math::Cylinder,
247    tol: Tolerances,
248) -> OgeomResult<Meeting> {
249    let axis = cylinder.axis();
250    let along = plane.normal().dot(axis.direction);
251
252    // The plane contains the axis direction: the section is straight lines,
253    // one for each side the plane cuts, or none if it misses.
254    if along.abs() <= tol.angular() {
255        let gap = plane.signed_distance_to(axis.location);
256        let reach = gap.abs();
257        let foot = plane.project(axis.location);
258        if drum_onto_plane(&plane, &cylinder, tol).is_some() {
259            // Tangent along one line.
260            return Ok(Meeting::Along(vec![line_through(foot, axis.direction)]));
261        }
262        if reach > cylinder.radius() {
263            return Ok(Meeting::Apart);
264        }
265        // How far along the plane, from the foot of the axis, each line sits.
266        let offset = cylinder
267            .radius()
268            .mul_add(cylinder.radius(), -(gap * gap))
269            .max(0.0)
270            .sqrt();
271        let sideways =
272            Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
273        return Ok(Meeting::Along(vec![
274            line_through(foot + sideways.vector() * offset, axis.direction),
275            line_through(foot - sideways.vector() * offset, axis.direction),
276        ]));
277    }
278
279    // Perpendicular to the axis: a circle of the cylinder's own radius.
280    let centre = intersect_axis_plane(axis, plane, tol)?;
281    if square_to_axis(along, tol) {
282        return Ok(
283            match circle_on(centre, plane.normal(), cylinder.radius(), tol) {
284                Some(circle) => Meeting::Along(vec![circle]),
285                None => Meeting::Apart,
286            },
287        );
288    }
289
290    // Oblique: an ellipse. Its minor axis is the cylinder's radius, across the
291    // slope; its major is that divided by the cosine of the tilt, along it.
292    let minor = cylinder.radius();
293    let major = minor / along.abs();
294    // The minor axis runs where the plane and a plane perpendicular to the axis
295    // agree: the cross of the two normals.
296    let minor_direction =
297        Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
298    let major_direction =
299        Direction::from_cross(minor_direction.vector(), plane.normal().vector(), tol)?;
300    let frame = Frame::from_axes(
301        centre,
302        major_direction,
303        minor_direction,
304        plane.normal(),
305        tol,
306    )?;
307    Ok(Meeting::Along(vec![
308        ogeom_geom::EllipseCurve::new(Ellipse::new(frame, major, minor, tol)?).into(),
309    ]))
310}
311
312/// Two spheres: apart, tangent at a point, the same, or a circle.
313fn sphere_sphere(a: ogeom_math::Sphere, b: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
314    let between = b.centre() - a.centre();
315    let distance = between.magnitude();
316    if distance <= tol.confusion() {
317        return if (a.radius() - b.radius()).abs() <= tol.confusion() {
318            Meeting::Same
319        } else {
320            // Concentric and different: one inside the other, never meeting.
321            Meeting::Apart
322        };
323    }
324    let (ra, rb) = (a.radius(), b.radius());
325    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
326        return Meeting::Apart;
327    }
328    let Ok(direction) = Direction::new(between, tol) else {
329        return Meeting::Apart;
330    };
331    // Where the plane of the intersection circle crosses the line of centres.
332    let reach = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
333    let centre = a.centre() + direction.vector() * reach;
334    let squared = ra.mul_add(ra, -(reach * reach));
335    if squared <= tol.confusion() * tol.confusion() {
336        return Meeting::Touching(vec![centre]);
337    }
338    match circle_on(centre, direction, squared.max(0.0).sqrt(), tol) {
339        Some(circle) => Meeting::Along(vec![circle]),
340        None => Meeting::Touching(vec![centre]),
341    }
342}
343
344/// Two cylinders sharing an axis.
345///
346/// The only cylinder pair with a closed form worth writing down. Two general
347/// cylinders meet in a quartic space curve, which is what the marching
348/// intersector is for.
349fn coaxial_cylinders(
350    a: ogeom_math::Cylinder,
351    b: ogeom_math::Cylinder,
352    tol: Tolerances,
353) -> OgeomResult<Meeting> {
354    if !a.axis().is_coaxial(b.axis(), tol) {
355        // Equal radii with intersecting axes: the one crossing whose quartic
356        // factors, into the two ellipses in the axes' bisector planes, each
357        // an oblique plane section the plane machinery already speaks. The
358        // ellipses cross at the two points where the cylinders are tangent;
359        // that is the crossing's geometry, stated exactly rather than
360        // marched through.
361        if (a.radius() - b.radius()).abs() <= tol.confusion() {
362            let (da, db) = (a.axis().direction.vector(), b.axis().direction.vector());
363            let normal = da.cross(db);
364            if normal.magnitude() > tol.angular() {
365                let (pa, pb) = (a.axis().location, b.axis().location);
366                // Closest points of the two axis lines; coincident when the
367                // axes genuinely intersect.
368                let w = pb - pa;
369                let dd = da.dot(db);
370                let denom = dd.mul_add(-dd, 1.0);
371                let s = dd.mul_add(-db.dot(w), da.dot(w)) / denom;
372                let t = dd.mul_add(da.dot(w), -db.dot(w)) / denom;
373                let on_a = pa + da * s;
374                let on_b = pb + db * t;
375                if on_a.distance(on_b) <= tol.confusion() {
376                    let centre = on_a;
377                    let mut curves = Vec::new();
378                    for m in [da - db, da + db] {
379                        if m.magnitude() <= tol.angular() {
380                            continue;
381                        }
382                        let plane =
383                            ogeom_math::Plane::through(centre, ogeom_math::Direction::new(m, tol)?);
384                        if let Meeting::Along(mut found) = plane_cylinder(plane, a, tol)? {
385                            curves.append(&mut found);
386                        }
387                    }
388                    if !curves.is_empty() {
389                        return Ok(Meeting::Along(curves));
390                    }
391                }
392            }
393        }
394        ogeom_bail!(
395            NotDone,
396            "two cylinders that do not share an axis meet in a quartic space \
397             curve, which needs the general marching intersector"
398        );
399    }
400    Ok(if (a.radius() - b.radius()).abs() <= tol.confusion() {
401        Meeting::Same
402    } else {
403        // Same axis, different radii: one inside the other, touching nowhere.
404        Meeting::Apart
405    })
406}
407
408/// A cylinder and a sphere: the circles of a sphere centred on the axis, or
409/// a sphere off the axis that stands clear of the cylinder or touches it
410/// at one point.
411fn cylinder_sphere(
412    drum: &ogeom_geom::CylinderSurface,
413    sphere: ogeom_math::Sphere,
414    tol: Tolerances,
415) -> OgeomResult<Meeting> {
416    let cylinder = drum.cylinder();
417    if cylinder.axis().distance_to(sphere.centre()) <= tol.confusion() {
418        return coaxial_cylinder_sphere(cylinder, sphere, tol);
419    }
420    match sphere_beside_cylinder(drum, sphere, tol) {
421        Some(meeting) => Ok(meeting),
422        None => ogeom_bail!(
423            NotDone,
424            "a sphere off a cylinder's axis and crossing it meets it in a \
425             quartic space curve, which needs the general marching intersector"
426        ),
427    }
428}
429
430/// A sphere off a cylinder's axis, where it does not cross the cylinder:
431/// inside or outside it and clear, or resting against its wall from inside
432/// or outside at one point. `None` where it crosses.
433///
434/// A sphere whose centre stands `d` off the axis reaches from `d - ρ` to
435/// `d + ρ` of it, and the cylinder's radius against those two is the whole
436/// story: within the confusion distance of either, the sphere touches the
437/// wall at the point of it nearest or furthest from the axis, and the
438/// surfaces part on every side of it, by `2d` across the axis from an
439/// inner touch, which is past the confusion distance since the centre is
440/// off the axis by more. A touch beyond the drum's height touches nothing.
441fn sphere_beside_cylinder(
442    drum: &ogeom_geom::CylinderSurface,
443    sphere: ogeom_math::Sphere,
444    tol: Tolerances,
445) -> Option<Meeting> {
446    let cylinder = drum.cylinder();
447    let axis = cylinder.axis();
448    let (centre, radius) = (sphere.centre(), sphere.radius());
449    let outward = centre - axis.project(centre);
450    let off = outward.magnitude();
451    let outward = Direction::new(outward, tol).ok()?;
452    let r = cylinder.radius();
453    // How far the sphere stands clear of the wall from inside, and from
454    // outside.
455    let inner = r - (off + radius);
456    let outer = (off - radius) - r;
457    if inner > tol.confusion() || outer > tol.confusion() {
458        return Some(Meeting::Apart);
459    }
460    let touch = if inner.abs() <= tol.confusion() {
461        centre + outward.vector() * radius
462    } else if outer.abs() <= tol.confusion() {
463        centre - outward.vector() * radius
464    } else {
465        return None;
466    };
467    let (h0, h1) = ogeom_geom::Surface::domain(drum).1;
468    let height = (touch - axis.location).dot(axis.direction.vector());
469    Some(
470        if height < h0.min(h1) - tol.confusion() || height > h0.max(h1) + tol.confusion() {
471            Meeting::Apart
472        } else {
473            Meeting::Touching(vec![touch])
474        },
475    )
476}
477
478/// A cylinder and a sphere whose centre is on the cylinder's axis.
479fn coaxial_cylinder_sphere(
480    cylinder: ogeom_math::Cylinder,
481    sphere: ogeom_math::Sphere,
482    tol: Tolerances,
483) -> OgeomResult<Meeting> {
484    let axis = cylinder.axis();
485    let (r, radius) = (cylinder.radius(), sphere.radius());
486    if r > radius + tol.confusion() {
487        return Ok(Meeting::Apart);
488    }
489    if (r - radius).abs() <= tol.confusion() {
490        // The sphere's equator lies on the cylinder, and they are tangent
491        // along it rather than crossing.
492        let centre = sphere.centre();
493        return Ok(match circle_on(centre, axis.direction, r, tol) {
494            Some(circle) => Meeting::Along(vec![circle]),
495            None => Meeting::Apart,
496        });
497    }
498    // Two circles, symmetric about the sphere's centre.
499    let reach = radius.mul_add(radius, -(r * r)).max(0.0).sqrt();
500    let mut out = Vec::with_capacity(2);
501    for side in [reach, -reach] {
502        let centre = sphere.centre() + axis.direction.vector() * side;
503        if let Some(circle) = circle_on(centre, axis.direction, r, tol) {
504            out.push(circle);
505        }
506    }
507    Ok(if out.is_empty() {
508        Meeting::Apart
509    } else {
510        Meeting::Along(out)
511    })
512}
513
514/// A plane perpendicular to a torus's axis: apart, one tangent circle, or two
515/// parallels. A plane through the axis: two meridians.
516///
517/// Those are the plane/torus configurations with a closed form worth the
518/// name: an oblique plane, or one parallel to the axis and off it, crossing
519/// a torus meets it in a quartic (with Villarceau's circles at exactly one
520/// magic tilt), and that is the marching intersector's business; one that
521/// does not cross it is clear of it or rests on it at one point
522/// ([`plane_beside_torus`]). A plane through the axis often holds the
523/// torus's seam, and a fitted section there never meets the seam's own
524/// vertices. The blend machinery lives on this case:
525/// a rolling ball's toroidal envelope is tangent to the plane it rolls on
526/// along a circle, and that tangency must be *reported as the circle it is*,
527/// the way a tangent plane reports its line on a cylinder; a tangential
528/// answer with no curve in it would send the boolean above into a refusal.
529fn axial_plane_torus(
530    plane: ogeom_math::Plane,
531    torus: ogeom_math::Torus,
532    tol: Tolerances,
533) -> OgeomResult<Meeting> {
534    let axis = torus.axis();
535    let along = plane.normal().dot(axis.direction);
536    if along.abs() <= tol.angular()
537        && plane.signed_distance_to(axis.location).abs() <= tol.confusion()
538    {
539        return Ok(meridians(plane, torus, tol));
540    }
541    if !square_to_axis(along, tol) {
542        if let Some(meeting) = plane_beside_torus(plane, torus, along, tol) {
543            return Ok(meeting);
544        }
545        ogeom_bail!(
546            NotDone,
547            "a plane oblique to a torus's axis, or parallel to it and off it, \
548             crossing it meets it in a quartic, which needs the general \
549             marching intersector"
550        );
551    }
552    // The plane's height above the tube's centre plane.
553    let height = -plane.signed_distance_to(axis.location) * along.signum();
554    let minor = torus.minor_radius();
555    if height.abs() > minor + tol.confusion() {
556        return Ok(Meeting::Apart);
557    }
558    let centre = axis.location + axis.direction.vector() * height;
559    if (height.abs() - minor).abs() <= tol.confusion() {
560        // Tangent along the parallel at the tube's top or bottom.
561        return Ok(
562            match circle_on(centre, axis.direction, torus.major_radius(), tol) {
563                Some(circle) => Meeting::Along(vec![circle]),
564                None => Meeting::Apart,
565            },
566        );
567    }
568    // Two parallels, one either side of the tube. A spindle's tube swallows
569    // the axis, and its inner parallel then lies on the tube's other half,
570    // past the axis: the same circle, at the radius's size.
571    let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
572    let circles: Vec<Curve> = [
573        torus.major_radius() + spread,
574        (torus.major_radius() - spread).abs(),
575    ]
576    .into_iter()
577    .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
578    .collect();
579    Ok(if circles.is_empty() {
580        Meeting::Apart
581    } else {
582        Meeting::Along(circles)
583    })
584}
585
586/// A plane not square to a torus's axis, where it does not cross the torus:
587/// clear of it, or resting against it at one point. `None` where it crosses,
588/// or where it lies so nearly square that the whole parallel it rests on is
589/// within the confusion distance of it.
590///
591/// The torus is its centre circle swept by a ball of the minor radius, so
592/// along the plane's normal `n` it reaches `R·s + r` either side of its
593/// centre, `s` being the sine of the normal's angle to the axis. That reach
594/// is attained at one point, on the tube's outer side under the normal's
595/// turn about the axis. Within the confusion distance of it, the plane
596/// touches there. Every other point stands further off the plane, by up to
597/// `2·R·s` across the axis, so the touch is isolated once that is past the
598/// confusion distance; short of it the plane lies along the parallel, which
599/// is the march's business.
600fn plane_beside_torus(
601    plane: ogeom_math::Plane,
602    torus: ogeom_math::Torus,
603    along: f64,
604    tol: Tolerances,
605) -> Option<Meeting> {
606    let axis = torus.axis();
607    let normal = plane.normal().vector();
608    let across = normal - axis.direction.vector() * along;
609    let sine = across.magnitude();
610    let (major, minor) = (torus.major_radius(), torus.minor_radius());
611    if 2.0 * major * sine <= tol.confusion() {
612        return None;
613    }
614    let reach = major.mul_add(sine, minor);
615    // How far the torus's centre stands off the plane, and how far the
616    // torus stands clear of it.
617    let offset = plane.signed_distance_to(axis.location);
618    let gap = offset.abs() - reach;
619    if gap > tol.confusion() {
620        return Some(Meeting::Apart);
621    }
622    if gap.abs() > tol.confusion() {
623        return None;
624    }
625    // Towards the plane: against the normal from a centre on its positive
626    // side.
627    let toward = -offset.signum();
628    let touch = axis.location + (across * (major / sine) + normal * minor) * toward;
629    Some(Meeting::Touching(vec![touch]))
630}
631
632/// A plane through a torus's axis: the two tube circles either side of the
633/// axis, each starting on the outer equator as the torus's own meridians
634/// do, so a section lying on the torus's seam starts where the seam does.
635fn meridians(plane: ogeom_math::Plane, torus: ogeom_math::Torus, tol: Tolerances) -> Meeting {
636    let axis = torus.axis();
637    let normal = plane.normal();
638    let Ok(out) = Direction::from_cross(axis.direction.vector(), normal.vector(), tol) else {
639        return Meeting::Apart;
640    };
641    let circles: Vec<Curve> = [out.vector(), -out.vector()]
642        .into_iter()
643        .filter_map(|radial| {
644            let centre = axis.location + radial * torus.major_radius();
645            let x = Direction::new(radial, tol).ok()?;
646            let frame = Frame::new(centre, normal, x, tol).ok()?;
647            let circle = Circle::new(frame, torus.minor_radius(), tol).ok()?;
648            Some(ogeom_geom::CircleCurve::new(circle).into())
649        })
650        .collect();
651    Meeting::Along(circles)
652}
653
654/// A cylinder sharing a torus's axis: apart, one tangent circle, or two
655/// parallels at mirrored heights.
656fn coaxial_cylinder_torus(
657    cylinder: ogeom_math::Cylinder,
658    torus: ogeom_math::Torus,
659    tol: Tolerances,
660) -> OgeomResult<Meeting> {
661    if !cylinder.axis().is_coaxial(torus.axis(), tol) {
662        ogeom_bail!(
663            NotDone,
664            "a cylinder off a torus's axis meets it in a quartic space curve, \
665             which needs the general marching intersector"
666        );
667    }
668    let axis = torus.axis();
669    let minor = torus.minor_radius();
670    // In a meridian plane the tube is two circles, centred the major radius
671    // either side of the axis; the far one reaches this side only on a
672    // spindle, whose tube swallows the axis. The cylinder's line meets each
673    // it reaches: tangent at the tube's equator, or at mirrored heights.
674    let mut heights: Vec<f64> = Vec::new();
675    for reach in [
676        (cylinder.radius() - torus.major_radius()).abs(),
677        cylinder.radius() + torus.major_radius(),
678    ] {
679        if reach > minor + tol.confusion() {
680            continue;
681        }
682        if (reach - minor).abs() <= tol.confusion() {
683            heights.push(0.0);
684            continue;
685        }
686        let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
687        heights.extend([rise, -rise]);
688    }
689    let mut kept: Vec<f64> = Vec::new();
690    for height in heights {
691        if kept.iter().all(|k| (k - height).abs() > tol.confusion()) {
692            kept.push(height);
693        }
694    }
695    let circles: Vec<Curve> = kept
696        .into_iter()
697        .filter_map(|height| {
698            circle_on(
699                axis.location + axis.direction.vector() * height,
700                axis.direction,
701                cylinder.radius(),
702                tol,
703            )
704        })
705        .collect();
706    Ok(if circles.is_empty() {
707        Meeting::Apart
708    } else {
709        Meeting::Along(circles)
710    })
711}
712
713/// A plane square to a cone's axis: the parallel at that height, or the apex.
714///
715/// The perpendicular slice is the configuration the rebuilds lean on (a
716/// drafted wall's cap, a chamfer cone against the face it melts into), and
717/// the answer is a circle framed on the cone's own frame, so a caller
718/// re-deriving an edge finds its parameters where the old ones were. An
719/// oblique plane meets a cone in a conic ([`conic_of`]).
720fn plane_cone(
721    plane: ogeom_math::Plane,
722    cone: ogeom_math::Cone,
723    heights: (f64, f64),
724    tol: Tolerances,
725) -> OgeomResult<Meeting> {
726    let axis = cone.axis();
727    let along = plane.normal().dot(axis.direction);
728    if !square_to_axis(along, tol) && plane.signed_distance_to(cone.apex()).abs() <= tol.confusion()
729    {
730        return Ok(rulings_in(plane, &cone, heights, tol));
731    }
732    if !square_to_axis(along, tol) {
733        return conic_of(plane, &cone, heights, tol);
734    }
735    // The plane's height along the axis, from the cone frame's origin.
736    let height = -plane.signed_distance_to(axis.location) * along.signum();
737    let radius = cone.radius_at(height);
738    if radius.abs() <= tol.confusion() {
739        // The plane passes through the apex, where the parallel has no
740        // length: a touch, not a curve.
741        return Ok(Meeting::Touching(vec![cone.apex()]));
742    }
743    // Past the apex, on the far nappe, the radius runs negative: the
744    // parallel is the circle of its size, which the chart places half a
745    // turn round.
746    let centre = axis.location + axis.direction.vector() * height;
747    Ok(match cone_parallel(&cone, centre, radius.abs(), tol) {
748        Some(circle) => Meeting::Along(vec![circle]),
749        None => Meeting::Apart,
750    })
751}
752
753/// An oblique plane clear of a cone's apex: the conic it cuts, exactly.
754///
755/// In the plane's own frame (`e2` the axis's direction within the plane,
756/// `e1` across it, the apex standing `k` off the plane along its normal)
757/// the cone `((p - apex) . a)^2 = |p - apex|^2 cos^2(alpha)` reads
758/// `C u^2 + D v^2 - 2 k c1 c2 v + (C - c1^2) k^2 = 0`, with `C = cos^2(alpha)`,
759/// `c1 = a . n`, `c2 = a . e2` and `D = c1^2 - sin^2(alpha)`: an ellipse
760/// where the plane is steeper to the axis than the cone's wall (`D > 0`),
761/// a hyperbola where it is shallower, one branch on each nappe, and a
762/// parabola where it runs parallel to a ruling. Only what lies on the
763/// nappe the cone's window reaches is stated, the open conics over a span
764/// that carries them past the window's far end.
765fn conic_of(
766    plane: ogeom_math::Plane,
767    cone: &ogeom_math::Cone,
768    heights: (f64, f64),
769    tol: Tolerances,
770) -> OgeomResult<Meeting> {
771    let a = cone.axis().direction.vector();
772    let n = plane.normal().vector();
773    let apex = cone.apex();
774    let half = cone.half_angle().abs();
775    let (sin, cos) = half.sin_cos();
776    let c1 = a.dot(n);
777    let Ok(e2) = Direction::new(a - n * c1, tol) else {
778        return Ok(Meeting::Apart);
779    };
780    let e2 = e2.vector();
781    let e1 = n.cross(e2);
782    let c2 = a.dot(e2);
783    let k = (plane.project(apex) - apex).dot(n);
784    let foot = apex + n * k;
785    let big_c = cos * cos;
786    let d = c1.mul_add(c1, -(sin * sin));
787    // Which nappes the window holds: heights measured from the apex along
788    // the axis.
789    let apex_height = cone.frame().to_local(apex).z;
790    let (lo, hi) = (
791        heights.0.min(heights.1) - apex_height,
792        heights.0.max(heights.1) - apex_height,
793    );
794    let reach = lo.abs().max(hi.abs());
795    let on_window_nappe = |p: Point| {
796        let h = (p - apex).dot(a);
797        (h >= 0.0 && hi > tol.confusion()) || (h <= 0.0 && lo < -tol.confusion())
798    };
799    if !reach.is_finite() {
800        ogeom_bail!(NotDone, "an unbounded cone window carries no conic span");
801    }
802    // A span over which an open conic's points climb past the window's far
803    // end, both ways.
804    let span = |at: &dyn Fn(f64) -> Point| -> f64 {
805        let mut t = 1.0_f64;
806        for _ in 0..64 {
807            let far = [at(t), at(-t)]
808                .iter()
809                .all(|p| (*p - apex).dot(a).abs() > reach + tol.confusion());
810            if far {
811                break;
812            }
813            t *= 2.0;
814        }
815        t
816    };
817    let frame = |origin: Point, x: Vector| -> OgeomResult<Frame> {
818        let x = Direction::new(x, tol)?;
819        Frame::new(origin, Direction::new(n, tol)?, x, tol)
820    };
821    let mut out: Vec<Curve> = Vec::new();
822    if d.abs() <= 1e-9 {
823        // Parallel to a ruling: v = q u^2 + v_apex in the plane.
824        let q = big_c / (2.0 * k * c1 * c2);
825        if !q.is_finite() {
826            return Ok(Meeting::Apart);
827        }
828        let v_apex = (big_c - c1 * c1) * k / (2.0 * c1 * c2);
829        let vertex = foot + e2 * v_apex;
830        if !on_window_nappe(vertex) {
831            return Ok(Meeting::Apart);
832        }
833        let opening = if q > 0.0 { e2 } else { -e2 };
834        let parabola =
835            ogeom_math::Parabola::new(frame(vertex, opening)?, 1.0 / (4.0 * q.abs()), tol)?;
836        let extent = span(&|t| ogeom_math::elementary::parabola_at(&parabola, t).point);
837        out.push(ogeom_geom::ParabolaCurve::new(parabola, extent)?.into());
838        return Ok(Meeting::Along(out));
839    }
840    let v0 = k * c1 * c2 / d;
841    let r = d * v0 * v0 - (big_c - c1 * c1) * k * k;
842    let centre = foot + e2 * v0;
843    if d > 0.0 {
844        if r <= tol.confusion() * tol.confusion() {
845            return Ok(Meeting::Apart);
846        }
847        let (along_u, along_v) = ((r / big_c).sqrt(), (r / d).sqrt());
848        if !on_window_nappe(centre + e2 * along_v) {
849            return Ok(Meeting::Apart);
850        }
851        let ellipse = if along_v >= along_u {
852            Ellipse::new(frame(centre, e2)?, along_v, along_u, tol)?
853        } else {
854            Ellipse::new(frame(centre, e1)?, along_u, along_v, tol)?
855        };
856        out.push(ogeom_geom::EllipseCurve::new(ellipse).into());
857        return Ok(Meeting::Along(out));
858    }
859    // A hyperbola: its branches open along e1 where r > 0, along e2 where
860    // r < 0, one on each nappe.
861    let (major_dir, major, minor) = if r > 0.0 {
862        (e1, (r / big_c).sqrt(), (r / -d).sqrt())
863    } else {
864        (e2, (-r / -d).sqrt(), (-r / big_c).sqrt())
865    };
866    if !(major > tol.confusion() && minor > tol.confusion()) {
867        return Ok(Meeting::Touching(vec![apex]));
868    }
869    for toward in [major_dir, -major_dir] {
870        if !on_window_nappe(centre + toward * major) {
871            continue;
872        }
873        let hyperbola = ogeom_math::Hyperbola::new(frame(centre, toward)?, major, minor, tol)?;
874        let extent = span(&|t| ogeom_math::elementary::hyperbola_at(&hyperbola, t).point);
875        out.push(ogeom_geom::HyperbolaCurve::new(hyperbola, extent)?.into());
876    }
877    Ok(if out.is_empty() {
878        Meeting::Apart
879    } else {
880        Meeting::Along(out)
881    })
882}
883
884/// A cone surface's window along its axis.
885fn heights(cone: &ogeom_geom::ConeSurface) -> (f64, f64) {
886    ogeom_geom::Surface::domain(cone).1
887}
888
889/// A plane through a cone's apex: the rulings it holds. A plane steeper
890/// than the cone holds two, one tangent to it holds the one it touches
891/// along, and one shallower meets the cone at the apex alone. Each ruling
892/// is stated over the cone's window of `heights`, one segment for each
893/// nappe the window reaches.
894fn rulings_in(
895    plane: ogeom_math::Plane,
896    cone: &ogeom_math::Cone,
897    heights: (f64, f64),
898    tol: Tolerances,
899) -> Meeting {
900    let apex = cone.apex();
901    let apex_height = cone.frame().to_local(apex).z;
902    // A ruling from the apex along `direction`, cut to the window: the
903    // stretch on each side of the apex the window holds.
904    let stated = |direction: Direction| -> Vec<Curve> {
905        let climb = direction.vector().dot(cone.axis().direction.vector());
906        if climb.abs() <= tol.angular() {
907            return Vec::new();
908        }
909        let (lo, hi) = (heights.0.min(heights.1), heights.0.max(heights.1));
910        let mut out = Vec::new();
911        for (from, to) in [(lo.max(apex_height), hi), (lo, hi.min(apex_height))] {
912            if !(from.is_finite() && to.is_finite()) || to - from <= tol.confusion() {
913                continue;
914            }
915            let (a, b) = ((from - apex_height) / climb, (to - apex_height) / climb);
916            if let Ok(line) = ogeom_geom::LineCurve::over(
917                ogeom_math::Axis::new(apex, direction),
918                a.min(b),
919                a.max(b),
920            ) {
921                out.push(line.into());
922            }
923        }
924        out
925    };
926    let a = cone.axis().direction.vector();
927    let n = plane.normal().vector();
928    let half = cone.half_angle().abs();
929    let across = n.dot(a);
930    // How far the plane leans off tangency: tangent planes make the cone's
931    // half angle with the axis's perpendicular.
932    let lean = across.abs().clamp(0.0, 1.0).asin() - half;
933    if lean.abs() <= 1e-10 {
934        // The ruling the plane touches along: the plane's own direction
935        // nearest the axis.
936        let toward = a - n * across;
937        return match Direction::new(toward, tol).map(stated) {
938            Ok(lines) if !lines.is_empty() => Meeting::Along(lines),
939            _ => Meeting::Touching(vec![apex]),
940        };
941    }
942    if lean > 0.0 {
943        return Meeting::Touching(vec![apex]);
944    }
945    // Two rulings: a direction `cos a + sin (cos t e + sin t f)` lies in the
946    // plane where `cos t` takes the value below.
947    let side = n - a * across;
948    let Ok(e) = Direction::new(side, tol) else {
949        return Meeting::Touching(vec![apex]);
950    };
951    let e = e.vector();
952    let f = a.cross(e);
953    let (sin, cos) = half.sin_cos();
954    let k = (-across * cos / (side.magnitude() * sin)).clamp(-1.0, 1.0);
955    let s = (1.0 - k * k).max(0.0).sqrt();
956    let lines: Vec<Curve> = [s, -s]
957        .into_iter()
958        .filter_map(|t| {
959            let d = a * cos + (e * k + f * t) * sin;
960            Direction::new(d, tol).ok()
961        })
962        .flat_map(stated)
963        .collect();
964    if lines.is_empty() {
965        Meeting::Touching(vec![apex])
966    } else {
967        Meeting::Along(lines)
968    }
969}
970
971/// A cylinder sharing a cone's axis: the parallels where the slant crosses
972/// the cylinder's radius, one on each nappe.
973///
974/// The radius function is linear in height, so it reaches the cylinder's
975/// radius once on the chart's own nappe and once on the far one, past the
976/// apex, where it runs negative; each crossing is a parallel, and the chart
977/// places the far one half a turn round. A cone face stopping short of its
978/// apex keeps only the near one: the far one's image lies outside its
979/// window.
980fn coaxial_cylinder_cone(
981    cylinder: ogeom_math::Cylinder,
982    cone: ogeom_math::Cone,
983    tol: Tolerances,
984) -> OgeomResult<Meeting> {
985    if !cylinder.axis().is_coaxial(cone.axis(), tol) {
986        ogeom_bail!(
987            NotDone,
988            "a cylinder off a cone's axis meets it in a curve only the \
989             general marching intersector can trace"
990        );
991    }
992    let axis = cone.axis();
993    let slope = cone.half_angle().tan();
994    let circles: Vec<Curve> = [cylinder.radius(), -cylinder.radius()]
995        .into_iter()
996        .filter_map(|radius| {
997            let height = (radius - cone.reference_radius()) / slope;
998            cone_parallel(
999                &cone,
1000                axis.location + axis.direction.vector() * height,
1001                cylinder.radius(),
1002                tol,
1003            )
1004        })
1005        .collect();
1006    Ok(if circles.is_empty() {
1007        Meeting::Apart
1008    } else {
1009        Meeting::Along(circles)
1010    })
1011}
1012
1013/// Two cones sharing an axis: the same surface, the shared apex, or the
1014/// parallel where the slants cross.
1015///
1016/// In height and radius coordinates along the shared axis each cone is a line,
1017/// and the crossing is one linear equation; the parallel there is a circle
1018/// unless it lands on the apex, which is a touch.
1019fn coaxial_cones(
1020    a: ogeom_math::Cone,
1021    b: ogeom_math::Cone,
1022    tol: Tolerances,
1023) -> OgeomResult<Meeting> {
1024    if !a.axis().is_coaxial(b.axis(), tol) {
1025        ogeom_bail!(
1026            NotDone,
1027            "two cones that do not share an axis meet in a curve only the \
1028             general marching intersector can trace"
1029        );
1030    }
1031    let axis = a.axis();
1032    // Both radius functions expressed against `a`'s height origin. The axes
1033    // share a sense (`is_coaxial` checked), so the slopes compare directly.
1034    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
1035    let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
1036    let (ref_a, ref_b) = (
1037        a.reference_radius(),
1038        slope_b.mul_add(-lift, b.reference_radius()),
1039    );
1040    if (slope_a - slope_b).abs() <= tol.angular() && (ref_a - ref_b).abs() <= tol.confusion() {
1041        return Ok(Meeting::Same);
1042    }
1043    // Where the radius lines cross, on the same nappes or (one radius the
1044    // other's negative) on opposite ones, past an apex: each crossing is a
1045    // parallel, or a shared apex where the radius there is zero.
1046    let mut heights: Vec<f64> = Vec::new();
1047    if (slope_a - slope_b).abs() > tol.angular() {
1048        heights.push((ref_b - ref_a) / (slope_a - slope_b));
1049    }
1050    if (slope_a + slope_b).abs() > tol.angular() {
1051        heights.push(-(ref_a + ref_b) / (slope_a + slope_b));
1052    }
1053    let mut circles: Vec<Curve> = Vec::new();
1054    let mut touches: Vec<Point> = Vec::new();
1055    for height in heights {
1056        let radius = a.radius_at(height).abs();
1057        let centre = axis.location + axis.direction.vector() * height;
1058        if radius <= tol.confusion() {
1059            if touches.iter().all(|t| t.distance(centre) > tol.confusion()) {
1060                touches.push(centre);
1061            }
1062            continue;
1063        }
1064        let repeated = circles.iter().any(|c| {
1065            matches!(c, Curve::Circle(k) if k.circle().centre().distance(centre) <= tol.confusion())
1066        });
1067        if !repeated && let Some(circle) = cone_parallel(&a, centre, radius, tol) {
1068            circles.push(circle);
1069        }
1070    }
1071    Ok(if !circles.is_empty() {
1072        Meeting::Along(circles)
1073    } else if !touches.is_empty() {
1074        Meeting::Touching(touches)
1075    } else {
1076        Meeting::Apart
1077    })
1078}
1079
1080/// A parallel of a cone, framed on the cone's own frame so parameters carry.
1081fn cone_parallel(
1082    cone: &ogeom_math::Cone,
1083    centre: Point,
1084    radius: f64,
1085    tol: Tolerances,
1086) -> Option<Curve> {
1087    if radius <= tol.confusion() {
1088        return None;
1089    }
1090    let frame = cone.frame();
1091    let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
1092    Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
1093}
1094
1095/// Two tori sharing an axis: the same surface, apart, or circles where the
1096/// tube profiles cross.
1097///
1098/// In the shared meridian half-plane the two tubes are two circles, and
1099/// revolving their meetings gives the answer: radical-line algebra in the
1100/// `(distance-from-axis, height)` plane, each solution a parallel.
1101fn coaxial_tori(
1102    a: ogeom_math::Torus,
1103    b: ogeom_math::Torus,
1104    tol: Tolerances,
1105) -> OgeomResult<Meeting> {
1106    if !a.axis().is_coaxial(b.axis(), tol) {
1107        ogeom_bail!(
1108            NotDone,
1109            "two tori that do not share an axis meet in a curve only the \
1110             general marching intersector can trace"
1111        );
1112    }
1113    let axis = a.axis();
1114    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
1115    if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
1116        && lift.abs() <= tol.confusion()
1117        && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
1118    {
1119        return Ok(Meeting::Same);
1120    }
1121    // Profile circles in a meridian plane: each tube is two circles, centred
1122    // its major radius either side of the axis at its height, radii the
1123    // minors. A spindle's far circle reaches past the axis onto this side,
1124    // so every pairing is met.
1125    let (ra, rb) = (a.minor_radius(), b.minor_radius());
1126    let profile_points = profile_meetings(
1127        &[
1128            (ogeom_math::Point2::new(a.major_radius(), 0.0), ra),
1129            (ogeom_math::Point2::new(-a.major_radius(), 0.0), ra),
1130        ],
1131        &[
1132            (ogeom_math::Point2::new(b.major_radius(), lift), rb),
1133            (ogeom_math::Point2::new(-b.major_radius(), lift), rb),
1134        ],
1135        tol,
1136    );
1137    let circles: Vec<Curve> = profile_points
1138        .into_iter()
1139        .filter_map(|p| {
1140            circle_on(
1141                axis.location + axis.direction.vector() * p.y,
1142                axis.direction,
1143                p.x,
1144                tol,
1145            )
1146        })
1147        .collect();
1148    Ok(if circles.is_empty() {
1149        Meeting::Apart
1150    } else {
1151        Meeting::Along(circles)
1152    })
1153}
1154
1155/// Where circles in a meridian plane meet, on the plane's own side of the
1156/// axis (a positive distance from it): each meeting revolves into a
1157/// parallel. Every circle of `a` is met with every circle of `b`; the
1158/// meetings come back once each.
1159fn profile_meetings(
1160    a: &[(ogeom_math::Point2, f64)],
1161    b: &[(ogeom_math::Point2, f64)],
1162    tol: Tolerances,
1163) -> Vec<ogeom_math::Point2> {
1164    let mut points: Vec<ogeom_math::Point2> = Vec::new();
1165    for &(ca, ra) in a {
1166        for &(cb, rb) in b {
1167            let between = cb - ca;
1168            let distance = between.magnitude();
1169            // Concentric circles of different radii never meet; the same
1170            // circle is the callers' `Same`.
1171            if distance <= tol.confusion()
1172                || distance > ra + rb + tol.confusion()
1173                || distance < (ra - rb).abs() - tol.confusion()
1174            {
1175                continue;
1176            }
1177            let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
1178            let squared = ra.mul_add(ra, -(along * along));
1179            let direction = between * (1.0 / distance);
1180            let foot = ca + direction * along;
1181            let found = if squared <= tol.confusion() * tol.confusion() {
1182                vec![foot]
1183            } else {
1184                let offset =
1185                    ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
1186                vec![foot + offset, foot - offset]
1187            };
1188            for p in found {
1189                if p.x > tol.confusion() && points.iter().all(|q| q.distance(p) > tol.confusion()) {
1190                    points.push(p);
1191                }
1192            }
1193        }
1194    }
1195    points
1196}
1197
1198/// A sphere centred on a torus's axis: in a meridian plane the sphere is a
1199/// circle on the axis and the tube two circles beside it, and each place
1200/// they meet revolves into a parallel; a sphere seated in the tube touches
1201/// it along one. A sphere off the axis meets the torus in a curve only the
1202/// marcher traces.
1203fn axial_sphere_torus(
1204    sphere: ogeom_math::Sphere,
1205    torus: ogeom_math::Torus,
1206    tol: Tolerances,
1207) -> OgeomResult<Meeting> {
1208    let axis = torus.axis();
1209    let lift = (sphere.centre() - axis.location).dot(axis.direction.vector());
1210    let foot = axis.location + axis.direction.vector() * lift;
1211    if foot.distance(sphere.centre()) > tol.confusion() {
1212        ogeom_bail!(
1213            NotDone,
1214            "a sphere off a torus's axis meets it in a curve only the general \
1215             marching intersector can trace"
1216        );
1217    }
1218    let tube = torus.minor_radius();
1219    let points = profile_meetings(
1220        &[(ogeom_math::Point2::new(0.0, lift), sphere.radius())],
1221        &[
1222            (ogeom_math::Point2::new(torus.major_radius(), 0.0), tube),
1223            (ogeom_math::Point2::new(-torus.major_radius(), 0.0), tube),
1224        ],
1225        tol,
1226    );
1227    let circles: Vec<Curve> = points
1228        .into_iter()
1229        .filter_map(|p| {
1230            circle_on(
1231                axis.location + axis.direction.vector() * p.y,
1232                axis.direction,
1233                p.x,
1234                tol,
1235            )
1236        })
1237        .collect();
1238    Ok(if circles.is_empty() {
1239        Meeting::Apart
1240    } else {
1241        Meeting::Along(circles)
1242    })
1243}
1244
1245/// Where an axis crosses a plane.
1246fn intersect_axis_plane(
1247    axis: ogeom_math::Axis,
1248    plane: ogeom_math::Plane,
1249    tol: Tolerances,
1250) -> OgeomResult<Point> {
1251    let along = plane.normal().dot(axis.direction);
1252    if along.abs() <= tol.angular() {
1253        ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
1254    }
1255    let t = -plane.signed_distance_to(axis.location) / along;
1256    Ok(axis.location + axis.direction.vector() * t)
1257}
1258
1259/// A full circle in the plane through `centre` with the given normal.
1260fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
1261    if radius <= tol.confusion() {
1262        return None;
1263    }
1264    // Any perpendicular will do for where the parameterization starts.
1265    let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
1266        Vector::X
1267    } else {
1268        Vector::Y
1269    };
1270    let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
1271    let frame = Frame::new(centre, normal, x, tol).ok()?;
1272    Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
1273}
1274
1275/// An unbounded line through a point.
1276fn line_through(through: Point, direction: Direction) -> Curve {
1277    ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
1278}