Skip to main content

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, EllipticalCylinder, 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    /// A plane not parallel to an elliptical cylinder's axis cuts the
236    /// conjugate semi-diameters `a (X - Z n.X / n.Z)` and
237    /// `b (Y - Z n.Y / n.Z)` about the point where the axis pierces it: an
238    /// ellipse, or a circle where they are perpendicular and equally long
239    /// (#287).
240    EllipticalCylinderPlaneSection,
241    /// A plane parallel to an elliptical cylinder's axis cuts one or two
242    /// rulings, where its trace on the cross-section meets the ellipse
243    /// (#287).
244    EllipticalCylinderPlaneRulings,
245    /// Two cylinders, circular or elliptical, with parallel axes meet in
246    /// the rulings over the angles where their cross-sections meet: the
247    /// real roots of the other's equation along the carrier's ellipse, a
248    /// quartic in the half-angle, isolated exactly (#287).
249    ParallelEllipticalCylinderRulings,
250    /// A plane containing a torus's axis cuts the two meridian circles of
251    /// the tube at the angles where the plane meets the ring (#382).
252    TorusMeridianCircles,
253}
254
255/// Derive the exact intersection curve of two elementary surfaces.
256///
257/// Returns `Err` with an explicit refusal for every pair this module does
258/// not derive in closed form. Refusal is never a fallback to approximation:
259/// a caller that needs those cases must use the certified numeric analysis
260/// and decide for itself what to do with an unproven region.
261pub fn exact_surface_intersection(
262    first: &Surface,
263    second: &Surface,
264) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
265    // Two B-splines: no equation for either, so the section is carried on
266    // both (ADR 0077).
267    if let (Surface::BSpline(a), Surface::BSpline(b)) = (first, second) {
268        let branches: Vec<Curve3> = crate::pair_trace::spline_pair_intersection(a, b, None)?
269            .into_iter()
270            .map(Curve3::PairSection)
271            .collect();
272        let spans = branches
273            .iter()
274            .map(|c| match c {
275                Curve3::PairSection(s) => Some(Interval::new(0.0, s.end())),
276                _ => None,
277            })
278            .collect();
279        return Ok(ExactIntersectionCurve {
280            branches,
281            derivation: Derivation::PairTrace,
282            spans,
283        });
284    }
285    // A plane through a cone's apex: rulings, or only the apex (final).
286    if let (Surface::Cone(c), Surface::Plane(p)) | (Surface::Plane(p), Surface::Cone(c)) =
287        (first, second)
288    {
289        if let Some(curve) = cone_apex_plane(c, p)? {
290            return Ok(curve);
291        }
292    }
293    match closed_form(first, second) {
294        Ok(curve) => Ok(curve),
295        // Apart, or a frame that cannot be read: final either way.
296        Err(
297            refusal @ (ExactIntersectionRefusal::Disjoint
298            | ExactIntersectionRefusal::DegenerateFrame),
299        ) => Err(refusal),
300        // No conic or line for this pair: a cylinder or cone cut by a
301        // quadric is still exact as a ruled section (ADR 0076).
302        Err(refusal) => {
303            if let Some(curve) = crate::ruled_section::ruled_section(first, second)? {
304                return Ok(curve);
305            }
306            if let Some(curve) = crate::torus_section::torus_section(first, second)? {
307                return Ok(curve);
308            }
309            // No closed form at all: traced as an implicit curve on the
310            // compact surface, with certified topology (ADR 0077).
311            match crate::implicit_section::traced_section(first, second)? {
312                Some(curve) => Ok(curve),
313                None => Err(refusal),
314            }
315        }
316    }
317}
318
319/// The pairs with a line, circle or ellipse in closed form.
320fn closed_form(
321    first: &Surface,
322    second: &Surface,
323) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
324    match (first, second) {
325        (Surface::Plane(a), Surface::Plane(b)) => plane_plane(a, b),
326        (Surface::Cylinder(c), Surface::Plane(p)) => cylinder_plane(c, p),
327        (Surface::Plane(p), Surface::Cylinder(c)) => cylinder_plane(c, p),
328        (Surface::Sphere(s), Surface::Plane(p)) => sphere_plane(s, p),
329        (Surface::Plane(p), Surface::Sphere(s)) => sphere_plane(s, p),
330        // A plane cutting a cone gives a conic whose kind depends on the
331        // tilt: circle and ellipse are representable, parabola and
332        // hyperbola are not. Coaxial (tilt 0) is handled by the shared
333        // revolution path; any other tilt is refused by kind.
334        (Surface::Cone(c), Surface::Plane(p)) | (Surface::Plane(p), Surface::Cone(c)) => {
335            cone_plane(c, p)
336        }
337        (Surface::EllipticalCylinder(c), Surface::Plane(p))
338        | (Surface::Plane(p), Surface::EllipticalCylinder(c)) => elliptical_cylinder_plane(c, p),
339        (Surface::Sphere(a), Surface::Sphere(b)) => sphere_sphere(a, b),
340        (Surface::Cylinder(a), Surface::Cylinder(b)) => cylinder_cylinder(a, b),
341        // Every remaining elementary pair is covered by the shared
342        // surface-of-revolution identity when the axes coincide.
343        _ => crate::revolution_profile::coaxial_revolution_intersection(first, second),
344    }
345}
346
347/// Two planes meet in a line, unless their normals are parallel.
348///
349/// Identity: the direction is `n1 x n2`. With `di = ni . oi`, the point
350/// `p = (d1 (n2 x dir) + d2 (dir x n1)) / |dir|^2` satisfies both plane
351/// equations and lies nearest the origin, so it is a canonical choice that
352/// depends only on the operands.
353fn plane_plane(
354    first: &Plane,
355    second: &Plane,
356) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
357    let first_normal = first.frame.z;
358    let second_normal = second.frame.z;
359    let direction = first_normal.cross(second_normal);
360    let direction_squared = direction.dot(direction);
361    if direction_squared == 0.0 {
362        // Parallel normals: the planes either coincide or never meet.
363        // Neither outcome is a regular curve.
364        return Err(ExactIntersectionRefusal::Disjoint);
365    }
366    let first_offset = first_normal.dot(first.frame.origin);
367    let second_offset = second_normal.dot(second.frame.origin);
368    let origin = (second_normal.cross(direction) * first_offset
369        + direction.cross(first_normal) * second_offset)
370        / direction_squared;
371    let unit_direction = direction / direction_squared.sqrt();
372    if !origin.is_finite() || !unit_direction.is_finite() {
373        return Err(ExactIntersectionRefusal::DegenerateFrame);
374    }
375    Ok(ExactIntersectionCurve {
376        spans: vec![None],
377        branches: vec![Curve3::Line(axiolid_curve::Line3 {
378            origin,
379            direction: unit_direction,
380        })],
381        derivation: Derivation::PlanePlaneLine,
382    })
383}
384
385/// A plane cuts a sphere in a circle.
386///
387/// Identity: with `d` the signed distance from the centre to the plane, the
388/// section has radius `sqrt(r^2 - d^2)` and is centred at the centre's
389/// projection onto the plane. `|d| >= r` is refused: `|d| > r` misses the
390/// sphere entirely and `|d| == r` touches at one point, which is not a
391/// regular curve.
392fn sphere_plane(
393    sphere: &Sphere,
394    plane: &Plane,
395) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
396    let normal = plane.frame.z;
397    let normal_squared = normal.dot(normal);
398    if normal_squared == 0.0 {
399        return Err(ExactIntersectionRefusal::DegenerateFrame);
400    }
401    let unit_normal = normal / normal_squared.sqrt();
402    let centre = sphere.frame.origin;
403    let signed_distance = unit_normal.dot(centre - plane.frame.origin);
404    // Tangent or not is decided exactly; in `f64` a plane exactly tangent
405    // along a normal like (3, 2, 6) came out as a circle of radius 1e-7.
406    let (numerator, nn) = within_radius(sphere.radius, normal, centre, plane.frame.origin)?;
407    match esign(&numerator) {
408        Sign::Positive => {}
409        // Exactly tangent: a touch is a point, not a curve.
410        Sign::Zero => return Err(ExactIntersectionRefusal::NotRegularCurve),
411        _ => return Err(ExactIntersectionRefusal::Disjoint),
412    }
413    let radius_squared = rounded_square(&numerator, &nn)?;
414    let section_centre = centre - unit_normal * signed_distance;
415    let frame = frame_from_normal(section_centre, unit_normal)?;
416    Ok(ExactIntersectionCurve {
417        spans: vec![None],
418        branches: vec![Curve3::Circle(Circle3 {
419            frame,
420            radius: radius_squared.sqrt(),
421        })],
422        derivation: Derivation::SpherePlaneCircle,
423    })
424}
425
426/// A plane cuts an infinite cylinder in a circle or an ellipse.
427///
428/// Identities, with `theta` the angle between the plane normal and the
429/// cylinder axis:
430/// - `theta == 0` (plane perpendicular to the axis): a circle of radius `r`.
431/// - `0 < theta < pi/2`: an ellipse with minor semi-axis `r` across the
432///   axis and major semi-axis `r / cos(theta)` along the tilt direction.
433/// - `theta == pi/2` (plane parallel to the axis): refused. The section is
434///   then two parallel lines, one line, or empty, none of which is a single
435///   regular curve.
436fn cylinder_plane(
437    cylinder: &Cylinder,
438    plane: &Plane,
439) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
440    let axis_squared = cylinder.frame.z.dot(cylinder.frame.z);
441    let normal_squared = plane.frame.z.dot(plane.frame.z);
442    if axis_squared == 0.0 || normal_squared == 0.0 {
443        return Err(ExactIntersectionRefusal::DegenerateFrame);
444    }
445    let axis = cylinder.frame.z / axis_squared.sqrt();
446    let normal = plane.frame.z / normal_squared.sqrt();
447    // Parallel and perpendicular are decided exactly on the given axes: in
448    // `f64` an axis (-3, -3, -3) against a normal (-3, 1, 2), whose dot
449    // product is exactly zero, gave cos = 2.8e-17 and a 10^16-long ellipse.
450    let bad = ExactIntersectionRefusal::DegenerateFrame;
451    let exact_axis = exact3(cylinder.frame.z).ok_or(bad.clone())?;
452    let exact_normal = exact3(plane.frame.z).ok_or(bad.clone())?;
453    let along = edot(&exact_axis, &exact_normal);
454    if esign(&along) == Sign::Zero {
455        // Plane parallel to the axis: the section is a pair of rulings, one
456        // ruling when the plane is tangent, or empty. Each is an exact line,
457        // so this is derived rather than refused.
458        return cylinder_plane_parallel(cylinder, plane, axis, normal);
459    }
460    let perpendicular = ecross_is_zero(&exact_axis, &exact_normal);
461    // cos(theta) between axis and plane normal, from the exact dot product
462    // rounded once. A tilted plane keeps a cosine below 1 even when its
463    // tilt is below `f64` resolution, so it is still reported as the
464    // ellipse it is.
465    let cosine = if perpendicular {
466        1.0
467    } else {
468        let scale = (edot(&exact_axis, &exact_axis).to_f64()
469            * edot(&exact_normal, &exact_normal).to_f64())
470        .sqrt();
471        (along.to_f64().abs() / scale).min(1.0_f64.next_down())
472    };
473    // Non-zero exactly, but it can round to zero or NaN for extreme inputs.
474    if cosine.is_nan() || cosine <= 0.0 {
475        return Err(bad);
476    }
477    // The section centre is where the cylinder axis pierces the plane.
478    let axis_origin = cylinder.frame.origin;
479    let to_plane = normal.dot(plane.frame.origin - axis_origin);
480    let centre = axis_origin + axis * (to_plane / axis.dot(normal));
481    if !centre.is_finite() {
482        return Err(ExactIntersectionRefusal::DegenerateFrame);
483    }
484    Ok(ExactIntersectionCurve {
485        spans: vec![None],
486        branches: vec![cylinder_section_curve(
487            centre,
488            axis,
489            normal,
490            cylinder.radius,
491            cosine,
492        )?],
493        derivation: if perpendicular {
494            Derivation::CylinderPlanePerpendicularCircle
495        } else {
496            Derivation::CylinderPlaneObliqueEllipse
497        },
498    })
499}
500
501/// Build the circle or ellipse a plane cuts from a cylinder.
502///
503/// The minor axis lies along `axis x normal`, which is perpendicular to the
504/// tilt and so always spans the cylinder at its own radius. The major axis
505/// completes the frame and is stretched by `1 / cos(theta)`.
506fn cylinder_section_curve(
507    centre: Point3,
508    axis: Vec3,
509    normal: Vec3,
510    radius: Scalar,
511    cosine: Scalar,
512) -> Result<Curve3, ExactIntersectionRefusal> {
513    if cosine == 1.0 {
514        let frame = frame_from_normal(centre, normal)?;
515        return Ok(Curve3::Circle(Circle3 { frame, radius }));
516    }
517    let across = axis.cross(normal);
518    let across_squared = across.dot(across);
519    if across_squared == 0.0 {
520        return Err(ExactIntersectionRefusal::DegenerateFrame);
521    }
522    let minor = across / across_squared.sqrt();
523    let major = normal.cross(minor);
524    if !minor.is_finite() || !major.is_finite() {
525        return Err(ExactIntersectionRefusal::DegenerateFrame);
526    }
527    let frame = Frame3 {
528        origin: centre,
529        x: minor,
530        y: major,
531        z: normal,
532    };
533    Ok(Curve3::Ellipse(Ellipse3 {
534        frame,
535        semi_axis_x: radius,
536        semi_axis_y: radius / cosine,
537    }))
538}
539
540/// Build an orthonormal frame whose `z` is the given unit normal.
541///
542/// The in-plane axes are otherwise arbitrary, so they are chosen
543/// deterministically from the normal's own components: pick the coordinate
544/// axis least aligned with the normal as a seed. A deterministic choice
545/// matters because the frame ends up in the returned curve, and an
546/// orientation that varied run to run would make results irreproducible.
547pub(crate) fn frame_from_normal(
548    origin: Point3,
549    normal: Vec3,
550) -> Result<Frame3, ExactIntersectionRefusal> {
551    let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
552        Vec3::new(1.0, 0.0, 0.0)
553    } else if normal.y.abs() <= normal.z.abs() {
554        Vec3::new(0.0, 1.0, 0.0)
555    } else {
556        Vec3::new(0.0, 0.0, 1.0)
557    };
558    let x_axis = normal.cross(seed);
559    let x_squared = x_axis.dot(x_axis);
560    if x_squared == 0.0 {
561        return Err(ExactIntersectionRefusal::DegenerateFrame);
562    }
563    let x = x_axis / x_squared.sqrt();
564    let y = normal.cross(x);
565    if !x.is_finite() || !y.is_finite() {
566        return Err(ExactIntersectionRefusal::DegenerateFrame);
567    }
568    Ok(Frame3 {
569        origin,
570        x,
571        y,
572        z: normal,
573    })
574}
575
576/// Two spheres meet in a circle lying in their radical plane.
577///
578/// Identity: with `d` the centre distance and
579/// `a = (d^2 + r1^2 - r2^2) / (2 d)` the distance from the first centre
580/// along the centre line, the circle has radius `sqrt(r1^2 - a^2)` and is
581/// centred at `c1 + a * u`. Both are built from the operands' own numbers.
582fn sphere_sphere(
583    first: &Sphere,
584    second: &Sphere,
585) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
586    let separation = second.frame.origin - first.frame.origin;
587    let distance = separation.length();
588    if distance == 0.0 {
589        // Concentric: identical spheres coincide, otherwise they never meet.
590        return Err(if first.radius == second.radius {
591            ExactIntersectionRefusal::NotRegularCurve
592        } else {
593            ExactIntersectionRefusal::Disjoint
594        });
595    }
596    if distance > first.radius + second.radius {
597        return Err(ExactIntersectionRefusal::Disjoint);
598    }
599    if distance < (first.radius - second.radius).abs() {
600        // One sphere strictly encloses the other.
601        return Err(ExactIntersectionRefusal::Disjoint);
602    }
603    let axis = separation / distance;
604    let along = (distance * distance + first.radius * first.radius - second.radius * second.radius)
605        / (2.0 * distance);
606    let squared = first.radius * first.radius - along * along;
607    if squared <= 0.0 {
608        // Tangent spheres touch at a single point, which is not a curve.
609        return Err(ExactIntersectionRefusal::NotRegularCurve);
610    }
611    let centre = first.frame.origin + axis * along;
612    let frame = frame_from_normal(centre, axis)?;
613    Ok(ExactIntersectionCurve {
614        spans: vec![None],
615        branches: vec![Curve3::Circle(Circle3 {
616            frame,
617            radius: squared.sqrt(),
618        })],
619        derivation: Derivation::SphereSphereCircle,
620    })
621}
622
623/// Two cylinders, where the pair has a closed-form conic decomposition.
624///
625/// Only two configurations decompose into conics:
626/// - parallel axes: the cross-section is two circles meeting in at most
627///   two points, so the intersection is one or two lines along the axis;
628/// - intersecting axes with EQUAL radii: the Steinmetz case, where the
629///   solid identity `|p-P|^2 - (a.(p-P))^2 = |p-P|^2 - (b.(p-P))^2`
630///   factors into the two planes with normals `a-b` and `a+b`, each
631///   cutting the first cylinder in an ellipse.
632///
633/// Every other configuration -- notably unequal radii on intersecting
634/// axes -- is a genuine space quartic that is NOT planar and therefore
635/// has no exact conic representation. Verified numerically before this
636/// was written: the third singular value of sampled points is 2.83, not
637/// ~0, so no plane contains the curve. Those refuse rather than being
638/// approximated by a fitted spline.
639fn cylinder_cylinder(
640    first: &Cylinder,
641    second: &Cylinder,
642) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
643    let first_axis = first.frame.z;
644    let second_axis = second.frame.z;
645    let cross = first_axis.cross(second_axis);
646    if cross.length() == 0.0 {
647        return parallel_cylinders(first, second, first_axis);
648    }
649    if first.radius != second.radius {
650        return Err(ExactIntersectionRefusal::NotRegularCurve);
651    }
652    // Steinmetz needs the axes to actually meet. Skew axes of equal
653    // radius still give a quartic, so the common point is required, not
654    // assumed: the shortest connecting segment must have zero length.
655    let between = second.frame.origin - first.frame.origin;
656    let unit_cross = cross / cross.length();
657    if between.dot(unit_cross) != 0.0 {
658        return Err(ExactIntersectionRefusal::NotRegularCurve);
659    }
660    // Solve for the crossing point on the first axis.
661    let denominator = first_axis
662        .dot(second_axis)
663        .mul_add(-first_axis.dot(second_axis), 1.0);
664    if denominator == 0.0 {
665        return Err(ExactIntersectionRefusal::NotRegularCurve);
666    }
667    let along = (between.dot(first_axis) - first_axis.dot(second_axis) * between.dot(second_axis))
668        / denominator;
669    let meeting = first.frame.origin + first_axis * along;
670    let mut branches = Vec::new();
671    for normal in [first_axis - second_axis, first_axis + second_axis] {
672        let length = normal.length();
673        if length == 0.0 {
674            continue;
675        }
676        let unit_normal = normal / length;
677        let cosine = unit_normal.dot(first_axis).abs();
678        if cosine == 0.0 {
679            return Err(ExactIntersectionRefusal::NotRegularCurve);
680        }
681        branches.push(cylinder_section_curve(
682            meeting,
683            first_axis,
684            unit_normal,
685            first.radius,
686            cosine,
687        )?);
688    }
689    Ok(ExactIntersectionCurve::whole(
690        branches,
691        Derivation::CylinderCylinderSteinmetzEllipses,
692    ))
693}
694
695/// Cylinders with parallel axes meet in lines parallel to those axes.
696///
697/// Identity: reduce to the plane perpendicular to the shared axis, where
698/// the problem is two circles. With `d` the perpendicular centre offset,
699/// the circles meet where `x = (d^2 + r1^2 - r2^2) / 2d` along the offset
700/// direction and `y = +/- sqrt(r1^2 - x^2)` across it. Each solution
701/// lifts to a full line along the axis.
702fn parallel_cylinders(
703    first: &Cylinder,
704    second: &Cylinder,
705    axis: Vec3,
706) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
707    let between = second.frame.origin - first.frame.origin;
708    let offset = between - axis * between.dot(axis);
709    let distance = offset.length();
710    if distance == 0.0 {
711        return Err(if first.radius == second.radius {
712            ExactIntersectionRefusal::NotRegularCurve
713        } else {
714            ExactIntersectionRefusal::Disjoint
715        });
716    }
717    if distance > first.radius + second.radius || distance < (first.radius - second.radius).abs() {
718        return Err(ExactIntersectionRefusal::Disjoint);
719    }
720    let toward = offset / distance;
721    let along = (distance * distance + first.radius * first.radius - second.radius * second.radius)
722        / (2.0 * distance);
723    let squared = first.radius * first.radius - along * along;
724    let base = first.frame.origin + toward * along;
725    if squared <= 0.0 {
726        // Tangent cylinders share exactly one line.
727        return Ok(ExactIntersectionCurve {
728            spans: vec![None],
729            branches: vec![Curve3::Line(axiolid_curve::Line3 {
730                origin: base,
731                direction: axis,
732            })],
733            derivation: Derivation::ParallelCylinderLines,
734        });
735    }
736    let across = axis.cross(toward);
737    let half = squared.sqrt();
738    let mut branches = Vec::new();
739    for sign in [1.0, -1.0] {
740        branches.push(Curve3::Line(axiolid_curve::Line3 {
741            origin: base + across * (sign * half),
742            direction: axis,
743        }));
744    }
745    Ok(ExactIntersectionCurve::whole(
746        branches,
747        Derivation::ParallelCylinderLines,
748    ))
749}
750
751/// A plane cuts a cone in a conic whose kind follows the tilt.
752///
753/// With `phi` the angle between the plane and the cone axis and `alpha`
754/// the semi-angle, the section is an ellipse while `phi > alpha`, a
755/// parabola at `phi == alpha`, and a hyperbola below. Only the
756/// perpendicular case reduces to a circle, and that is the coaxial case
757/// handled by the shared revolution identity.
758///
759/// `Curve3` has no parabola or hyperbola variant, so those kinds are
760/// refused by name. The ellipse case is genuinely derivable in closed
761/// form but needs the apex-offset construction rather than the
762/// cylinder's parallel-axis one, so it is refused as unsupported until
763/// that derivation is written and tested. Naming the two gaps
764/// differently keeps 'not representable' distinct from 'not implemented'.
765fn cone_plane(
766    cone: &axiolid_surface::Cone,
767    plane: &axiolid_surface::Plane,
768) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
769    let axis = cone.frame.z;
770    let normal = plane.frame.z;
771    let alignment = axis.dot(normal).abs().clamp(0.0, 1.0);
772    // Angle between the plane and the axis, from the axis/normal angle.
773    let plane_axis_angle = alignment.asin();
774    let semi = cone.semi_angle.abs();
775    let perpendicular = match (exact3(axis), exact3(normal)) {
776        (Some(a), Some(n)) => ecross_is_zero(&a, &n),
777        _ => return Err(ExactIntersectionRefusal::DegenerateFrame),
778    };
779    if perpendicular {
780        // Perpendicular plane (decided exactly): a circle, via the coaxial
781        // profile path.
782        return crate::revolution_profile::coaxial_revolution_intersection(
783            &Surface::Cone(*cone),
784            &Surface::Plane(*plane),
785        );
786    }
787    if plane_axis_angle > semi {
788        return Err(ExactIntersectionRefusal::UnsupportedPair);
789    }
790    Err(ExactIntersectionRefusal::UnrepresentableConic)
791}
792
793/// A plane through a cone's apex cuts the modelled nappe in rulings: rays
794/// from the apex, two where the plane is steeper than the cone, one where
795/// it is tangent along a ruling, none (only the apex) where it is flatter.
796///
797/// Identity: with `z` the unit direction the nappe opens along, `alpha`
798/// the semi-angle and `x`, `y` completing the frame, a ruling is
799/// `cos(alpha) z + sin(alpha) (cos(phi) x + sin(phi) y)`; it lies in the
800/// plane where `A cos(phi) + B sin(phi) = C` with `A = sin(alpha) n.x`,
801/// `B = sin(alpha) n.y`, `C = -cos(alpha) n.z`, solved in closed form. The
802/// rays start at the apex, so each branch's span is `[0, +inf)`.
803/// Returns `None` when the plane misses the apex.
804fn cone_apex_plane(
805    cone: &axiolid_surface::Cone,
806    plane: &Plane,
807) -> Result<Option<ExactIntersectionCurve>, ExactIntersectionRefusal> {
808    let slope = cone.semi_angle.tan();
809    if slope == 0.0 || !slope.is_finite() {
810        return Ok(None);
811    }
812    let axis = cone.frame.z.normalize();
813    let apex = cone.frame.origin - axis * (cone.radius / slope);
814    let n = plane.frame.z.normalize();
815    let offset = n.dot(apex - plane.frame.origin);
816    let scale = 1.0 + apex.length() + plane.frame.origin.length();
817    if offset.abs() > 1e-12 * scale {
818        return Ok(None);
819    }
820    // The nappe opens where the radius grows.
821    let z = axis * slope.signum();
822    let x = cone.frame.x.normalize();
823    let y = z.cross(x).normalize();
824    let x = y.cross(z);
825    let alpha = slope.abs().atan();
826    let (sa, ca) = alpha.sin_cos();
827    let (a, b, c) = (sa * n.dot(x), sa * n.dot(y), -ca * n.dot(z));
828    let rr = a * a + b * b;
829    let e = rr - c * c;
830    if e < -1e-14 * (rr + c * c) || rr == 0.0 {
831        // Only the apex: a point, not a curve.
832        return Err(ExactIntersectionRefusal::NotRegularCurve);
833    }
834    let root = e.max(0.0).sqrt();
835    let tangent = e <= 1e-14 * (rr + c * c);
836    let signs: &[Scalar] = if tangent { &[0.0] } else { &[1.0, -1.0] };
837    let mut branches = Vec::new();
838    for s in signs {
839        let (cos_phi, sin_phi) = ((a * c - s * b * root) / rr, (b * c + s * a * root) / rr);
840        let direction = z * ca + (x * cos_phi + y * sin_phi) * sa;
841        if !direction.is_finite() || !apex.is_finite() {
842            return Err(ExactIntersectionRefusal::DegenerateFrame);
843        }
844        branches.push(Curve3::Line(axiolid_curve::Line3 {
845            origin: apex,
846            direction: direction.normalize(),
847        }));
848    }
849    let spans = vec![Some(Interval::new(0.0, Scalar::INFINITY)); branches.len()];
850    Ok(Some(ExactIntersectionCurve {
851        branches,
852        derivation: Derivation::ConeApexRulings,
853        spans,
854    }))
855}
856
857/// A plane parallel to a cylinder axis cuts rulings, not a conic.
858///
859/// Identity: with `d` the distance from the axis to the plane and the
860/// half-chord `h = sqrt(r^2 - d^2)`, the plane meets the cylinder in the
861/// two lines through `foot +/- t*h` along the axis, where `foot` is the
862/// axis point projected onto the plane and `t = axis x normal` is the unit
863/// in-plane direction perpendicular to the axis. A tangent plane gives one
864/// line; a plane clear of the cylinder gives none.
865fn cylinder_plane_parallel(
866    cylinder: &Cylinder,
867    plane: &Plane,
868    axis: Vec3,
869    normal: Vec3,
870) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
871    let distance = normal.dot(cylinder.frame.origin - plane.frame.origin);
872    // Two rulings, one (tangent) or none, decided exactly.
873    let (numerator, nn) = within_radius(
874        cylinder.radius,
875        plane.frame.z,
876        cylinder.frame.origin,
877        plane.frame.origin,
878    )?;
879    let tangent_plane = match esign(&numerator) {
880        Sign::Positive => false,
881        Sign::Zero => true,
882        _ => return Err(ExactIntersectionRefusal::Disjoint),
883    };
884    // `axis` and `normal` are unit and perpendicular here, so their cross
885    // product is already unit: no second normalisation is needed.
886    let tangent = axis.cross(normal);
887    let foot = cylinder.frame.origin - normal * distance;
888    if !foot.is_finite() || !tangent.is_finite() {
889        return Err(ExactIntersectionRefusal::DegenerateFrame);
890    }
891    let half_chord = if tangent_plane {
892        0.0
893    } else {
894        rounded_square(&numerator, &nn)?.sqrt()
895    };
896    let mut branches = Vec::new();
897    let offsets: &[Scalar] = if tangent_plane {
898        &[0.0]
899    } else {
900        &[half_chord, -half_chord]
901    };
902    branches
903        .try_reserve_exact(offsets.len())
904        .map_err(|_| ExactIntersectionRefusal::DegenerateFrame)?;
905    for offset in offsets {
906        branches.push(Curve3::Line(axiolid_curve::Line3 {
907            origin: foot + tangent * *offset,
908            direction: axis,
909        }));
910    }
911    Ok(ExactIntersectionCurve::whole(
912        branches,
913        Derivation::CylinderPlaneParallelRulings,
914    ))
915}
916
917/// A plane cuts an elliptical cylinder in an ellipse, a circle, rulings or
918/// nothing (#287).
919///
920/// Identity: the surface is `P(u, v) = O + a cos(u) X + b sin(u) Y + v Z`
921/// in its frame, the plane `n . (p - Q) = 0`. With `n_X = n . X`,
922/// `n_Y = n . Y`, `n_Z = n . Z` and `k = n . (O - Q)`, the section is
923/// `a n_X cos(u) + b n_Y sin(u) + n_Z v + k = 0`.
924///
925/// - `n_Z != 0` (decided exactly): `v` is a function of `u`, and the
926///   section is `C + cos(u) A + sin(u) B` with `C = O - Z k / n_Z`,
927///   `A = a (X - Z n_X / n_Z)`, `B = b (Y - Z n_Y / n_Z)`: an ellipse with
928///   conjugate semi-diameters `A`, `B`, turned to its principal axes. Its
929///   parameter is not the surface's `u`; a pcurve reads it by the plane.
930///   With `A . B = 0` and `|A| = |B|` exactly (in the frame's axes as
931///   given, read as orthonormal), it is a circle; a plane `z = c` across
932///   a wall swept obliquely from a circle in `z = 0` cuts the circle back
933///   only up to the rounding of the sweep direction, and is then the
934///   ellipse its numbers define.
935/// - `n_Z = 0`: the plane is parallel to the axis, and its trace on the
936///   cross-section is the line `a n_X s + b n_Y t = -k` in
937///   `(s, t) = (cos u, sin u)`, which meets the unit circle where
938///   `R^2 = a^2 n_X^2 + b^2 n_Y^2` exceeds `k^2` (two rulings), equals it
939///   (one, touching) or falls short (none). The comparison is exact.
940fn elliptical_cylinder_plane(
941    cylinder: &EllipticalCylinder,
942    plane: &Plane,
943) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
944    let bad = ExactIntersectionRefusal::DegenerateFrame;
945    let (a, b) = (cylinder.semi_axis_x, cylinder.semi_axis_y);
946    if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) {
947        return Err(bad);
948    }
949    let frame = cylinder.frame;
950    let ex = exact3(frame.x).ok_or(bad.clone())?;
951    let ey = exact3(frame.y).ok_or(bad.clone())?;
952    let ez = exact3(frame.z).ok_or(bad.clone())?;
953    let en = exact3(plane.frame.z).ok_or(bad.clone())?;
954    let eo = exact3(frame.origin).ok_or(bad.clone())?;
955    let eq = exact3(plane.frame.origin).ok_or(bad.clone())?;
956    let (ea, eb) = (
957        Dyadic::try_from_f64(a).ok_or(bad.clone())?,
958        Dyadic::try_from_f64(b).ok_or(bad.clone())?,
959    );
960    let (nx, ny, nz) = (edot(&en, &ex), edot(&en, &ey), edot(&en, &ez));
961    let k = edot(&en, &esub(&eo, &eq));
962    if esign(&nz) == Sign::Zero {
963        return elliptical_cylinder_plane_rulings(cylinder, &ea.mul(&nx), &eb.mul(&ny), &k);
964    }
965    // Exactly a circle: `A . B = 0` and `|A|^2 = |B|^2`, each times `n_Z^2`.
966    let nz2 = nz.square();
967    let along = ea.mul(&eb).mul(&nx).mul(&ny);
968    let across_a = ea.square().mul(&nz2.add(&nx.square()));
969    let across_b = eb.square().mul(&nz2.add(&ny.square()));
970    let circle = esign(&along) == Sign::Zero && esign(&across_a.sub(&across_b)) == Sign::Zero;
971    let (fx, fy, fz) = (nx.to_f64(), ny.to_f64(), nz.to_f64());
972    let z = frame.z;
973    let centre = frame.origin - z * (k.to_f64() / fz);
974    let first = (frame.x - z * (fx / fz)) * a;
975    let second = (frame.y - z * (fy / fz)) * b;
976    let normal = {
977        let n = plane.frame.z;
978        let length = n.length();
979        if length == 0.0 || !length.is_finite() {
980            return Err(bad);
981        }
982        n / length
983    };
984    if !centre.is_finite() || !first.is_finite() || !second.is_finite() {
985        return Err(bad);
986    }
987    let curve = if circle {
988        let radius = first.length();
989        let x = first / radius;
990        let y = normal.cross(x);
991        if !(radius > 0.0 && x.is_finite() && y.is_finite()) {
992            return Err(bad);
993        }
994        Curve3::Circle(Circle3 {
995            frame: Frame3 {
996                origin: centre,
997                x,
998                y,
999                z: normal,
1000            },
1001            radius,
1002        })
1003    } else {
1004        // Principal axes of the conjugate pair: `t0` with
1005        // `tan(2 t0) = 2 A.B / (A.A - B.B)`; `A.B = 0` exactly keeps them.
1006        let t0 = if esign(&along) == Sign::Zero {
1007            0.0
1008        } else {
1009            0.5 * (2.0 * first.dot(second)).atan2(first.dot(first) - second.dot(second))
1010        };
1011        let (s0, c0) = t0.sin_cos();
1012        let major = first * c0 + second * s0;
1013        let minor = second * c0 - first * s0;
1014        let (semi_x, semi_y) = (major.length(), minor.length());
1015        if !(semi_x > 0.0 && semi_y > 0.0 && semi_x.is_finite() && semi_y.is_finite()) {
1016            return Err(bad);
1017        }
1018        let x = major / semi_x;
1019        // The plane's own normal orients the frame; the second axis is
1020        // the principal direction perpendicular to the first within it.
1021        let y = normal.cross(x);
1022        if !x.is_finite() || !y.is_finite() {
1023            return Err(bad);
1024        }
1025        Curve3::Ellipse(Ellipse3 {
1026            frame: Frame3 {
1027                origin: centre,
1028                x,
1029                y,
1030                z: normal,
1031            },
1032            semi_axis_x: semi_x,
1033            semi_axis_y: semi_y,
1034        })
1035    };
1036    Ok(ExactIntersectionCurve::whole(
1037        vec![curve],
1038        Derivation::EllipticalCylinderPlaneSection,
1039    ))
1040}
1041
1042/// The rulings a plane parallel to an elliptical cylinder's axis cuts:
1043/// where the line `ma s + mb t = -k` meets the unit circle in
1044/// `(s, t) = (cos u, sin u)`, lifted to the surface (#287).
1045fn elliptical_cylinder_plane_rulings(
1046    cylinder: &EllipticalCylinder,
1047    ma: &Dyadic,
1048    mb: &Dyadic,
1049    k: &Dyadic,
1050) -> Result<ExactIntersectionCurve, ExactIntersectionRefusal> {
1051    let bad = ExactIntersectionRefusal::DegenerateFrame;
1052    let reach = ma.square().add(&mb.square());
1053    if esign(&reach) == Sign::Zero {
1054        return Err(bad);
1055    }
1056    let gap = reach.sub(&k.square());
1057    let touching = match esign(&gap) {
1058        Sign::Positive => false,
1059        Sign::Zero => true,
1060        _ => return Err(ExactIntersectionRefusal::Disjoint),
1061    };
1062    let (mx, my, kf) = (ma.to_f64(), mb.to_f64(), k.to_f64());
1063    let r2 = reach.to_f64();
1064    let r = r2.sqrt();
1065    // The foot of the trace nearest the centre, and its direction.
1066    let (fs, ft) = (-kf * mx / r2, -kf * my / r2);
1067    let (ws, wt) = (-my / r, mx / r);
1068    let half = if touching {
1069        0.0
1070    } else {
1071        (gap.to_f64() / r2).sqrt()
1072    };
1073    let offsets: &[Scalar] = if touching { &[0.0] } else { &[1.0, -1.0] };
1074    let frame = cylinder.frame;
1075    let mut branches = Vec::with_capacity(offsets.len());
1076    for sign in offsets {
1077        let (s, t) = (fs + sign * half * ws, ft + sign * half * wt);
1078        let origin = frame.origin
1079            + frame.x * (cylinder.semi_axis_x * s)
1080            + frame.y * (cylinder.semi_axis_y * t);
1081        if !origin.is_finite() || !frame.z.is_finite() {
1082            return Err(bad);
1083        }
1084        branches.push(Curve3::Line(axiolid_curve::Line3 {
1085            origin,
1086            direction: frame.z,
1087        }));
1088    }
1089    Ok(ExactIntersectionCurve::whole(
1090        branches,
1091        Derivation::EllipticalCylinderPlaneRulings,
1092    ))
1093}