axiolid_nurbs/
exact_surface_intersection.rs

1//! Exact intersection curves for elementary surface pairs.
2//!
3//! A traced-and-fitted spline is an approximation with an error bound. For
4//! the surface pairs whose intersection has a closed-form conic or linear
5//! answer, no fitting is needed: the curve is derived symbolically from the
6//! operands and is exact in the same sense as the rest of the exact B-rep
7//! path.
8//!
9//! This module covers only those pairs, and refuses everything else rather
10//! than falling back to approximation. Each derivation states the identity
11//! it relies on, so a reader can check the algebra rather than trust it.
12
13use axiolid_core::{Frame3, Interval, Point3, Scalar, Vec3};
14use axiolid_curve::{Circle3, Curve3, Ellipse3};
15use axiolid_exact::{Arith, Dyadic};
16use axiolid_guarantees::Sign;
17use axiolid_surface::{Cylinder, Plane, Sphere, Surface};
18
19// --- exact decisions ---------------------------------------------------------
20//
21// Which closed form applies (tangent or crossing, parallel or oblique,
22// perpendicular or tilted) is a sign question about the operands' own
23// doubles. It is decided here in exact dyadic arithmetic, so a tangency
24// that holds exactly for the given numbers is never mistaken for a tiny
25// crossing, and vice versa. The constructed curves are still `f64`: their
26// sizes are rounded once, from exact numerators where that is cheap.
27
28type D3 = [Dyadic; 3];
29
30/// An exact copy of a vector; `None` when a component is not finite.
31fn exact3(v: Vec3) -> Option<D3> {
32    Some([
33        Dyadic::try_from_f64(v.x)?,
34        Dyadic::try_from_f64(v.y)?,
35        Dyadic::try_from_f64(v.z)?,
36    ])
37}
38
39fn edot(a: &D3, b: &D3) -> Dyadic {
40    a[0].mul(&b[0]).add(&a[1].mul(&b[1])).add(&a[2].mul(&b[2]))
41}
42
43fn esub(a: &D3, b: &D3) -> D3 {
44    [a[0].sub(&b[0]), a[1].sub(&b[1]), a[2].sub(&b[2])]
45}
46
47fn ecross_is_zero(a: &D3, b: &D3) -> bool {
48    let c = [
49        a[1].mul(&b[2]).sub(&a[2].mul(&b[1])),
50        a[2].mul(&b[0]).sub(&a[0].mul(&b[2])),
51        a[0].mul(&b[1]).sub(&a[1].mul(&b[0])),
52    ];
53    c.iter().all(|v| esign(v) == Sign::Zero)
54}
55
56fn esign(v: &Dyadic) -> Sign {
57    v.sign().expect("dyadic signs are always decided")
58}
59
60/// `r^2 |n|^2 - (n . (c - o))^2`, exactly: positive when the point `c` lies
61/// closer than `r` to the plane through `o` with (non-unit) normal `n`,
62/// zero when exactly at distance `r`. Also returns `|n|^2`.
63fn within_radius(
64    radius: Scalar,
65    normal: Vec3,
66    point: Point3,
67    plane_origin: Point3,
68) -> Result<(Dyadic, Dyadic), ExactIntersectionRefusal> {
69    let bad = ExactIntersectionRefusal::DegenerateFrame;
70    let n = exact3(normal).ok_or(bad.clone())?;
71    let c = exact3(point).ok_or(bad.clone())?;
72    let o = exact3(plane_origin).ok_or(bad.clone())?;
73    let r = Dyadic::try_from_f64(radius).ok_or(bad.clone())?;
74    let nn = edot(&n, &n);
75    if esign(&nn) == Sign::Zero {
76        return Err(bad);
77    }
78    let nd = edot(&n, &esub(&c, &o));
79    Ok((r.square().mul(&nn).sub(&nd.square()), nn))
80}
81
82/// `numerator / nn` rounded once, for a size whose square is known exactly.
83fn rounded_square(numerator: &Dyadic, nn: &Dyadic) -> Result<Scalar, ExactIntersectionRefusal> {
84    let value = numerator.to_f64() / nn.to_f64();
85    if value > 0.0 && value.is_finite() {
86        Ok(value)
87    } else {
88        Err(ExactIntersectionRefusal::DegenerateFrame)
89    }
90}
91
92/// Why an elementary pair has no exact closed-form intersection curve here.
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub enum ExactIntersectionRefusal {
96    /// The pair is not one of the supported elementary combinations.
97    ///
98    /// Not a statement about the geometry: the intersection may well be a
99    /// nameable curve, just not one this module derives.
100    UnsupportedPair,
101    /// The surfaces are parallel or concentric and do not meet at all.
102    Disjoint,
103    /// The surfaces coincide or touch tangentially, so the intersection is
104    /// not a regular curve.
105    ///
106    /// A single tangential point or a shared surface patch cannot be
107    /// returned as a curve without inventing structure.
108    NotRegularCurve,
109    /// A required frame axis was degenerate, so no exact frame can be built.
110    DegenerateFrame,
111    /// The intersection is a parabola or hyperbola, which `Curve3` has no
112    /// variant for.
113    ///
114    /// The curve is perfectly well defined and exactly derivable; it simply
115    /// cannot be represented without adding a conic variant. Refusing names
116    /// that representational gap instead of substituting a nearby ellipse or
117    /// a fitted spline.
118    UnrepresentableConic,
119    /// A traced section could not be decided within its work budget or
120    /// near a singular point whose branches do not match the field's sign
121    /// changes about it. Unlike `NotRegularCurve`, this says nothing of
122    /// whether the surfaces cross there.
123    Undecided,
124}
125
126/// The exact intersection curve of two elementary surfaces, when one exists
127/// in closed form.
128///
129/// Returns the curve together with the identity used to derive it, so a
130/// caller can record provenance rather than re-deriving trust.
131#[derive(Debug, Clone, PartialEq)]
132#[non_exhaustive]
133pub struct ExactIntersectionCurve {
134    /// The branches of the intersection, in deterministic order.
135    ///
136    /// Usually one, but several elementary pairs genuinely meet in TWO
137    /// disjoint components: equal-radius cylinders on intersecting axes
138    /// cut two ellipses, and parallel cylinders cut two lines. Returning a
139    /// single curve would have forced this code to pick one and discard the
140    /// other, which is exactly the silent geometry loss the rest of this
141    /// module refuses to do. Exact: every coordinate comes from the
142    /// operands' own numbers through the stated identity, never from a fit.
143    pub branches: Vec<Curve3>,
144    /// Which closed-form identity produced `branches`.
145    pub derivation: Derivation,
146    /// The parameter span each branch exists on, aligned with `branches`:
147    /// `None` for a curve defined on its whole natural domain (a line, a
148    /// full circle or ellipse), `Some` for a piece of a ruled section
149    /// (ADR 0076), which exists only where its discriminant is not
150    /// negative. Two pieces over one span join at both ends into a loop.
151    pub spans: Vec<Option<Interval>>,
152}
153
154impl ExactIntersectionCurve {
155    /// Branches on their whole natural domains.
156    pub(crate) fn whole(branches: Vec<Curve3>, derivation: Derivation) -> Self {
157        let spans = vec![None; branches.len()];
158        Self {
159            branches,
160            derivation,
161            spans,
162        }
163    }
164
165    /// Branches with explicit spans.
166    pub(crate) fn with_spans(
167        branches: Vec<Curve3>,
168        spans: Vec<Option<Interval>>,
169        derivation: Derivation,
170    ) -> Self {
171        Self {
172            branches,
173            derivation,
174            spans,
175        }
176    }
177
178    /// The sole branch, when the caller expects exactly one.
179    ///
180    /// Panics when there are several: a caller that assumes one branch and
181    /// silently sees only the first would lose geometry, so this fails
182    /// loudly instead.
183    pub fn single(&self) -> &Curve3 {
184        assert_eq!(self.branches.len(), 1, "expected one branch");
185        &self.branches[0]
186    }
187}
188
189/// The closed-form identity behind an exact intersection curve.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191#[non_exhaustive]
192pub enum Derivation {
193    /// Two non-parallel planes meet in a line along `n1 x n2`.
194    PlanePlaneLine,
195    /// A plane perpendicular to a cylinder axis cuts a circle of the
196    /// cylinder's own radius.
197    CylinderPlanePerpendicularCircle,
198    /// A plane oblique to a cylinder axis cuts an ellipse with semi-axes
199    /// `r` and `r / cos(theta)`.
200    CylinderPlaneObliqueEllipse,
201    /// A plane at signed distance `d` from a sphere centre cuts a circle of
202    /// radius `sqrt(r^2 - d^2)`.
203    SpherePlaneCircle,
204    /// Two spheres meet in a circle in their radical plane.
205    SphereSphereCircle,
206    /// Equal-radius cylinders on intersecting axes cut two ellipses.
207    CylinderCylinderSteinmetzEllipses,
208    /// Cylinders with parallel axes meet in one or two axis-parallel lines.
209    ParallelCylinderLines,
210    /// A plane parallel to a cylinder axis cuts one or two rulings.
211    CylinderPlaneParallelRulings,
212    /// Two coaxial surfaces of revolution meet in circles perpendicular to
213    /// the shared axis, found by intersecting their meridian profiles.
214    CoaxialRevolutionCircles,
215    /// A quadric substituted into a ruled carrier (cylinder, elliptical
216    /// cylinder, or a cone cut by a plane) is quadratic in the ruling
217    /// parameter at every angle; the curve is a root branch of that
218    /// quadratic (ADR 0076).
219    RuledQuadricSection,
220    /// A plane or sphere meets each circle of a torus about its axis where
221    /// `A(v) cos u + B(v) sin u = C(v)`; the curve is `u` as a function of
222    /// the tube angle `v` (ADR 0076).
223    TorusAngleSection,
224    /// A plane through a cone's apex cuts rays along its rulings, starting
225    /// at the apex.
226    ConeApexRulings,
227    /// The zero set of one surface's equation read in the other's
228    /// parameters, traced into certified monotone cells (ADR 0077): a torus
229    /// against a cylinder, cone or torus off its axis.
230    ImplicitTrace,
231    /// Two B-spline surfaces meet along curves found by splitting Bezier
232    /// sub-patch pairs until no closed loop can hide in one, seeded where
233    /// sub-patch edges cross, and followed on both surfaces (ADR 0077).
234    PairTrace,
235}
236
237/// Derive the exact intersection curve of two elementary surfaces.
238///
239/// Returns `Err` with an explicit refusal for every pair this module does
240/// not derive in closed form. Refusal is never a fallback to approximation:
241/// a caller that needs those cases must use the certified numeric analysis
242/// and decide for itself what to do with an unproven region.
243pub fn exact_surface_intersection(
244    first: &Surface,
245    second: &Surface,
246) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
247    // Two B-splines: no equation for either, so the section is carried on
248    // both (ADR 0077).
249    if let (Surface::BSpline(a), Surface::BSpline(b)) = (first, second) {
250        let branches: Vec<Curve3> = crate::pair_trace::spline_pair_intersection(a, b, None)?
251            .into_iter()
252            .map(Curve3::PairSection)
253            .collect();
254        let spans = branches
255            .iter()
256            .map(|c| match c {
257                Curve3::PairSection(s) => Some(Interval::new(0.0, s.end())),
258                _ => None,
259            })
260            .collect();
261        return Ok(ExactIntersectionCurve {
262            branches,
263            derivation: Derivation::PairTrace,
264            spans,
265        });
266    }
267    // A plane through a cone's apex: rulings, or only the apex (final).
268    if let (Surface::Cone(c), Surface::Plane(p)) | (Surface::Plane(p), Surface::Cone(c)) =
269        (first, second)
270    {
271        if let Some(curve) = cone_apex_plane(c, p)? {
272            return Ok(curve);
273        }
274    }
275    match closed_form(first, second) {
276        Ok(curve) => Ok(curve),
277        // Apart, or a frame that cannot be read: final either way.
278        Err(
279            refusal @ (ExactIntersectionRefusal::Disjoint
280            | ExactIntersectionRefusal::DegenerateFrame),
281        ) => Err(refusal),
282        // No conic or line for this pair: a cylinder or cone cut by a
283        // quadric is still exact as a ruled section (ADR 0076).
284        Err(refusal) => {
285            if let Some(curve) = crate::ruled_section::ruled_section(first, second)? {
286                return Ok(curve);
287            }
288            if let Some(curve) = crate::torus_section::torus_section(first, second)? {
289                return Ok(curve);
290            }
291            // No closed form at all: traced as an implicit curve on the
292            // compact surface, with certified topology (ADR 0077).
293            match crate::implicit_section::traced_section(first, second)? {
294                Some(curve) => Ok(curve),
295                None => Err(refusal),
296            }
297        }
298    }
299}
300
301/// The pairs with a line, circle or ellipse in closed form.
302fn closed_form(
303    first: &Surface,
304    second: &Surface,
305) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
306    match (first, second) {
307        (Surface::Plane(a), Surface::Plane(b)) => plane_plane(a, b),
308        (Surface::Cylinder(c), Surface::Plane(p)) => cylinder_plane(c, p),
309        (Surface::Plane(p), Surface::Cylinder(c)) => cylinder_plane(c, p),
310        (Surface::Sphere(s), Surface::Plane(p)) => sphere_plane(s, p),
311        (Surface::Plane(p), Surface::Sphere(s)) => sphere_plane(s, p),
312        // A plane cutting a cone gives a conic whose kind depends on the
313        // tilt: circle and ellipse are representable, parabola and
314        // hyperbola are not. Coaxial (tilt 0) is handled by the shared
315        // revolution path; any other tilt is refused by kind.
316        (Surface::Cone(c), Surface::Plane(p)) | (Surface::Plane(p), Surface::Cone(c)) => {
317            cone_plane(c, p)
318        }
319        (Surface::Sphere(a), Surface::Sphere(b)) => sphere_sphere(a, b),
320        (Surface::Cylinder(a), Surface::Cylinder(b)) => cylinder_cylinder(a, b),
321        // Every remaining elementary pair is covered by the shared
322        // surface-of-revolution identity when the axes coincide.
323        _ => crate::revolution_profile::coaxial_revolution_intersection(first, second),
324    }
325}
326
327/// Two planes meet in a line, unless their normals are parallel.
328///
329/// Identity: the direction is `n1 x n2`. With `di = ni . oi`, the point
330/// `p = (d1 (n2 x dir) + d2 (dir x n1)) / |dir|^2` satisfies both plane
331/// equations and lies nearest the origin, so it is a canonical choice that
332/// depends only on the operands.
333fn plane_plane(
334    first: &Plane,
335    second: &Plane,
336) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
337    let first_normal = first.frame.z;
338    let second_normal = second.frame.z;
339    let direction = first_normal.cross(second_normal);
340    let direction_squared = direction.dot(direction);
341    if direction_squared == 0.0 {
342        // Parallel normals: the planes either coincide or never meet.
343        // Neither outcome is a regular curve.
344        return Err(ExactIntersectionRefusal::Disjoint);
345    }
346    let first_offset = first_normal.dot(first.frame.origin);
347    let second_offset = second_normal.dot(second.frame.origin);
348    let origin = (second_normal.cross(direction) * first_offset
349        + direction.cross(first_normal) * second_offset)
350        / direction_squared;
351    let unit_direction = direction / direction_squared.sqrt();
352    if !origin.is_finite() || !unit_direction.is_finite() {
353        return Err(ExactIntersectionRefusal::DegenerateFrame);
354    }
355    Ok(ExactIntersectionCurve {
356        spans: vec![None],
357        branches: vec![Curve3::Line(axiolid_curve::Line3 {
358            origin,
359            direction: unit_direction,
360        })],
361        derivation: Derivation::PlanePlaneLine,
362    })
363}
364
365/// A plane cuts a sphere in a circle.
366///
367/// Identity: with `d` the signed distance from the centre to the plane, the
368/// section has radius `sqrt(r^2 - d^2)` and is centred at the centre's
369/// projection onto the plane. `|d| >= r` is refused: `|d| > r` misses the
370/// sphere entirely and `|d| == r` touches at one point, which is not a
371/// regular curve.
372fn sphere_plane(
373    sphere: &Sphere,
374    plane: &Plane,
375) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
376    let normal = plane.frame.z;
377    let normal_squared = normal.dot(normal);
378    if normal_squared == 0.0 {
379        return Err(ExactIntersectionRefusal::DegenerateFrame);
380    }
381    let unit_normal = normal / normal_squared.sqrt();
382    let centre = sphere.frame.origin;
383    let signed_distance = unit_normal.dot(centre - plane.frame.origin);
384    // Tangent or not is decided exactly; in `f64` a plane exactly tangent
385    // along a normal like (3, 2, 6) came out as a circle of radius 1e-7.
386    let (numerator, nn) = within_radius(sphere.radius, normal, centre, plane.frame.origin)?;
387    match esign(&numerator) {
388        Sign::Positive => {}
389        // Exactly tangent: a touch is a point, not a curve.
390        Sign::Zero => return Err(ExactIntersectionRefusal::NotRegularCurve),
391        _ => return Err(ExactIntersectionRefusal::Disjoint),
392    }
393    let radius_squared = rounded_square(&numerator, &nn)?;
394    let section_centre = centre - unit_normal * signed_distance;
395    let frame = frame_from_normal(section_centre, unit_normal)?;
396    Ok(ExactIntersectionCurve {
397        spans: vec![None],
398        branches: vec![Curve3::Circle(Circle3 {
399            frame,
400            radius: radius_squared.sqrt(),
401        })],
402        derivation: Derivation::SpherePlaneCircle,
403    })
404}
405
406/// A plane cuts an infinite cylinder in a circle or an ellipse.
407///
408/// Identities, with `theta` the angle between the plane normal and the
409/// cylinder axis:
410/// - `theta == 0` (plane perpendicular to the axis): a circle of radius `r`.
411/// - `0 < theta < pi/2`: an ellipse with minor semi-axis `r` across the
412///   axis and major semi-axis `r / cos(theta)` along the tilt direction.
413/// - `theta == pi/2` (plane parallel to the axis): refused. The section is
414///   then two parallel lines, one line, or empty, none of which is a single
415///   regular curve.
416fn cylinder_plane(
417    cylinder: &Cylinder,
418    plane: &Plane,
419) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
420    let axis_squared = cylinder.frame.z.dot(cylinder.frame.z);
421    let normal_squared = plane.frame.z.dot(plane.frame.z);
422    if axis_squared == 0.0 || normal_squared == 0.0 {
423        return Err(ExactIntersectionRefusal::DegenerateFrame);
424    }
425    let axis = cylinder.frame.z / axis_squared.sqrt();
426    let normal = plane.frame.z / normal_squared.sqrt();
427    // Parallel and perpendicular are decided exactly on the given axes: in
428    // `f64` an axis (-3, -3, -3) against a normal (-3, 1, 2), whose dot
429    // product is exactly zero, gave cos = 2.8e-17 and a 10^16-long ellipse.
430    let bad = ExactIntersectionRefusal::DegenerateFrame;
431    let exact_axis = exact3(cylinder.frame.z).ok_or(bad.clone())?;
432    let exact_normal = exact3(plane.frame.z).ok_or(bad.clone())?;
433    let along = edot(&exact_axis, &exact_normal);
434    if esign(&along) == Sign::Zero {
435        // Plane parallel to the axis: the section is a pair of rulings, one
436        // ruling when the plane is tangent, or empty. Each is an exact line,
437        // so this is derived rather than refused.
438        return cylinder_plane_parallel(cylinder, plane, axis, normal);
439    }
440    let perpendicular = ecross_is_zero(&exact_axis, &exact_normal);
441    // cos(theta) between axis and plane normal, from the exact dot product
442    // rounded once. A tilted plane keeps a cosine below 1 even when its
443    // tilt is below `f64` resolution, so it is still reported as the
444    // ellipse it is.
445    let cosine = if perpendicular {
446        1.0
447    } else {
448        let scale = (edot(&exact_axis, &exact_axis).to_f64()
449            * edot(&exact_normal, &exact_normal).to_f64())
450        .sqrt();
451        (along.to_f64().abs() / scale).min(1.0_f64.next_down())
452    };
453    // Non-zero exactly, but it can round to zero or NaN for extreme inputs.
454    if cosine.is_nan() || cosine <= 0.0 {
455        return Err(bad);
456    }
457    // The section centre is where the cylinder axis pierces the plane.
458    let axis_origin = cylinder.frame.origin;
459    let to_plane = normal.dot(plane.frame.origin - axis_origin);
460    let centre = axis_origin + axis * (to_plane / axis.dot(normal));
461    if !centre.is_finite() {
462        return Err(ExactIntersectionRefusal::DegenerateFrame);
463    }
464    Ok(ExactIntersectionCurve {
465        spans: vec![None],
466        branches: vec![cylinder_section_curve(
467            centre,
468            axis,
469            normal,
470            cylinder.radius,
471            cosine,
472        )?],
473        derivation: if perpendicular {
474            Derivation::CylinderPlanePerpendicularCircle
475        } else {
476            Derivation::CylinderPlaneObliqueEllipse
477        },
478    })
479}
480
481/// Build the circle or ellipse a plane cuts from a cylinder.
482///
483/// The minor axis lies along `axis x normal`, which is perpendicular to the
484/// tilt and so always spans the cylinder at its own radius. The major axis
485/// completes the frame and is stretched by `1 / cos(theta)`.
486fn cylinder_section_curve(
487    centre: Point3,
488    axis: Vec3,
489    normal: Vec3,
490    radius: Scalar,
491    cosine: Scalar,
492) -> Result<Curve3, ExactIntersectionRefusal> {
493    if cosine == 1.0 {
494        let frame = frame_from_normal(centre, normal)?;
495        return Ok(Curve3::Circle(Circle3 { frame, radius }));
496    }
497    let across = axis.cross(normal);
498    let across_squared = across.dot(across);
499    if across_squared == 0.0 {
500        return Err(ExactIntersectionRefusal::DegenerateFrame);
501    }
502    let minor = across / across_squared.sqrt();
503    let major = normal.cross(minor);
504    if !minor.is_finite() || !major.is_finite() {
505        return Err(ExactIntersectionRefusal::DegenerateFrame);
506    }
507    let frame = Frame3 {
508        origin: centre,
509        x: minor,
510        y: major,
511        z: normal,
512    };
513    Ok(Curve3::Ellipse(Ellipse3 {
514        frame,
515        semi_axis_x: radius,
516        semi_axis_y: radius / cosine,
517    }))
518}
519
520/// Build an orthonormal frame whose `z` is the given unit normal.
521///
522/// The in-plane axes are otherwise arbitrary, so they are chosen
523/// deterministically from the normal's own components: pick the coordinate
524/// axis least aligned with the normal as a seed. A deterministic choice
525/// matters because the frame ends up in the returned curve, and an
526/// orientation that varied run to run would make results irreproducible.
527pub(crate) fn frame_from_normal(
528    origin: Point3,
529    normal: Vec3,
530) -> Result<Frame3, ExactIntersectionRefusal> {
531    let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
532        Vec3::new(1.0, 0.0, 0.0)
533    } else if normal.y.abs() <= normal.z.abs() {
534        Vec3::new(0.0, 1.0, 0.0)
535    } else {
536        Vec3::new(0.0, 0.0, 1.0)
537    };
538    let x_axis = normal.cross(seed);
539    let x_squared = x_axis.dot(x_axis);
540    if x_squared == 0.0 {
541        return Err(ExactIntersectionRefusal::DegenerateFrame);
542    }
543    let x = x_axis / x_squared.sqrt();
544    let y = normal.cross(x);
545    if !x.is_finite() || !y.is_finite() {
546        return Err(ExactIntersectionRefusal::DegenerateFrame);
547    }
548    Ok(Frame3 {
549        origin,
550        x,
551        y,
552        z: normal,
553    })
554}
555
556/// Two spheres meet in a circle lying in their radical plane.
557///
558/// Identity: with `d` the centre distance and
559/// `a = (d^2 + r1^2 - r2^2) / (2 d)` the distance from the first centre
560/// along the centre line, the circle has radius `sqrt(r1^2 - a^2)` and is
561/// centred at `c1 + a * u`. Both are built from the operands' own numbers.
562fn sphere_sphere(
563    first: &Sphere,
564    second: &Sphere,
565) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
566    let separation = second.frame.origin - first.frame.origin;
567    let distance = separation.length();
568    if distance == 0.0 {
569        // Concentric: identical spheres coincide, otherwise they never meet.
570        return Err(if first.radius == second.radius {
571            ExactIntersectionRefusal::NotRegularCurve
572        } else {
573            ExactIntersectionRefusal::Disjoint
574        });
575    }
576    if distance > first.radius + second.radius {
577        return Err(ExactIntersectionRefusal::Disjoint);
578    }
579    if distance < (first.radius - second.radius).abs() {
580        // One sphere strictly encloses the other.
581        return Err(ExactIntersectionRefusal::Disjoint);
582    }
583    let axis = separation / distance;
584    let along = (distance * distance + first.radius * first.radius - second.radius * second.radius)
585        / (2.0 * distance);
586    let squared = first.radius * first.radius - along * along;
587    if squared <= 0.0 {
588        // Tangent spheres touch at a single point, which is not a curve.
589        return Err(ExactIntersectionRefusal::NotRegularCurve);
590    }
591    let centre = first.frame.origin + axis * along;
592    let frame = frame_from_normal(centre, axis)?;
593    Ok(ExactIntersectionCurve {
594        spans: vec![None],
595        branches: vec![Curve3::Circle(Circle3 {
596            frame,
597            radius: squared.sqrt(),
598        })],
599        derivation: Derivation::SphereSphereCircle,
600    })
601}
602
603/// Two cylinders, where the pair has a closed-form conic decomposition.
604///
605/// Only two configurations decompose into conics:
606/// - parallel axes: the cross-section is two circles meeting in at most
607///   two points, so the intersection is one or two lines along the axis;
608/// - intersecting axes with EQUAL radii: the Steinmetz case, where the
609///   solid identity `|p-P|^2 - (a.(p-P))^2 = |p-P|^2 - (b.(p-P))^2`
610///   factors into the two planes with normals `a-b` and `a+b`, each
611///   cutting the first cylinder in an ellipse.
612///
613/// Every other configuration -- notably unequal radii on intersecting
614/// axes -- is a genuine space quartic that is NOT planar and therefore
615/// has no exact conic representation. Verified numerically before this
616/// was written: the third singular value of sampled points is 2.83, not
617/// ~0, so no plane contains the curve. Those refuse rather than being
618/// approximated by a fitted spline.
619fn cylinder_cylinder(
620    first: &Cylinder,
621    second: &Cylinder,
622) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
623    let first_axis = first.frame.z;
624    let second_axis = second.frame.z;
625    let cross = first_axis.cross(second_axis);
626    if cross.length() == 0.0 {
627        return parallel_cylinders(first, second, first_axis);
628    }
629    if first.radius != second.radius {
630        return Err(ExactIntersectionRefusal::NotRegularCurve);
631    }
632    // Steinmetz needs the axes to actually meet. Skew axes of equal
633    // radius still give a quartic, so the common point is required, not
634    // assumed: the shortest connecting segment must have zero length.
635    let between = second.frame.origin - first.frame.origin;
636    let unit_cross = cross / cross.length();
637    if between.dot(unit_cross) != 0.0 {
638        return Err(ExactIntersectionRefusal::NotRegularCurve);
639    }
640    // Solve for the crossing point on the first axis.
641    let denominator = first_axis
642        .dot(second_axis)
643        .mul_add(-first_axis.dot(second_axis), 1.0);
644    if denominator == 0.0 {
645        return Err(ExactIntersectionRefusal::NotRegularCurve);
646    }
647    let along = (between.dot(first_axis) - first_axis.dot(second_axis) * between.dot(second_axis))
648        / denominator;
649    let meeting = first.frame.origin + first_axis * along;
650    let mut branches = Vec::new();
651    for normal in [first_axis - second_axis, first_axis + second_axis] {
652        let length = normal.length();
653        if length == 0.0 {
654            continue;
655        }
656        let unit_normal = normal / length;
657        let cosine = unit_normal.dot(first_axis).abs();
658        if cosine == 0.0 {
659            return Err(ExactIntersectionRefusal::NotRegularCurve);
660        }
661        branches.push(cylinder_section_curve(
662            meeting,
663            first_axis,
664            unit_normal,
665            first.radius,
666            cosine,
667        )?);
668    }
669    Ok(ExactIntersectionCurve::whole(
670        branches,
671        Derivation::CylinderCylinderSteinmetzEllipses,
672    ))
673}
674
675/// Cylinders with parallel axes meet in lines parallel to those axes.
676///
677/// Identity: reduce to the plane perpendicular to the shared axis, where
678/// the problem is two circles. With `d` the perpendicular centre offset,
679/// the circles meet where `x = (d^2 + r1^2 - r2^2) / 2d` along the offset
680/// direction and `y = +/- sqrt(r1^2 - x^2)` across it. Each solution
681/// lifts to a full line along the axis.
682fn parallel_cylinders(
683    first: &Cylinder,
684    second: &Cylinder,
685    axis: Vec3,
686) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
687    let between = second.frame.origin - first.frame.origin;
688    let offset = between - axis * between.dot(axis);
689    let distance = offset.length();
690    if distance == 0.0 {
691        return Err(if first.radius == second.radius {
692            ExactIntersectionRefusal::NotRegularCurve
693        } else {
694            ExactIntersectionRefusal::Disjoint
695        });
696    }
697    if distance > first.radius + second.radius || distance < (first.radius - second.radius).abs() {
698        return Err(ExactIntersectionRefusal::Disjoint);
699    }
700    let toward = offset / distance;
701    let along = (distance * distance + first.radius * first.radius - second.radius * second.radius)
702        / (2.0 * distance);
703    let squared = first.radius * first.radius - along * along;
704    let base = first.frame.origin + toward * along;
705    if squared <= 0.0 {
706        // Tangent cylinders share exactly one line.
707        return Ok(ExactIntersectionCurve {
708            spans: vec![None],
709            branches: vec![Curve3::Line(axiolid_curve::Line3 {
710                origin: base,
711                direction: axis,
712            })],
713            derivation: Derivation::ParallelCylinderLines,
714        });
715    }
716    let across = axis.cross(toward);
717    let half = squared.sqrt();
718    let mut branches = Vec::new();
719    for sign in [1.0, -1.0] {
720        branches.push(Curve3::Line(axiolid_curve::Line3 {
721            origin: base + across * (sign * half),
722            direction: axis,
723        }));
724    }
725    Ok(ExactIntersectionCurve::whole(
726        branches,
727        Derivation::ParallelCylinderLines,
728    ))
729}
730
731/// A plane cuts a cone in a conic whose kind follows the tilt.
732///
733/// With `phi` the angle between the plane and the cone axis and `alpha`
734/// the semi-angle, the section is an ellipse while `phi > alpha`, a
735/// parabola at `phi == alpha`, and a hyperbola below. Only the
736/// perpendicular case reduces to a circle, and that is the coaxial case
737/// handled by the shared revolution identity.
738///
739/// `Curve3` has no parabola or hyperbola variant, so those kinds are
740/// refused by name. The ellipse case is genuinely derivable in closed
741/// form but needs the apex-offset construction rather than the
742/// cylinder's parallel-axis one, so it is refused as unsupported until
743/// that derivation is written and tested. Naming the two gaps
744/// differently keeps 'not representable' distinct from 'not implemented'.
745fn cone_plane(
746    cone: &axiolid_surface::Cone,
747    plane: &axiolid_surface::Plane,
748) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
749    let axis = cone.frame.z;
750    let normal = plane.frame.z;
751    let alignment = axis.dot(normal).abs().clamp(0.0, 1.0);
752    // Angle between the plane and the axis, from the axis/normal angle.
753    let plane_axis_angle = alignment.asin();
754    let semi = cone.semi_angle.abs();
755    let perpendicular = match (exact3(axis), exact3(normal)) {
756        (Some(a), Some(n)) => ecross_is_zero(&a, &n),
757        _ => return Err(ExactIntersectionRefusal::DegenerateFrame),
758    };
759    if perpendicular {
760        // Perpendicular plane (decided exactly): a circle, via the coaxial
761        // profile path.
762        return crate::revolution_profile::coaxial_revolution_intersection(
763            &Surface::Cone(*cone),
764            &Surface::Plane(*plane),
765        );
766    }
767    if plane_axis_angle > semi {
768        return Err(ExactIntersectionRefusal::UnsupportedPair);
769    }
770    Err(ExactIntersectionRefusal::UnrepresentableConic)
771}
772
773/// A plane through a cone's apex cuts the modelled nappe in rulings: rays
774/// from the apex, two where the plane is steeper than the cone, one where
775/// it is tangent along a ruling, none (only the apex) where it is flatter.
776///
777/// Identity: with `z` the unit direction the nappe opens along, `alpha`
778/// the semi-angle and `x`, `y` completing the frame, a ruling is
779/// `cos(alpha) z + sin(alpha) (cos(phi) x + sin(phi) y)`; it lies in the
780/// plane where `A cos(phi) + B sin(phi) = C` with `A = sin(alpha) n.x`,
781/// `B = sin(alpha) n.y`, `C = -cos(alpha) n.z`, solved in closed form. The
782/// rays start at the apex, so each branch's span is `[0, +inf)`.
783/// Returns `None` when the plane misses the apex.
784fn cone_apex_plane(
785    cone: &axiolid_surface::Cone,
786    plane: &Plane,
787) -> Result<Option<ExactIntersectionCurve>, ExactIntersectionRefusal> {
788    let slope = cone.semi_angle.tan();
789    if slope == 0.0 || !slope.is_finite() {
790        return Ok(None);
791    }
792    let axis = cone.frame.z.normalize();
793    let apex = cone.frame.origin - axis * (cone.radius / slope);
794    let n = plane.frame.z.normalize();
795    let offset = n.dot(apex - plane.frame.origin);
796    let scale = 1.0 + apex.length() + plane.frame.origin.length();
797    if offset.abs() > 1e-12 * scale {
798        return Ok(None);
799    }
800    // The nappe opens where the radius grows.
801    let z = axis * slope.signum();
802    let x = cone.frame.x.normalize();
803    let y = z.cross(x).normalize();
804    let x = y.cross(z);
805    let alpha = slope.abs().atan();
806    let (sa, ca) = alpha.sin_cos();
807    let (a, b, c) = (sa * n.dot(x), sa * n.dot(y), -ca * n.dot(z));
808    let rr = a * a + b * b;
809    let e = rr - c * c;
810    if e < -1e-14 * (rr + c * c) || rr == 0.0 {
811        // Only the apex: a point, not a curve.
812        return Err(ExactIntersectionRefusal::NotRegularCurve);
813    }
814    let root = e.max(0.0).sqrt();
815    let tangent = e <= 1e-14 * (rr + c * c);
816    let signs: &[Scalar] = if tangent { &[0.0] } else { &[1.0, -1.0] };
817    let mut branches = Vec::new();
818    for s in signs {
819        let (cos_phi, sin_phi) = ((a * c - s * b * root) / rr, (b * c + s * a * root) / rr);
820        let direction = z * ca + (x * cos_phi + y * sin_phi) * sa;
821        if !direction.is_finite() || !apex.is_finite() {
822            return Err(ExactIntersectionRefusal::DegenerateFrame);
823        }
824        branches.push(Curve3::Line(axiolid_curve::Line3 {
825            origin: apex,
826            direction: direction.normalize(),
827        }));
828    }
829    let spans = vec![Some(Interval::new(0.0, Scalar::INFINITY)); branches.len()];
830    Ok(Some(ExactIntersectionCurve {
831        branches,
832        derivation: Derivation::ConeApexRulings,
833        spans,
834    }))
835}
836
837/// A plane parallel to a cylinder axis cuts rulings, not a conic.
838///
839/// Identity: with `d` the distance from the axis to the plane and the
840/// half-chord `h = sqrt(r^2 - d^2)`, the plane meets the cylinder in the
841/// two lines through `foot +/- t*h` along the axis, where `foot` is the
842/// axis point projected onto the plane and `t = axis x normal` is the unit
843/// in-plane direction perpendicular to the axis. A tangent plane gives one
844/// line; a plane clear of the cylinder gives none.
845fn cylinder_plane_parallel(
846    cylinder: &Cylinder,
847    plane: &Plane,
848    axis: Vec3,
849    normal: Vec3,
850) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
851    let distance = normal.dot(cylinder.frame.origin - plane.frame.origin);
852    // Two rulings, one (tangent) or none, decided exactly.
853    let (numerator, nn) = within_radius(
854        cylinder.radius,
855        plane.frame.z,
856        cylinder.frame.origin,
857        plane.frame.origin,
858    )?;
859    let tangent_plane = match esign(&numerator) {
860        Sign::Positive => false,
861        Sign::Zero => true,
862        _ => return Err(ExactIntersectionRefusal::Disjoint),
863    };
864    // `axis` and `normal` are unit and perpendicular here, so their cross
865    // product is already unit: no second normalisation is needed.
866    let tangent = axis.cross(normal);
867    let foot = cylinder.frame.origin - normal * distance;
868    if !foot.is_finite() || !tangent.is_finite() {
869        return Err(ExactIntersectionRefusal::DegenerateFrame);
870    }
871    let half_chord = if tangent_plane {
872        0.0
873    } else {
874        rounded_square(&numerator, &nn)?.sqrt()
875    };
876    let mut branches = Vec::new();
877    let offsets: &[Scalar] = if tangent_plane {
878        &[0.0]
879    } else {
880        &[half_chord, -half_chord]
881    };
882    branches
883        .try_reserve_exact(offsets.len())
884        .map_err(|_| ExactIntersectionRefusal::DegenerateFrame)?;
885    for offset in offsets {
886        branches.push(Curve3::Line(axiolid_curve::Line3 {
887            origin: foot + tangent * *offset,
888            direction: axis,
889        }));
890    }
891    Ok(ExactIntersectionCurve::whole(
892        branches,
893        Derivation::CylinderPlaneParallelRulings,
894    ))
895}