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)) => 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::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/// A plane and a cylinder.
203///
204/// Three genuinely different answers depending on the angle between them, and
205/// the whole reason a closed form is worth having: a circle, an ellipse, or a
206/// pair of straight lines, each exact.
207fn plane_cylinder(
208    plane: ogeom_math::Plane,
209    cylinder: ogeom_math::Cylinder,
210    tol: Tolerances,
211) -> OgeomResult<Meeting> {
212    let axis = cylinder.axis();
213    let along = plane.normal().dot(axis.direction);
214
215    // The plane contains the axis direction: the section is straight lines,
216    // one for each side the plane cuts, or none if it misses.
217    if along.abs() <= tol.angular() {
218        let gap = plane.signed_distance_to(axis.location);
219        let reach = gap.abs();
220        if reach > cylinder.radius() + tol.confusion() {
221            return Ok(Meeting::Apart);
222        }
223        // How far along the plane, from the foot of the axis, each line sits.
224        let offset = cylinder
225            .radius()
226            .mul_add(cylinder.radius(), -(gap * gap))
227            .max(0.0)
228            .sqrt();
229        let foot = plane.project(axis.location);
230        let sideways =
231            Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
232        if offset <= tol.confusion() {
233            // Tangent along one line.
234            return Ok(Meeting::Along(vec![line_through(foot, axis.direction)]));
235        }
236        return Ok(Meeting::Along(vec![
237            line_through(foot + sideways.vector() * offset, axis.direction),
238            line_through(foot - sideways.vector() * offset, axis.direction),
239        ]));
240    }
241
242    // Perpendicular to the axis: a circle of the cylinder's own radius.
243    let centre = intersect_axis_plane(axis, plane, tol)?;
244    if square_to_axis(along, tol) {
245        return Ok(
246            match circle_on(centre, plane.normal(), cylinder.radius(), tol) {
247                Some(circle) => Meeting::Along(vec![circle]),
248                None => Meeting::Apart,
249            },
250        );
251    }
252
253    // Oblique: an ellipse. Its minor axis is the cylinder's radius, across the
254    // slope; its major is that divided by the cosine of the tilt, along it.
255    let minor = cylinder.radius();
256    let major = minor / along.abs();
257    // The minor axis runs where the plane and a plane perpendicular to the axis
258    // agree: the cross of the two normals.
259    let minor_direction =
260        Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
261    let major_direction =
262        Direction::from_cross(minor_direction.vector(), plane.normal().vector(), tol)?;
263    let frame = Frame::from_axes(
264        centre,
265        major_direction,
266        minor_direction,
267        plane.normal(),
268        tol,
269    )?;
270    Ok(Meeting::Along(vec![
271        ogeom_geom::EllipseCurve::new(Ellipse::new(frame, major, minor, tol)?).into(),
272    ]))
273}
274
275/// Two spheres: apart, tangent at a point, the same, or a circle.
276fn sphere_sphere(a: ogeom_math::Sphere, b: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
277    let between = b.centre() - a.centre();
278    let distance = between.magnitude();
279    if distance <= tol.confusion() {
280        return if (a.radius() - b.radius()).abs() <= tol.confusion() {
281            Meeting::Same
282        } else {
283            // Concentric and different: one inside the other, never meeting.
284            Meeting::Apart
285        };
286    }
287    let (ra, rb) = (a.radius(), b.radius());
288    if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
289        return Meeting::Apart;
290    }
291    let Ok(direction) = Direction::new(between, tol) else {
292        return Meeting::Apart;
293    };
294    // Where the plane of the intersection circle crosses the line of centres.
295    let reach = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
296    let centre = a.centre() + direction.vector() * reach;
297    let squared = ra.mul_add(ra, -(reach * reach));
298    if squared <= tol.confusion() * tol.confusion() {
299        return Meeting::Touching(vec![centre]);
300    }
301    match circle_on(centre, direction, squared.max(0.0).sqrt(), tol) {
302        Some(circle) => Meeting::Along(vec![circle]),
303        None => Meeting::Touching(vec![centre]),
304    }
305}
306
307/// Two cylinders sharing an axis.
308///
309/// The only cylinder pair with a closed form worth writing down. Two general
310/// cylinders meet in a quartic space curve, which is what the marching
311/// intersector is for.
312fn coaxial_cylinders(
313    a: ogeom_math::Cylinder,
314    b: ogeom_math::Cylinder,
315    tol: Tolerances,
316) -> OgeomResult<Meeting> {
317    if !a.axis().is_coaxial(b.axis(), tol) {
318        // Equal radii with intersecting axes: the one crossing whose quartic
319        // factors, into the two ellipses in the axes' bisector planes, each
320        // an oblique plane section the plane machinery already speaks. The
321        // ellipses cross at the two points where the cylinders are tangent;
322        // that is the crossing's geometry, stated exactly rather than
323        // marched through.
324        if (a.radius() - b.radius()).abs() <= tol.confusion() {
325            let (da, db) = (a.axis().direction.vector(), b.axis().direction.vector());
326            let normal = da.cross(db);
327            if normal.magnitude() > tol.angular() {
328                let (pa, pb) = (a.axis().location, b.axis().location);
329                // Closest points of the two axis lines; coincident when the
330                // axes genuinely intersect.
331                let w = pb - pa;
332                let dd = da.dot(db);
333                let denom = dd.mul_add(-dd, 1.0);
334                let s = dd.mul_add(-db.dot(w), da.dot(w)) / denom;
335                let t = dd.mul_add(da.dot(w), -db.dot(w)) / denom;
336                let on_a = pa + da * s;
337                let on_b = pb + db * t;
338                if on_a.distance(on_b) <= tol.confusion() {
339                    let centre = on_a;
340                    let mut curves = Vec::new();
341                    for m in [da - db, da + db] {
342                        if m.magnitude() <= tol.angular() {
343                            continue;
344                        }
345                        let plane =
346                            ogeom_math::Plane::through(centre, ogeom_math::Direction::new(m, tol)?);
347                        if let Meeting::Along(mut found) = plane_cylinder(plane, a, tol)? {
348                            curves.append(&mut found);
349                        }
350                    }
351                    if !curves.is_empty() {
352                        return Ok(Meeting::Along(curves));
353                    }
354                }
355            }
356        }
357        ogeom_bail!(
358            NotDone,
359            "two cylinders that do not share an axis meet in a quartic space \
360             curve, which needs the general marching intersector"
361        );
362    }
363    Ok(if (a.radius() - b.radius()).abs() <= tol.confusion() {
364        Meeting::Same
365    } else {
366        // Same axis, different radii: one inside the other, touching nowhere.
367        Meeting::Apart
368    })
369}
370
371/// A cylinder and a sphere whose centre is on the cylinder's axis.
372fn coaxial_cylinder_sphere(
373    cylinder: ogeom_math::Cylinder,
374    sphere: ogeom_math::Sphere,
375    tol: Tolerances,
376) -> OgeomResult<Meeting> {
377    let axis = cylinder.axis();
378    if axis.distance_to(sphere.centre()) > tol.confusion() {
379        ogeom_bail!(
380            NotDone,
381            "a sphere off a cylinder's axis meets it in a quartic space curve, \
382             which needs the general marching intersector"
383        );
384    }
385    let (r, radius) = (cylinder.radius(), sphere.radius());
386    if r > radius + tol.confusion() {
387        return Ok(Meeting::Apart);
388    }
389    if (r - radius).abs() <= tol.confusion() {
390        // The sphere's equator lies on the cylinder, and they are tangent
391        // along it rather than crossing.
392        let centre = sphere.centre();
393        return Ok(match circle_on(centre, axis.direction, r, tol) {
394            Some(circle) => Meeting::Along(vec![circle]),
395            None => Meeting::Apart,
396        });
397    }
398    // Two circles, symmetric about the sphere's centre.
399    let reach = radius.mul_add(radius, -(r * r)).max(0.0).sqrt();
400    let mut out = Vec::with_capacity(2);
401    for side in [reach, -reach] {
402        let centre = sphere.centre() + axis.direction.vector() * side;
403        if let Some(circle) = circle_on(centre, axis.direction, r, tol) {
404            out.push(circle);
405        }
406    }
407    Ok(if out.is_empty() {
408        Meeting::Apart
409    } else {
410        Meeting::Along(out)
411    })
412}
413
414/// A plane perpendicular to a torus's axis: apart, one tangent circle, or two
415/// parallels. A plane through the axis: two meridians.
416///
417/// Those are the plane/torus configurations with a closed form worth the
418/// name: an oblique plane, or one parallel to the axis and off it, meets a
419/// torus in a quartic (with Villarceau's circles at exactly one magic
420/// tilt), and that is the marching intersector's business. A plane through
421/// the axis often holds the torus's seam, and a fitted section there never
422/// meets the seam's own vertices. The blend machinery lives on this case:
423/// a rolling ball's toroidal envelope is tangent to the plane it rolls on
424/// along a circle, and that tangency must be *reported as the circle it is*,
425/// the way a tangent plane reports its line on a cylinder; a tangential
426/// answer with no curve in it would send the boolean above into a refusal.
427fn axial_plane_torus(
428    plane: ogeom_math::Plane,
429    torus: ogeom_math::Torus,
430    tol: Tolerances,
431) -> OgeomResult<Meeting> {
432    let axis = torus.axis();
433    let along = plane.normal().dot(axis.direction);
434    if along.abs() <= tol.angular()
435        && plane.signed_distance_to(axis.location).abs() <= tol.confusion()
436    {
437        return Ok(meridians(plane, torus, tol));
438    }
439    if !square_to_axis(along, tol) {
440        ogeom_bail!(
441            NotDone,
442            "a plane oblique to a torus's axis, or parallel to it and off it, \
443             meets it in a quartic, which needs the general marching \
444             intersector"
445        );
446    }
447    // The plane's height above the tube's centre plane.
448    let height = -plane.signed_distance_to(axis.location) * along.signum();
449    let minor = torus.minor_radius();
450    if height.abs() > minor + tol.confusion() {
451        return Ok(Meeting::Apart);
452    }
453    let centre = axis.location + axis.direction.vector() * height;
454    if (height.abs() - minor).abs() <= tol.confusion() {
455        // Tangent along the parallel at the tube's top or bottom.
456        return Ok(
457            match circle_on(centre, axis.direction, torus.major_radius(), tol) {
458                Some(circle) => Meeting::Along(vec![circle]),
459                None => Meeting::Apart,
460            },
461        );
462    }
463    // Two parallels, one either side of the tube. A spindle's tube swallows
464    // the axis, and its inner parallel then lies on the tube's other half,
465    // past the axis: the same circle, at the radius's size.
466    let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
467    let circles: Vec<Curve> = [
468        torus.major_radius() + spread,
469        (torus.major_radius() - spread).abs(),
470    ]
471    .into_iter()
472    .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
473    .collect();
474    Ok(if circles.is_empty() {
475        Meeting::Apart
476    } else {
477        Meeting::Along(circles)
478    })
479}
480
481/// A plane through a torus's axis: the two tube circles either side of the
482/// axis, each starting on the outer equator as the torus's own meridians
483/// do, so a section lying on the torus's seam starts where the seam does.
484fn meridians(plane: ogeom_math::Plane, torus: ogeom_math::Torus, tol: Tolerances) -> Meeting {
485    let axis = torus.axis();
486    let normal = plane.normal();
487    let Ok(out) = Direction::from_cross(axis.direction.vector(), normal.vector(), tol) else {
488        return Meeting::Apart;
489    };
490    let circles: Vec<Curve> = [out.vector(), -out.vector()]
491        .into_iter()
492        .filter_map(|radial| {
493            let centre = axis.location + radial * torus.major_radius();
494            let x = Direction::new(radial, tol).ok()?;
495            let frame = Frame::new(centre, normal, x, tol).ok()?;
496            let circle = Circle::new(frame, torus.minor_radius(), tol).ok()?;
497            Some(ogeom_geom::CircleCurve::new(circle).into())
498        })
499        .collect();
500    Meeting::Along(circles)
501}
502
503/// A cylinder sharing a torus's axis: apart, one tangent circle, or two
504/// parallels at mirrored heights.
505fn coaxial_cylinder_torus(
506    cylinder: ogeom_math::Cylinder,
507    torus: ogeom_math::Torus,
508    tol: Tolerances,
509) -> OgeomResult<Meeting> {
510    if !cylinder.axis().is_coaxial(torus.axis(), tol) {
511        ogeom_bail!(
512            NotDone,
513            "a cylinder off a torus's axis meets it in a quartic space curve, \
514             which needs the general marching intersector"
515        );
516    }
517    let axis = torus.axis();
518    let minor = torus.minor_radius();
519    // In a meridian plane the tube is two circles, centred the major radius
520    // either side of the axis; the far one reaches this side only on a
521    // spindle, whose tube swallows the axis. The cylinder's line meets each
522    // it reaches: tangent at the tube's equator, or at mirrored heights.
523    let mut heights: Vec<f64> = Vec::new();
524    for reach in [
525        (cylinder.radius() - torus.major_radius()).abs(),
526        cylinder.radius() + torus.major_radius(),
527    ] {
528        if reach > minor + tol.confusion() {
529            continue;
530        }
531        if (reach - minor).abs() <= tol.confusion() {
532            heights.push(0.0);
533            continue;
534        }
535        let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
536        heights.extend([rise, -rise]);
537    }
538    let mut kept: Vec<f64> = Vec::new();
539    for height in heights {
540        if kept.iter().all(|k| (k - height).abs() > tol.confusion()) {
541            kept.push(height);
542        }
543    }
544    let circles: Vec<Curve> = kept
545        .into_iter()
546        .filter_map(|height| {
547            circle_on(
548                axis.location + axis.direction.vector() * height,
549                axis.direction,
550                cylinder.radius(),
551                tol,
552            )
553        })
554        .collect();
555    Ok(if circles.is_empty() {
556        Meeting::Apart
557    } else {
558        Meeting::Along(circles)
559    })
560}
561
562/// A plane square to a cone's axis: the parallel at that height, or the apex.
563///
564/// The perpendicular slice is the configuration the rebuilds lean on (a
565/// drafted wall's cap, a chamfer cone against the face it melts into), and
566/// the answer is a circle framed on the cone's own frame, so a caller
567/// re-deriving an edge finds its parameters where the old ones were. An
568/// oblique plane meets a cone in a conic, which is the marching
569/// intersector's business.
570fn plane_cone(
571    plane: ogeom_math::Plane,
572    cone: ogeom_math::Cone,
573    heights: (f64, f64),
574    tol: Tolerances,
575) -> OgeomResult<Meeting> {
576    let axis = cone.axis();
577    let along = plane.normal().dot(axis.direction);
578    if !square_to_axis(along, tol) && plane.signed_distance_to(cone.apex()).abs() <= tol.confusion()
579    {
580        return Ok(rulings_in(plane, &cone, heights, tol));
581    }
582    if !square_to_axis(along, tol) {
583        ogeom_bail!(
584            NotDone,
585            "a plane oblique to a cone's axis meets it in a conic, which \
586             needs the general marching intersector"
587        );
588    }
589    // The plane's height along the axis, from the cone frame's origin.
590    let height = -plane.signed_distance_to(axis.location) * along.signum();
591    let radius = cone.radius_at(height);
592    if radius.abs() <= tol.confusion() {
593        // The plane passes through the apex, where the parallel has no
594        // length: a touch, not a curve.
595        return Ok(Meeting::Touching(vec![cone.apex()]));
596    }
597    // Past the apex, on the far nappe, the radius runs negative: the
598    // parallel is the circle of its size, which the chart places half a
599    // turn round.
600    let centre = axis.location + axis.direction.vector() * height;
601    Ok(match cone_parallel(&cone, centre, radius.abs(), tol) {
602        Some(circle) => Meeting::Along(vec![circle]),
603        None => Meeting::Apart,
604    })
605}
606
607/// A cone surface's window along its axis.
608fn heights(cone: &ogeom_geom::ConeSurface) -> (f64, f64) {
609    ogeom_geom::Surface::domain(cone).1
610}
611
612/// A plane through a cone's apex: the rulings it holds. A plane steeper
613/// than the cone holds two, one tangent to it holds the one it touches
614/// along, and one shallower meets the cone at the apex alone. Each ruling
615/// is stated over the cone's window of `heights`, one segment for each
616/// nappe the window reaches.
617fn rulings_in(
618    plane: ogeom_math::Plane,
619    cone: &ogeom_math::Cone,
620    heights: (f64, f64),
621    tol: Tolerances,
622) -> Meeting {
623    let apex = cone.apex();
624    let apex_height = cone.frame().to_local(apex).z;
625    // A ruling from the apex along `direction`, cut to the window: the
626    // stretch on each side of the apex the window holds.
627    let stated = |direction: Direction| -> Vec<Curve> {
628        let climb = direction.vector().dot(cone.axis().direction.vector());
629        if climb.abs() <= tol.angular() {
630            return Vec::new();
631        }
632        let (lo, hi) = (heights.0.min(heights.1), heights.0.max(heights.1));
633        let mut out = Vec::new();
634        for (from, to) in [(lo.max(apex_height), hi), (lo, hi.min(apex_height))] {
635            if !(from.is_finite() && to.is_finite()) || to - from <= tol.confusion() {
636                continue;
637            }
638            let (a, b) = ((from - apex_height) / climb, (to - apex_height) / climb);
639            if let Ok(line) = ogeom_geom::LineCurve::over(
640                ogeom_math::Axis::new(apex, direction),
641                a.min(b),
642                a.max(b),
643            ) {
644                out.push(line.into());
645            }
646        }
647        out
648    };
649    let a = cone.axis().direction.vector();
650    let n = plane.normal().vector();
651    let half = cone.half_angle().abs();
652    let across = n.dot(a);
653    // How far the plane leans off tangency: tangent planes make the cone's
654    // half angle with the axis's perpendicular.
655    let lean = across.abs().clamp(0.0, 1.0).asin() - half;
656    if lean.abs() <= 1e-10 {
657        // The ruling the plane touches along: the plane's own direction
658        // nearest the axis.
659        let toward = a - n * across;
660        return match Direction::new(toward, tol).map(stated) {
661            Ok(lines) if !lines.is_empty() => Meeting::Along(lines),
662            _ => Meeting::Touching(vec![apex]),
663        };
664    }
665    if lean > 0.0 {
666        return Meeting::Touching(vec![apex]);
667    }
668    // Two rulings: a direction `cos a + sin (cos t e + sin t f)` lies in the
669    // plane where `cos t` takes the value below.
670    let side = n - a * across;
671    let Ok(e) = Direction::new(side, tol) else {
672        return Meeting::Touching(vec![apex]);
673    };
674    let e = e.vector();
675    let f = a.cross(e);
676    let (sin, cos) = half.sin_cos();
677    let k = (-across * cos / (side.magnitude() * sin)).clamp(-1.0, 1.0);
678    let s = (1.0 - k * k).max(0.0).sqrt();
679    let lines: Vec<Curve> = [s, -s]
680        .into_iter()
681        .filter_map(|t| {
682            let d = a * cos + (e * k + f * t) * sin;
683            Direction::new(d, tol).ok()
684        })
685        .flat_map(stated)
686        .collect();
687    if lines.is_empty() {
688        Meeting::Touching(vec![apex])
689    } else {
690        Meeting::Along(lines)
691    }
692}
693
694/// A cylinder sharing a cone's axis: the parallels where the slant crosses
695/// the cylinder's radius, one on each nappe.
696///
697/// The radius function is linear in height, so it reaches the cylinder's
698/// radius once on the chart's own nappe and once on the far one, past the
699/// apex, where it runs negative; each crossing is a parallel, and the chart
700/// places the far one half a turn round. A cone face stopping short of its
701/// apex keeps only the near one: the far one's image lies outside its
702/// window.
703fn coaxial_cylinder_cone(
704    cylinder: ogeom_math::Cylinder,
705    cone: ogeom_math::Cone,
706    tol: Tolerances,
707) -> OgeomResult<Meeting> {
708    if !cylinder.axis().is_coaxial(cone.axis(), tol) {
709        ogeom_bail!(
710            NotDone,
711            "a cylinder off a cone's axis meets it in a curve only the \
712             general marching intersector can trace"
713        );
714    }
715    let axis = cone.axis();
716    let slope = cone.half_angle().tan();
717    let circles: Vec<Curve> = [cylinder.radius(), -cylinder.radius()]
718        .into_iter()
719        .filter_map(|radius| {
720            let height = (radius - cone.reference_radius()) / slope;
721            cone_parallel(
722                &cone,
723                axis.location + axis.direction.vector() * height,
724                cylinder.radius(),
725                tol,
726            )
727        })
728        .collect();
729    Ok(if circles.is_empty() {
730        Meeting::Apart
731    } else {
732        Meeting::Along(circles)
733    })
734}
735
736/// Two cones sharing an axis: the same surface, the shared apex, or the
737/// parallel where the slants cross.
738///
739/// In height and radius coordinates along the shared axis each cone is a line,
740/// and the crossing is one linear equation; the parallel there is a circle
741/// unless it lands on the apex, which is a touch.
742fn coaxial_cones(
743    a: ogeom_math::Cone,
744    b: ogeom_math::Cone,
745    tol: Tolerances,
746) -> OgeomResult<Meeting> {
747    if !a.axis().is_coaxial(b.axis(), tol) {
748        ogeom_bail!(
749            NotDone,
750            "two cones that do not share an axis meet in a curve only the \
751             general marching intersector can trace"
752        );
753    }
754    let axis = a.axis();
755    // Both radius functions expressed against `a`'s height origin. The axes
756    // share a sense (`is_coaxial` checked), so the slopes compare directly.
757    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
758    let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
759    let (ref_a, ref_b) = (
760        a.reference_radius(),
761        slope_b.mul_add(-lift, b.reference_radius()),
762    );
763    if (slope_a - slope_b).abs() <= tol.angular() && (ref_a - ref_b).abs() <= tol.confusion() {
764        return Ok(Meeting::Same);
765    }
766    // Where the radius lines cross, on the same nappes or (one radius the
767    // other's negative) on opposite ones, past an apex: each crossing is a
768    // parallel, or a shared apex where the radius there is zero.
769    let mut heights: Vec<f64> = Vec::new();
770    if (slope_a - slope_b).abs() > tol.angular() {
771        heights.push((ref_b - ref_a) / (slope_a - slope_b));
772    }
773    if (slope_a + slope_b).abs() > tol.angular() {
774        heights.push(-(ref_a + ref_b) / (slope_a + slope_b));
775    }
776    let mut circles: Vec<Curve> = Vec::new();
777    let mut touches: Vec<Point> = Vec::new();
778    for height in heights {
779        let radius = a.radius_at(height).abs();
780        let centre = axis.location + axis.direction.vector() * height;
781        if radius <= tol.confusion() {
782            if touches.iter().all(|t| t.distance(centre) > tol.confusion()) {
783                touches.push(centre);
784            }
785            continue;
786        }
787        let repeated = circles.iter().any(|c| {
788            matches!(c, Curve::Circle(k) if k.circle().centre().distance(centre) <= tol.confusion())
789        });
790        if !repeated && let Some(circle) = cone_parallel(&a, centre, radius, tol) {
791            circles.push(circle);
792        }
793    }
794    Ok(if !circles.is_empty() {
795        Meeting::Along(circles)
796    } else if !touches.is_empty() {
797        Meeting::Touching(touches)
798    } else {
799        Meeting::Apart
800    })
801}
802
803/// A parallel of a cone, framed on the cone's own frame so parameters carry.
804fn cone_parallel(
805    cone: &ogeom_math::Cone,
806    centre: Point,
807    radius: f64,
808    tol: Tolerances,
809) -> Option<Curve> {
810    if radius <= tol.confusion() {
811        return None;
812    }
813    let frame = cone.frame();
814    let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
815    Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
816}
817
818/// Two tori sharing an axis: the same surface, apart, or circles where the
819/// tube profiles cross.
820///
821/// In the shared meridian half-plane the two tubes are two circles, and
822/// revolving their meetings gives the answer: radical-line algebra in the
823/// `(distance-from-axis, height)` plane, each solution a parallel.
824fn coaxial_tori(
825    a: ogeom_math::Torus,
826    b: ogeom_math::Torus,
827    tol: Tolerances,
828) -> OgeomResult<Meeting> {
829    if !a.axis().is_coaxial(b.axis(), tol) {
830        ogeom_bail!(
831            NotDone,
832            "two tori that do not share an axis meet in a curve only the \
833             general marching intersector can trace"
834        );
835    }
836    let axis = a.axis();
837    let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
838    if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
839        && lift.abs() <= tol.confusion()
840        && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
841    {
842        return Ok(Meeting::Same);
843    }
844    // Profile circles in a meridian plane: each tube is two circles, centred
845    // its major radius either side of the axis at its height, radii the
846    // minors. A spindle's far circle reaches past the axis onto this side,
847    // so every pairing is met.
848    let (ra, rb) = (a.minor_radius(), b.minor_radius());
849    let profile_points = profile_meetings(
850        &[
851            (ogeom_math::Point2::new(a.major_radius(), 0.0), ra),
852            (ogeom_math::Point2::new(-a.major_radius(), 0.0), ra),
853        ],
854        &[
855            (ogeom_math::Point2::new(b.major_radius(), lift), rb),
856            (ogeom_math::Point2::new(-b.major_radius(), lift), rb),
857        ],
858        tol,
859    );
860    let circles: Vec<Curve> = profile_points
861        .into_iter()
862        .filter_map(|p| {
863            circle_on(
864                axis.location + axis.direction.vector() * p.y,
865                axis.direction,
866                p.x,
867                tol,
868            )
869        })
870        .collect();
871    Ok(if circles.is_empty() {
872        Meeting::Apart
873    } else {
874        Meeting::Along(circles)
875    })
876}
877
878/// Where circles in a meridian plane meet, on the plane's own side of the
879/// axis (a positive distance from it): each meeting revolves into a
880/// parallel. Every circle of `a` is met with every circle of `b`; the
881/// meetings come back once each.
882fn profile_meetings(
883    a: &[(ogeom_math::Point2, f64)],
884    b: &[(ogeom_math::Point2, f64)],
885    tol: Tolerances,
886) -> Vec<ogeom_math::Point2> {
887    let mut points: Vec<ogeom_math::Point2> = Vec::new();
888    for &(ca, ra) in a {
889        for &(cb, rb) in b {
890            let between = cb - ca;
891            let distance = between.magnitude();
892            // Concentric circles of different radii never meet; the same
893            // circle is the callers' `Same`.
894            if distance <= tol.confusion()
895                || distance > ra + rb + tol.confusion()
896                || distance < (ra - rb).abs() - tol.confusion()
897            {
898                continue;
899            }
900            let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
901            let squared = ra.mul_add(ra, -(along * along));
902            let direction = between * (1.0 / distance);
903            let foot = ca + direction * along;
904            let found = if squared <= tol.confusion() * tol.confusion() {
905                vec![foot]
906            } else {
907                let offset =
908                    ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
909                vec![foot + offset, foot - offset]
910            };
911            for p in found {
912                if p.x > tol.confusion() && points.iter().all(|q| q.distance(p) > tol.confusion()) {
913                    points.push(p);
914                }
915            }
916        }
917    }
918    points
919}
920
921/// A sphere centred on a torus's axis: in a meridian plane the sphere is a
922/// circle on the axis and the tube two circles beside it, and each place
923/// they meet revolves into a parallel; a sphere seated in the tube touches
924/// it along one. A sphere off the axis meets the torus in a curve only the
925/// marcher traces.
926fn axial_sphere_torus(
927    sphere: ogeom_math::Sphere,
928    torus: ogeom_math::Torus,
929    tol: Tolerances,
930) -> OgeomResult<Meeting> {
931    let axis = torus.axis();
932    let lift = (sphere.centre() - axis.location).dot(axis.direction.vector());
933    let foot = axis.location + axis.direction.vector() * lift;
934    if foot.distance(sphere.centre()) > tol.confusion() {
935        ogeom_bail!(
936            NotDone,
937            "a sphere off a torus's axis meets it in a curve only the general \
938             marching intersector can trace"
939        );
940    }
941    let tube = torus.minor_radius();
942    let points = profile_meetings(
943        &[(ogeom_math::Point2::new(0.0, lift), sphere.radius())],
944        &[
945            (ogeom_math::Point2::new(torus.major_radius(), 0.0), tube),
946            (ogeom_math::Point2::new(-torus.major_radius(), 0.0), tube),
947        ],
948        tol,
949    );
950    let circles: Vec<Curve> = points
951        .into_iter()
952        .filter_map(|p| {
953            circle_on(
954                axis.location + axis.direction.vector() * p.y,
955                axis.direction,
956                p.x,
957                tol,
958            )
959        })
960        .collect();
961    Ok(if circles.is_empty() {
962        Meeting::Apart
963    } else {
964        Meeting::Along(circles)
965    })
966}
967
968/// Where an axis crosses a plane.
969fn intersect_axis_plane(
970    axis: ogeom_math::Axis,
971    plane: ogeom_math::Plane,
972    tol: Tolerances,
973) -> OgeomResult<Point> {
974    let along = plane.normal().dot(axis.direction);
975    if along.abs() <= tol.angular() {
976        ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
977    }
978    let t = -plane.signed_distance_to(axis.location) / along;
979    Ok(axis.location + axis.direction.vector() * t)
980}
981
982/// A full circle in the plane through `centre` with the given normal.
983fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
984    if radius <= tol.confusion() {
985        return None;
986    }
987    // Any perpendicular will do for where the parameterization starts.
988    let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
989        Vector::X
990    } else {
991        Vector::Y
992    };
993    let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
994    let frame = Frame::new(centre, normal, x, tol).ok()?;
995    Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
996}
997
998/// An unbounded line through a point.
999fn line_through(through: Point, direction: Direction) -> Curve {
1000    ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
1001}