Skip to main content

brepkit_math/
analytic_intersection.rs

1//! Closed-form and semi-analytic intersections of analytic surfaces with planes.
2//!
3//! Provides specialized intersection algorithms for cylinder, cone, sphere,
4//! and torus surfaces with planes, as well as a general marching approach
5//! for analytic-analytic surface intersections.
6
7use std::f64::consts::{FRAC_PI_2, TAU};
8
9use crate::MathError;
10use crate::curves::{Circle3D, Ellipse3D};
11use crate::frame::Frame3;
12use crate::nurbs::fitting::interpolate;
13use crate::nurbs::intersection::{IntersectionCurve, IntersectionPoint};
14use crate::surfaces::{ConicalSurface, CylindricalSurface, SphericalSurface, ToroidalSurface};
15use crate::tolerance::Tolerance;
16use crate::vec::{Point3, Vec3};
17
18/// Exact curve type resulting from plane-analytic surface intersection.
19#[derive(Debug, Clone)]
20pub enum ExactIntersectionCurve {
21    /// A circle (plane perpendicular to axis of cylinder/cone/sphere).
22    Circle(Circle3D),
23    /// An ellipse (plane oblique to cylinder/cone axis).
24    Ellipse(Ellipse3D),
25    /// Fallback to sampled point chain (torus, degenerate cases).
26    Points(Vec<Point3>),
27}
28
29/// Compute exact intersection curves between a plane and an analytic surface.
30///
31/// Returns exact `Circle3D` or `Ellipse3D` where possible, falling back to
32/// sampled points for complex cases (torus).
33///
34/// The plane is defined by `dot(normal, p) = d`.
35///
36/// # Errors
37///
38/// Returns an error if the intersection computation fails.
39pub fn exact_plane_analytic(
40    surface: AnalyticSurface<'_>,
41    plane_normal: Vec3,
42    plane_d: f64,
43) -> Result<Vec<ExactIntersectionCurve>, MathError> {
44    match surface {
45        AnalyticSurface::Cylinder(cyl) => exact_plane_cylinder(cyl, plane_normal, plane_d),
46        AnalyticSurface::Sphere(sphere) => exact_plane_sphere(sphere, plane_normal, plane_d),
47        AnalyticSurface::Cone(cone) => exact_plane_cone(cone, plane_normal, plane_d),
48        AnalyticSurface::Torus(torus) => {
49            // Torus intersections are degree-4 — fall back to sampling.
50            let chains = sample_plane_torus(torus, plane_normal, plane_d)?;
51            Ok(chains
52                .into_iter()
53                .map(ExactIntersectionCurve::Points)
54                .collect())
55        }
56    }
57}
58
59/// Exact plane-cylinder intersection.
60///
61/// - Plane perpendicular to axis → `Circle3D`
62/// - Plane oblique to axis → `Ellipse3D`
63/// - Plane parallel to axis → `Points` fallback (0 or 2 lines)
64fn exact_plane_cylinder(
65    cyl: &CylindricalSurface,
66    normal: Vec3,
67    d: f64,
68) -> Result<Vec<ExactIntersectionCurve>, MathError> {
69    let axis = cyl.axis();
70    let cos_theta = normal.dot(axis).abs();
71    let r = cyl.radius();
72
73    if cos_theta < 1e-10 {
74        // Plane parallel to cylinder axis → 0 or 2 line segments.
75        // Fall back to sampled points.
76        let chains = sample_plane_cylinder(cyl, normal, d)?;
77        return Ok(chains
78            .into_iter()
79            .map(ExactIntersectionCurve::Points)
80            .collect());
81    }
82
83    // Find where axis intersects the plane: axis_point + t*axis, dot(normal, P) = d
84    // t = (d - dot(normal, origin)) / dot(normal, axis)
85    let n_dot_axis = normal.dot(axis);
86    let n_dot_origin = dot_np(normal, cyl.origin());
87    let t = (d - n_dot_origin) / n_dot_axis;
88    let center_on_axis = Point3::new(
89        cyl.origin().x() + t * axis.x(),
90        cyl.origin().y() + t * axis.y(),
91        cyl.origin().z() + t * axis.z(),
92    );
93
94    if cos_theta > 1.0 - 1e-10 {
95        // Plane perpendicular to axis → Circle
96        let circle = Circle3D::new(center_on_axis, normal, r)?;
97        Ok(vec![ExactIntersectionCurve::Circle(circle)])
98    } else {
99        // Oblique plane → Ellipse
100        // Semi-minor = r (the cylinder radius, unchanged)
101        // Semi-major = r / cos(θ) where θ = angle between plane normal and axis
102        let semi_minor = r;
103        let semi_major = r / cos_theta;
104
105        // The major axis direction lies in the intersection of the plane
106        // with the plane containing the axis and the plane normal.
107        // It's the projection of the axis onto the cutting plane, normalized.
108        let axis_proj = Vec3::new(
109            axis.x() - n_dot_axis * normal.x(),
110            axis.y() - n_dot_axis * normal.y(),
111            axis.z() - n_dot_axis * normal.z(),
112        );
113        let u_axis = axis_proj.normalize()?;
114        let v_axis = normal.cross(u_axis);
115
116        let ellipse = Ellipse3D::with_axes(
117            center_on_axis,
118            normal,
119            semi_major,
120            semi_minor,
121            u_axis,
122            v_axis,
123        )?;
124        Ok(vec![ExactIntersectionCurve::Ellipse(ellipse)])
125    }
126}
127
128/// Exact plane-sphere intersection.
129///
130/// Always produces a `Circle3D` (or empty if no intersection).
131fn exact_plane_sphere(
132    sphere: &SphericalSurface,
133    normal: Vec3,
134    d: f64,
135) -> Result<Vec<ExactIntersectionCurve>, MathError> {
136    let h = dot_np(normal, sphere.center()) - d;
137    let r = sphere.radius();
138
139    if h.abs() > r - 1e-10 {
140        return Ok(vec![]);
141    }
142
143    let circle_r = (r.mul_add(r, -(h * h))).sqrt();
144    let circle_center = Point3::new(
145        h.mul_add(-normal.x(), sphere.center().x()),
146        h.mul_add(-normal.y(), sphere.center().y()),
147        h.mul_add(-normal.z(), sphere.center().z()),
148    );
149
150    let circle = Circle3D::new(circle_center, normal, circle_r)?;
151    Ok(vec![ExactIntersectionCurve::Circle(circle)])
152}
153
154/// Exact plane-cone intersection.
155///
156/// The conic type is set by the cone's half-opening angle from the axis
157/// (`γ = π/2 − half_angle`) versus the plane-axis angle `ψ`:
158/// - Plane perpendicular to axis (`ψ = π/2`) → `Circle3D`
159/// - `ψ > γ` (ellipse) → closed-form `Ellipse3D`
160/// - `ψ ≤ γ` (parabola/hyperbola) → bounded single-branch `Points` (one chain
161///   per branch — a hyperbola's two nappes never share a chain)
162fn exact_plane_cone(
163    cone: &ConicalSurface,
164    normal: Vec3,
165    d: f64,
166) -> Result<Vec<ExactIntersectionCurve>, MathError> {
167    let axis = cone.axis();
168    let cos_theta = normal.dot(axis).abs();
169    let half_angle = cone.half_angle();
170
171    if cos_theta > 1.0 - 1e-10 {
172        // Plane perpendicular to axis → Circle
173        // Find where axis meets the plane
174        let n_dot_axis = normal.dot(axis);
175        let n_dot_apex = dot_np(normal, cone.apex());
176        let t = (d - n_dot_apex) / n_dot_axis;
177
178        // t is the signed distance from apex to plane along the axis.
179        // The real cone is a single nappe; the perpendicular-plane section is a
180        // circle whose radius follows from the axial offset |t|.
181        // |t| ≈ 0 means the plane passes through the apex → degenerate point.
182        if t.abs() < 1e-10 {
183            return Ok(vec![]);
184        }
185
186        let center = Point3::new(
187            cone.apex().x() + t * axis.x(),
188            cone.apex().y() + t * axis.y(),
189            cone.apex().z() + t * axis.z(),
190        );
191        // half_angle is the angle from the radial plane to the surface.
192        // Axial distance t = v * sin(half_angle), so v = t / sin(half_angle).
193        // Radius at v = v * cos(half_angle) = t * cos(half_angle) / sin(half_angle).
194        let circle_r = t.abs() * half_angle.cos() / half_angle.sin();
195        if circle_r < 1e-15 {
196            return Ok(vec![]);
197        }
198
199        let circle = Circle3D::new(center, normal, circle_r)?;
200        return Ok(vec![ExactIntersectionCurve::Circle(circle)]);
201    }
202
203    // Oblique plane. Classify the conic in the plane-aligned frame.
204    //
205    // Decompose the (unit) axis as a = c·n + p·e1, where c = n·a, e1 is the unit
206    // in-plane projection of the axis, and p = |projection| = sqrt(1−c²). Write a
207    // point Q on the plane as Q = apex + e·n + s·e1 + t·e2 (e = d − n·apex,
208    // e2 = n×e1). The cone equation (w·a)² = cos²γ·(w·w) with k = cos²γ =
209    // sin²(half_angle) reduces to (no s·t cross term, since e1/e2 align with the
210    // conic axes):
211    //     (p²−k)·s² + 2ecp·s + e²(c²−k) = k·t²
212    // The s² coefficient A = p²−k = sin²θ − sin²(half_angle) sets the type:
213    // A < 0 → ellipse, A = 0 → parabola, A > 0 → hyperbola.
214    let c = normal.dot(axis);
215    let p2 = (1.0 - c * c).max(0.0);
216    let p = p2.sqrt();
217    let k = half_angle.sin().powi(2);
218    let a_coeff = p2 - k;
219
220    // Build the plane-aligned frame e1 (in-plane axis projection), e2 = n×e1.
221    let m = Vec3::new(
222        axis.x() - c * normal.x(),
223        axis.y() - c * normal.y(),
224        axis.z() - c * normal.z(),
225    );
226    let m_len = m.length();
227    if m_len < 1e-12 {
228        // Axis parallel to normal — handled by the perpendicular branch above;
229        // fall back to sampling for safety.
230        let chains = sample_plane_cone(cone, normal, d)?;
231        return Ok(chains
232            .into_iter()
233            .map(ExactIntersectionCurve::Points)
234            .collect());
235    }
236    let e1 = m * (1.0 / m_len);
237    let e2 = normal.cross(e1);
238    let apex = cone.apex();
239    let e = d - dot_np(normal, apex);
240
241    // Ellipse → closed form. A = p²−k < 0 with a margin to keep the
242    // near-parabolic regime on the robust sampled path.
243    if a_coeff < -1e-9 {
244        let abs_a = -a_coeff; // = k − p² > 0
245        // Real-nappe guard: in the ellipse regime n·g(u) keeps constant sign(c),
246        // so v = e/(n·g) ≥ 0 only when e and c share a sign. When e·c < 0 the
247        // plane is offset to the far side of the apex from the cone's opening —
248        // the section lies entirely on the phantom nappe, so there is no real
249        // curve (RHS below is positive regardless of sign, so it can't catch this).
250        if e * c < 0.0 {
251            return Ok(vec![]);
252        }
253        // |A|(s − s_c)² + k·t² = RHS, with s_c = ecp/|A| and
254        // RHS = e²·k·(1−k)/|A| (always > 0 for a real ellipse).
255        let s_c = e * c * p / abs_a;
256        let rhs = e * e * k * (1.0 - k) / abs_a;
257        if rhs <= 0.0 {
258            return Ok(vec![]);
259        }
260        let semi_s = (rhs / abs_a).sqrt(); // extent along e1
261        let semi_t = (rhs / k).sqrt(); // extent along e2
262        if semi_s < 1e-12 || semi_t < 1e-12 {
263            return Ok(vec![]);
264        }
265        let center = apex + normal * e + e1 * s_c;
266        let (semi_major, semi_minor, u_axis, v_axis) = if semi_s >= semi_t {
267            (semi_s, semi_t, e1, e2)
268        } else {
269            (semi_t, semi_s, e2, e1)
270        };
271        let ellipse = Ellipse3D::with_axes(center, normal, semi_major, semi_minor, u_axis, v_axis)?;
272        return Ok(vec![ExactIntersectionCurve::Ellipse(ellipse)]);
273    }
274
275    // Parabola / hyperbola (and the near-parabolic ellipse margin): the section
276    // is unbounded, so emit bounded, branch-separated sample chains.
277    let chains = sample_plane_cone(cone, normal, d)?;
278    Ok(chains
279        .into_iter()
280        .map(ExactIntersectionCurve::Points)
281        .collect())
282}
283
284/// Reference to an analytic surface for intersection dispatch.
285#[derive(Clone, Copy)]
286pub enum AnalyticSurface<'a> {
287    /// Cylindrical surface reference.
288    Cylinder(&'a CylindricalSurface),
289    /// Conical surface reference.
290    Cone(&'a ConicalSurface),
291    /// Spherical surface reference.
292    Sphere(&'a SphericalSurface),
293    /// Toroidal surface reference.
294    Torus(&'a ToroidalSurface),
295}
296
297/// Compute `n . p` treating a `Point3` as a position vector.
298fn dot_np(n: Vec3, p: Point3) -> f64 {
299    n.dot(Vec3::new(p.x(), p.y(), p.z()))
300}
301
302/// Intersect a plane with an analytic surface.
303///
304/// The plane is defined by `dot(normal, p) = d`.
305///
306/// # Errors
307///
308/// Returns an error if the intersection computation fails.
309pub fn intersect_plane_analytic(
310    surface: AnalyticSurface<'_>,
311    normal: Vec3,
312    d: f64,
313) -> Result<Vec<IntersectionCurve>, MathError> {
314    match surface {
315        AnalyticSurface::Cylinder(cyl) => intersect_plane_cylinder(cyl, normal, d),
316        AnalyticSurface::Cone(cone) => intersect_plane_cone(cone, normal, d),
317        AnalyticSurface::Sphere(sphere) => intersect_plane_sphere(sphere, normal, d),
318        AnalyticSurface::Torus(torus) => intersect_plane_torus(torus, normal, d),
319    }
320}
321
322/// Sample points on the plane-analytic intersection without NURBS curve fitting.
323///
324/// Returns chains of ordered 3D sample points. Each chain is one connected
325/// component of the intersection curve. This is much faster than
326/// `intersect_plane_analytic` when only sample points are needed (e.g. for
327/// boolean intersection segment generation).
328///
329/// # Errors
330///
331/// Returns an error if the intersection computation fails.
332pub fn sample_plane_analytic(
333    surface: AnalyticSurface<'_>,
334    normal: Vec3,
335    d: f64,
336) -> Result<Vec<Vec<Point3>>, MathError> {
337    match surface {
338        AnalyticSurface::Cylinder(cyl) => sample_plane_cylinder(cyl, normal, d),
339        AnalyticSurface::Cone(cone) => sample_plane_cone(cone, normal, d),
340        AnalyticSurface::Sphere(sphere) => sample_plane_sphere(sphere, normal, d),
341        AnalyticSurface::Torus(torus) => sample_plane_torus(torus, normal, d),
342    }
343}
344
345/// Sample the plane-cylinder intersection as ordered 3D points.
346#[allow(clippy::cast_precision_loss, clippy::unnecessary_wraps)]
347fn sample_plane_cylinder(
348    cyl: &CylindricalSurface,
349    normal: Vec3,
350    d: f64,
351) -> Result<Vec<Vec<Point3>>, MathError> {
352    let n_samples = 64_usize;
353    let mut points = Vec::with_capacity(n_samples + 1);
354
355    for i in 0..=n_samples {
356        let u = TAU * (i as f64) / (n_samples as f64);
357        let base = cyl.evaluate(u, 0.0);
358        let n_dot_axis = normal.dot(cyl.axis());
359        let n_dot_base = dot_np(normal, base);
360
361        if n_dot_axis.abs() < 1e-12 {
362            if (n_dot_base - d).abs() < 1e-6 {
363                points.push(base);
364            }
365        } else {
366            let v = (d - n_dot_base) / n_dot_axis;
367            if v.abs() <= 100.0 {
368                points.push(cyl.evaluate(u, v));
369            }
370        }
371    }
372
373    if points.len() < 2 {
374        Ok(vec![])
375    } else {
376        Ok(vec![points])
377    }
378}
379
380/// Sample the plane-sphere intersection as ordered 3D points.
381#[allow(clippy::cast_precision_loss)]
382fn sample_plane_sphere(
383    sphere: &SphericalSurface,
384    normal: Vec3,
385    d: f64,
386) -> Result<Vec<Vec<Point3>>, MathError> {
387    let h = dot_np(normal, sphere.center()) - d;
388    let r = sphere.radius();
389
390    if h.abs() > r - 1e-10 {
391        return Ok(vec![]);
392    }
393
394    let circle_r = (r.mul_add(r, -(h * h))).sqrt();
395    let circle_center = Point3::new(
396        h.mul_add(-normal.x(), sphere.center().x()),
397        h.mul_add(-normal.y(), sphere.center().y()),
398        h.mul_add(-normal.z(), sphere.center().z()),
399    );
400
401    let basis = Frame3::from_normal(circle_center, normal)?;
402    let u_dir = basis.x;
403    let v_dir = basis.y;
404
405    let n_samples = 64_usize;
406    let mut points = Vec::with_capacity(n_samples + 1);
407
408    for i in 0..=n_samples {
409        let theta = TAU * (i as f64) / (n_samples as f64);
410        let (sin_t, cos_t) = theta.sin_cos();
411        points.push(circle_center + u_dir * (circle_r * cos_t) + v_dir * (circle_r * sin_t));
412    }
413
414    Ok(vec![points])
415}
416
417/// Sample the plane-cone intersection as ordered 3D points.
418///
419/// The cone is the single real nappe `v >= 0` of `P(u,v) = apex + v·g(u)`.
420/// Along each generator `g(u)` the plane `n·P = d` is linear in `v`, so
421/// `v = (d − n·apex) / (n·g(u))`. We keep only `v >= 0` (the phantom `v < 0`
422/// nappe is geometrically absent) and `v` below a finite bound (near an
423/// asymptote `n·g(u) → 0` so `v → ∞` — those points run off the surface and
424/// must be excluded). The angular samples that survive form one contiguous arc
425/// (ellipse) or two (parabola/hyperbola, one per branch); each contiguous run
426/// is returned as a separate ordered chain so the consumer never stitches two
427/// disjoint branches into one curve.
428#[allow(clippy::cast_precision_loss, clippy::unnecessary_wraps)]
429fn sample_plane_cone(
430    cone: &ConicalSurface,
431    normal: Vec3,
432    d: f64,
433) -> Result<Vec<Vec<Point3>>, MathError> {
434    let apex = cone.apex();
435    let n_dot_apex = dot_np(normal, apex);
436    let e = d - n_dot_apex;
437
438    // Per-generator solve: along g(u) the plane is linear in v, v = e / (n·g(u)).
439    // Sample u densely; keep only the real nappe (v >= 0) and skip near-asymptote
440    // generators (n·g(u) ≈ 0 → v → ∞).
441    let n_samples = 512_usize;
442    let mut vs: Vec<Option<f64>> = Vec::with_capacity(n_samples);
443    let mut v_min = f64::INFINITY;
444    for i in 0..n_samples {
445        let u = TAU * (i as f64) / (n_samples as f64);
446        let g = cone.evaluate(u, 1.0) - apex;
447        let n_dot_g = normal.dot(Vec3::new(g.x(), g.y(), g.z()));
448        if n_dot_g.abs() < 1e-12 {
449            vs.push(None);
450            continue;
451        }
452        let v = e / n_dot_g;
453        if v >= -1e-12 {
454            let v = v.max(0.0);
455            v_min = v_min.min(v);
456            vs.push(Some(v));
457        } else {
458            vs.push(None);
459        }
460    }
461
462    if !v_min.is_finite() {
463        return Ok(Vec::new());
464    }
465
466    // Bound the arc around the conic vertex (closest approach to the apex, at
467    // v_min). An ellipse is naturally bounded; a parabola/hyperbola is not, so
468    // cap the cone radius at a generous multiple of the vertex radius. This is
469    // scale-invariant and centred on where any finite cone face's overlap lies;
470    // the downstream consumer trims the fitted curve to the actual face AABB, so
471    // over-coverage is harmless. The floor handles a vertex at the apex (v_min≈0).
472    let v_max = (8.0 * v_min).max(v_min + 4.0);
473
474    // Per-sample v within the cap; the raw values stay in `vs` for the
475    // boundary solve below.
476    let kept: Vec<Option<f64>> = vs.iter().map(|v| v.filter(|&v| v <= v_max)).collect();
477
478    let point_at = |u: f64, v: f64| -> Point3 {
479        let g = cone.evaluate(u, 1.0) - apex;
480        apex + g * v
481    };
482    #[allow(clippy::cast_precision_loss)]
483    let u_of = |i: usize| TAU * (i as f64) / (n_samples as f64);
484    let n_dot_g_at = |u: f64| -> f64 {
485        let g = cone.evaluate(u, 1.0) - apex;
486        normal.dot(Vec3::new(g.x(), g.y(), g.z()))
487    };
488
489    if kept.iter().all(Option::is_some) {
490        // Closed loop (ellipse regime): emit all points and repeat the first.
491        let mut pts: Vec<Point3> = kept
492            .iter()
493            .enumerate()
494            .filter_map(|(i, v)| v.map(|v| point_at(u_of(i), v)))
495            .collect();
496        if let Some(&first) = pts.first() {
497            pts.push(first);
498        }
499        return Ok(vec![pts]);
500    }
501
502    // A hyperbola/parabola tail diverges as 1/(n·g), so between the last kept
503    // sample and its dropped neighbour v can leap far past `v_max` in one
504    // uniform-u pitch — and any finite face window inside that leap is lost
505    // (a taper cone grazed 0.05 by a prism plane lost its entire 0.5-tall
506    // section to exactly this aliasing). Extend each run end to the exact
507    // `v_max` boundary: bisect u for `n·g(u) = e/v_max` inside the dropped
508    // pitch (n·g is monotone there — its extrema sit at the conic vertex,
509    // far from any asymptote), then fill the tail with uniform-u samples.
510    let tail = |i_end: usize, forward: bool, kept: &[Option<f64>]| -> Vec<Point3> {
511        let Some(v_end) = kept[i_end] else {
512            return Vec::new();
513        };
514        let u_end = u_of(i_end);
515        #[allow(clippy::cast_precision_loss)]
516        let pitch = TAU / (n_samples as f64);
517        let u_next = if forward {
518            u_end + pitch
519        } else {
520            u_end - pitch
521        };
522        let target = e / v_max;
523        let h_end = n_dot_g_at(u_end) - target;
524        let h_next = n_dot_g_at(u_next) - target;
525        if v_end >= v_max || h_end == 0.0 || h_end.signum() == h_next.signum() {
526            return Vec::new();
527        }
528        let (mut lo, mut hi) = (u_end, u_next);
529        for _ in 0..60 {
530            let mid = f64::midpoint(lo, hi);
531            if (n_dot_g_at(mid) - target).signum() == h_end.signum() {
532                lo = mid;
533            } else {
534                hi = mid;
535            }
536        }
537        let u_star = f64::midpoint(lo, hi);
538        let tail_n = 8_usize;
539        (1..=tail_n)
540            .filter_map(|k| {
541                #[allow(clippy::cast_precision_loss)]
542                let u = u_end + (u_star - u_end) * (k as f64) / (tail_n as f64);
543                let ng = n_dot_g_at(u);
544                if ng.abs() < 1e-12 {
545                    return None;
546                }
547                let v = e / ng;
548                (v >= -1e-12 && v <= v_max * (1.0 + 1e-9)).then(|| point_at(u, v.max(0.0)))
549            })
550            .collect()
551    };
552
553    // Split into contiguous runs of kept samples, treating the array as
554    // circular (rotate past a gap) so a branch straddling u=0 stays whole.
555    let gap = kept.iter().position(Option::is_none).unwrap_or(0);
556    let mut chains: Vec<Vec<Point3>> = Vec::new();
557    let mut run: Vec<usize> = Vec::new();
558    let flush = |run: &mut Vec<usize>, chains: &mut Vec<Vec<Point3>>| {
559        if run.len() >= 2 {
560            let first = run[0];
561            let last = run[run.len() - 1];
562            let mut pts: Vec<Point3> = tail(first, false, &kept);
563            pts.reverse();
564            pts.extend(
565                run.iter()
566                    .filter_map(|&i| kept[i].map(|v| point_at(u_of(i), v))),
567            );
568            pts.extend(tail(last, true, &kept));
569            chains.push(pts);
570        }
571        run.clear();
572    };
573    for k in 0..n_samples {
574        let idx = (gap + k) % n_samples;
575        if kept[idx].is_some() {
576            run.push(idx);
577        } else {
578            flush(&mut run, &mut chains);
579        }
580    }
581    flush(&mut run, &mut chains);
582    Ok(chains.into_iter().filter(|c| c.len() >= 2).collect())
583}
584
585/// Sample the plane-torus intersection as ordered 3D points.
586///
587/// Uses the same closed-form crossings and chaining as `intersect_plane_torus`
588/// but skips NURBS curve fitting (the callers here only need the points).
589#[allow(clippy::unnecessary_wraps)] // sibling match-arms and `?` callers need `Result`
590fn sample_plane_torus(
591    torus: &ToroidalSurface,
592    normal: Vec3,
593    d: f64,
594) -> Result<Vec<Vec<Point3>>, MathError> {
595    let crossing_pts = plane_torus_crossings(torus, normal, d, 128);
596    Ok(chain_torus_crossings(&crossing_pts)
597        .into_iter()
598        .map(|run| run.into_iter().map(|p| p.point).collect())
599        .collect())
600}
601
602/// Intersect a plane with a cylindrical surface.
603///
604/// For each `u` in `[0, 2pi)`, the cylinder point is linear in `v`,
605/// so the plane equation `dot(normal, P(u,v)) = d` is linear in `v`
606/// and can be solved directly.
607///
608/// # Errors
609///
610/// Returns an error if curve fitting fails.
611#[allow(clippy::cast_precision_loss)]
612pub fn intersect_plane_cylinder(
613    cyl: &CylindricalSurface,
614    normal: Vec3,
615    d: f64,
616) -> Result<Vec<IntersectionCurve>, MathError> {
617    let n_samples = 64_usize;
618    let mut points_3d = Vec::new();
619    let mut ipoints = Vec::new();
620
621    for i in 0..=n_samples {
622        let u = TAU * (i as f64) / (n_samples as f64);
623        // P(u, v) = origin + r*(cos(u)*x + sin(u)*y) + v*axis
624        // dot(normal, P) = d  =>  dot(normal, base(u)) + v * dot(normal, axis) = d
625        let base = cyl.evaluate(u, 0.0);
626        let n_dot_axis = normal.dot(cyl.axis());
627        let n_dot_base = dot_np(normal, base);
628
629        if n_dot_axis.abs() < 1e-12 {
630            // Plane parallel to axis -- check if base is on plane.
631            if (n_dot_base - d).abs() < 1e-6 {
632                let pt = base;
633                points_3d.push(pt);
634                ipoints.push(IntersectionPoint {
635                    point: pt,
636                    param1: (u, 0.0),
637                    param2: (0.0, 0.0),
638                });
639            }
640        } else {
641            let v = (d - n_dot_base) / n_dot_axis;
642            // Only keep points within a reasonable v range.
643            if v.abs() <= 100.0 {
644                let pt = cyl.evaluate(u, v);
645                points_3d.push(pt);
646                ipoints.push(IntersectionPoint {
647                    point: pt,
648                    param1: (u, v),
649                    param2: (0.0, 0.0),
650                });
651            }
652        }
653    }
654
655    build_curves_from_points(&points_3d, ipoints)
656}
657
658/// Intersect a plane with a spherical surface.
659///
660/// The intersection of a plane with a sphere is a circle (or empty/point).
661/// Computes the circle center, radius, and samples points on it.
662///
663/// # Errors
664///
665/// Returns an error if curve fitting fails.
666#[allow(clippy::cast_precision_loss)]
667pub fn intersect_plane_sphere(
668    sphere: &SphericalSurface,
669    normal: Vec3,
670    d: f64,
671) -> Result<Vec<IntersectionCurve>, MathError> {
672    let h = dot_np(normal, sphere.center()) - d;
673    let r = sphere.radius();
674
675    // No intersection if plane is too far from center.
676    if h.abs() > r - 1e-10 {
677        return Ok(vec![]);
678    }
679
680    let circle_r = (r.mul_add(r, -(h * h))).sqrt();
681    let circle_center = Point3::new(
682        h.mul_add(-normal.x(), sphere.center().x()),
683        h.mul_add(-normal.y(), sphere.center().y()),
684        h.mul_add(-normal.z(), sphere.center().z()),
685    );
686
687    // Build a local frame on the plane.
688    let basis = Frame3::from_normal(circle_center, normal)?;
689    let u_dir = basis.x;
690    let v_dir = basis.y;
691
692    let n_samples = 64_usize;
693    let mut points_3d = Vec::new();
694    let mut ipoints = Vec::new();
695
696    for i in 0..=n_samples {
697        let theta = TAU * (i as f64) / (n_samples as f64);
698        let (sin_t, cos_t) = theta.sin_cos();
699        let pt = circle_center + u_dir * (circle_r * cos_t) + v_dir * (circle_r * sin_t);
700        points_3d.push(pt);
701        ipoints.push(IntersectionPoint {
702            point: pt,
703            param1: (theta, 0.0),
704            param2: (0.0, 0.0),
705        });
706    }
707
708    build_curves_from_points(&points_3d, ipoints)
709}
710
711/// Intersect a plane with a conical surface.
712///
713/// Like a cylinder, the cone is linear along each generatrix, so the plane
714/// equation is linear in `v` for each fixed `u`.
715///
716/// # Errors
717///
718/// Returns an error if curve fitting fails.
719#[allow(clippy::cast_precision_loss)]
720pub fn intersect_plane_cone(
721    cone: &ConicalSurface,
722    normal: Vec3,
723    d: f64,
724) -> Result<Vec<IntersectionCurve>, MathError> {
725    let n_samples = 64_usize;
726    let mut points_3d = Vec::new();
727    let mut ipoints = Vec::new();
728
729    for i in 0..n_samples {
730        let u = TAU * (i as f64) / (n_samples as f64);
731        // P(u, v) = apex + v * dir(u)
732        // dot(normal, apex) + v * dot(normal, dir(u)) = d
733        let apex = cone.apex();
734        let n_dot_apex = dot_np(normal, apex);
735        // dir(u) = P(u,1) - apex
736        let p1 = cone.evaluate(u, 1.0);
737        let dir = p1 - apex;
738        let n_dot_dir = normal.dot(dir);
739
740        if n_dot_dir.abs() < 1e-12 {
741            continue;
742        }
743
744        let v = (d - n_dot_apex) / n_dot_dir;
745        // Allow negative v — the cone surface extends in both directions from the apex.
746        if v.abs() > 1e-10 && v.abs() < 100.0 {
747            let pt = cone.evaluate(u, v);
748            points_3d.push(pt);
749            ipoints.push(IntersectionPoint {
750                point: pt,
751                param1: (u, v),
752                param2: (0.0, 0.0),
753            });
754        }
755    }
756
757    build_curves_from_points(&points_3d, ipoints)
758}
759
760/// Intersect a plane with a toroidal surface.
761///
762/// The section is a degree-4 curve, but for each `v` the `u` values solve in
763/// closed form (see `plane_torus_crossings`), so it is sampled by a v-scan
764/// and each connected loop is fitted to a NURBS curve.
765///
766/// # Errors
767///
768/// Never returns an error today (curve-fit failures drop the affected loop);
769/// the `Result` is kept for signature parity with the other plane-analytic
770/// intersectors.
771#[allow(clippy::unnecessary_wraps)]
772pub fn intersect_plane_torus(
773    torus: &ToroidalSurface,
774    normal: Vec3,
775    d: f64,
776) -> Result<Vec<IntersectionCurve>, MathError> {
777    // The section satisfies a per-v closed form (see `plane_torus_crossings`),
778    // so scan v and solve u directly instead of a 2D sign-change grid with
779    // Newton refinement: O(n) rather than O(n²), and every point is exact.
780    let crossing_pts = plane_torus_crossings(torus, normal, d, 128);
781
782    let mut curves = Vec::new();
783    for ipts in chain_torus_crossings(&crossing_pts) {
784        let pts: Vec<Point3> = ipts.iter().map(|p| p.point).collect();
785        if let Ok(curve) = interpolate(&pts, 3.min(pts.len() - 1)) {
786            curves.push(IntersectionCurve {
787                curve,
788                points: ipts,
789            });
790        }
791    }
792
793    Ok(curves)
794}
795
796/// Greedy nearest-neighbour chaining of torus-plane crossing points into
797/// closed section loops. Runs shorter than four points are dropped.
798///
799/// Plane × full torus is always a set of CLOSED loops, but the greedy walk
800/// stops one step short of closing (the first point is already `used`, so it
801/// is never re-added and the last point sits ~one step from the start).
802/// A loop whose end-to-start gap is within ~2 point-spacings is closed by
803/// repeating its first point, so a fitted NURBS closes exactly and downstream
804/// consumers see a closed curve. A fragmented chain (greedy walk broke a loop
805/// at a near-tangency) ends far from its start and is left open — it must not
806/// be force-closed into a wrong loop.
807fn chain_torus_crossings(crossing_pts: &[(f64, f64, Point3)]) -> Vec<Vec<IntersectionPoint>> {
808    let mut used = vec![false; crossing_pts.len()];
809    let mut runs = Vec::new();
810
811    for start in 0..crossing_pts.len() {
812        if used[start] {
813            continue;
814        }
815        used[start] = true;
816        let mut chain = vec![start];
817
818        loop {
819            let last = chain[chain.len() - 1];
820            let last_pt = crossing_pts[last].2;
821            let mut best_idx = None;
822            let mut best_dist = 1.0_f64;
823
824            for (j, &is_used) in used.iter().enumerate() {
825                if is_used {
826                    continue;
827                }
828                let dist = (crossing_pts[j].2 - last_pt).length();
829                if dist < best_dist {
830                    best_dist = dist;
831                    best_idx = Some(j);
832                }
833            }
834
835            if let Some(j) = best_idx {
836                used[j] = true;
837                chain.push(j);
838            } else {
839                break;
840            }
841        }
842
843        if chain.len() < 4 {
844            continue;
845        }
846        let mut ipts: Vec<IntersectionPoint> = chain
847            .iter()
848            .map(|&i| IntersectionPoint {
849                point: crossing_pts[i].2,
850                param1: (crossing_pts[i].0, crossing_pts[i].1),
851                param2: (0.0, 0.0),
852            })
853            .collect();
854
855        let closing_gap = (ipts[ipts.len() - 1].point - ipts[0].point).length();
856        let median_spacing = {
857            let mut spac: Vec<f64> = ipts
858                .windows(2)
859                .map(|w| (w[1].point - w[0].point).length())
860                .collect();
861            spac.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
862            spac.get(spac.len() / 2).copied().unwrap_or(0.0)
863        };
864        // Wrap when the closing gap is within ~2 point-spacings (measured
865        // ratio ≈ 1.0 for the census ovals) and not already coincident — but
866        // only for a SIMPLE loop. A self-touching section (the inner/outer
867        // tangent figure-eight) is traced as one chain that folds back through
868        // its node and also ends near its start; sealing it would misrepresent
869        // a non-manifold singularity as a closed loop, so leave it open.
870        if closing_gap > 1e-9
871            && median_spacing > 1e-12
872            && closing_gap <= 2.0 * median_spacing
873            && !chain_self_touches(&ipts, median_spacing)
874        {
875            ipts.push(ipts[0]);
876        }
877        runs.push(ipts);
878    }
879
880    runs
881}
882
883/// Whether a chain folds back on itself in its interior — the signature of a
884/// self-touching section (a tangent figure-eight), as opposed to a simple
885/// loop whose only near-return is the intended closure at its two ends.
886///
887/// Checks whether two chain points far apart in index (and both away from the
888/// endpoints, so the closure region is excluded) come within ~1.5 spacings of
889/// each other. A convex/simple oval never does; a figure-eight does, at its
890/// node. Only called when a chain already looks closeable, so the O(m²) scan
891/// is rare.
892fn chain_self_touches(ipts: &[IntersectionPoint], median_spacing: f64) -> bool {
893    let m = ipts.len();
894    let k = (m / 4).clamp(1, 6);
895    if m < 3 * k || median_spacing <= 0.0 {
896        return false;
897    }
898    let thresh = median_spacing * 1.5;
899    for i in k..(m - k) {
900        for j in (i + k)..(m - k) {
901            if (ipts[i].point - ipts[j].point).length() < thresh {
902                return true;
903            }
904        }
905    }
906    false
907}
908
909/// Closed-form `(u, v, point)` crossings of a plane with a torus.
910///
911/// In the torus's own frame let `a = n·X`, `b = n·Y`, `c = n·Z`,
912/// `s = hypot(a, b)`, `phi = atan2(b, a)`. Substituting the torus
913/// parameterization into `n·P = d` gives
914///   `(R + r·cos v)·s·cos(u − phi) + r·c·sin v = d − n·center`,
915/// so for each `v` the two `u` branches solve directly as
916/// `u = phi ± acos((d − n·center − r·c·sin v) / (s·(R + r·cos v)))`.
917/// Scanning `v` at `n_v` samples replaces a 2D sign-change grid plus Newton
918/// refinement: each point is `torus.evaluate(u, v)` (on the torus by
919/// construction) with `u` solved so it lies on the plane to floating-point
920/// precision, so no iterative refinement is needed.
921///
922/// When `s ≈ 0` the plane is perpendicular to the axis and the section is up
923/// to two full circles at the `v` values solving `r·c·sin v = d − n·center`;
924/// those are sampled by scanning `u`.
925#[allow(clippy::cast_precision_loss)]
926fn plane_torus_crossings(
927    torus: &ToroidalSurface,
928    normal: Vec3,
929    d: f64,
930    n_v: usize,
931) -> Vec<(f64, f64, Point3)> {
932    let big_r = torus.major_radius();
933    let small_r = torus.minor_radius();
934    let a = normal.dot(torus.x_axis());
935    let b = normal.dot(torus.y_axis());
936    let c = normal.dot(torus.z_axis());
937    let s = a.hypot(b);
938    let phi = b.atan2(a);
939    let d_local = d - dot_np(normal, torus.center());
940
941    let mut pts: Vec<(f64, f64, Point3)> = Vec::new();
942
943    // Plane perpendicular to the axis: the section is up to two full circles.
944    if s < 1e-12 {
945        if c.abs() < 1e-12 {
946            return pts;
947        }
948        let sin_v = d_local / (small_r * c);
949        if sin_v.abs() > 1.0 + 1e-9 {
950            return pts;
951        }
952        let v0 = sin_v.clamp(-1.0, 1.0).asin();
953        let v1 = std::f64::consts::PI - v0;
954        let mut vs = vec![v0];
955        // Skip the mirror circle when the plane is tangent (v0 == v1).
956        if (v1 - v0).abs() > 1e-9 {
957            vs.push(v1);
958        }
959        for v in vs {
960            for i in 0..n_v {
961                let u = TAU * (i as f64) / (n_v as f64);
962                pts.push((u, v, torus.evaluate(u, v)));
963            }
964        }
965        return pts;
966    }
967
968    // General plane: scan v, solve the two u branches per v. Offset the scan
969    // by half a step so it never lands exactly on a tangency node (e.g. the
970    // inner-tangent figure-eight at v = π, where the two u branches collapse
971    // to one point) — a coincident node lets greedy chaining thread through
972    // and wrongly seal a self-touching section into a closed loop.
973    let v_off = TAU / (n_v as f64) * 0.5;
974    for i in 0..n_v {
975        let v = (i as f64).mul_add(TAU / (n_v as f64), v_off);
976        let tube_r = small_r.mul_add(v.cos(), big_r); // R + r·cos v > 0
977        let rhs = (d_local - small_r * c * v.sin()) / (s * tube_r);
978        if rhs.abs() > 1.0 {
979            continue;
980        }
981        let delta = rhs.clamp(-1.0, 1.0).acos();
982        for u in [phi + delta, phi - delta] {
983            pts.push((u, v, torus.evaluate(u, v)));
984        }
985    }
986    pts
987}
988
989/// Real intersection parameters `t` of the line `origin + t·dir` with a torus.
990///
991/// A line meets a torus in up to four points (degree-4). Substituting the line
992/// into the torus implicit `(a² + b² + c² + R² − r²)² = 4R²(a² + b²)` — where
993/// `(a, b, c)` are the line point's coordinates in the torus frame — gives a
994/// quartic in `t`, solved here for its real roots (each refined by one Newton
995/// step against the implicit). `dir` need not be unit length; `t` is in units of
996/// `dir`. Returns the roots sorted ascending (0–4 of them).
997///
998/// Used by the boolean section trimmer to find where a plane×torus oval exits a
999/// box face's straight boundary edge — the exact crossing shared by the two
1000/// adjacent faces, which is what makes the notch watertight.
1001#[must_use]
1002pub fn intersect_line_torus(torus: &ToroidalSurface, origin: Point3, dir: Vec3) -> Vec<f64> {
1003    let c = torus.center();
1004    let (xa, ya, za) = (torus.x_axis(), torus.y_axis(), torus.z_axis());
1005    let big_r = torus.major_radius();
1006    let small_r = torus.minor_radius();
1007
1008    // Line point in torus frame: a(t)=a0+a1 t, b(t)=b0+b1 t, c(t)=c0+c1 t.
1009    let o = Vec3::new(origin.x() - c.x(), origin.y() - c.y(), origin.z() - c.z());
1010    let (a0, a1) = (xa.dot(o), xa.dot(dir));
1011    let (b0, b1) = (ya.dot(o), ya.dot(dir));
1012    let (c0, c1) = (za.dot(o), za.dot(dir));
1013
1014    // G(t) = a² + b² + c² + R² − r²  (quadratic: g2 t² + g1 t + g0)
1015    let g2 = a1.mul_add(a1, b1.mul_add(b1, c1 * c1));
1016    let g1 = 2.0 * a1.mul_add(a0, b1.mul_add(b0, c1 * c0));
1017    let g0 = a0.mul_add(
1018        a0,
1019        b0.mul_add(b0, c0.mul_add(c0, big_r.mul_add(big_r, -small_r * small_r))),
1020    );
1021
1022    // H(t) = 4R² (a² + b²)  (quadratic: h2 t² + h1 t + h0)
1023    let four_rr = 4.0 * big_r * big_r;
1024    let h2 = four_rr * a1.mul_add(a1, b1 * b1);
1025    let h1 = four_rr * (2.0 * a1.mul_add(a0, b1 * b0));
1026    let h0 = four_rr * a0.mul_add(a0, b0 * b0);
1027
1028    // Quartic G² − H = 0:  e4 t⁴ + e3 t³ + e2 t² + e1 t + e0.
1029    let e4 = g2 * g2;
1030    let e3 = 2.0 * g2 * g1;
1031    let e2 = g1.mul_add(g1, 2.0 * g2 * g0) - h2;
1032    let e1 = 2.0f64.mul_add(g1 * g0, -h1);
1033    let e0 = g0.mul_add(g0, -h0);
1034
1035    let mut roots = real_roots_quartic(e4, e3, e2, e1, e0);
1036    // One Newton polish against the torus implicit for full precision.
1037    let impl_f = |t: f64| -> f64 {
1038        let p = origin + dir * t;
1039        let q = Vec3::new(p.x() - c.x(), p.y() - c.y(), p.z() - c.z());
1040        let (a, b, cc) = (xa.dot(q), ya.dot(q), za.dot(q));
1041        (a.hypot(b) - big_r).hypot(cc) - small_r
1042    };
1043    for t in &mut roots {
1044        let eps = 1e-7;
1045        let f = impl_f(*t);
1046        let df = (impl_f(*t + eps) - impl_f(*t - eps)) / (2.0 * eps);
1047        if df.abs() > 1e-12 {
1048            *t -= f / df;
1049        }
1050    }
1051    roots.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1052    roots
1053}
1054
1055/// Real roots of `c4 x⁴ + c3 x³ + c2 x² + c1 x + c0` via Durand–Kerner, falling
1056/// back to the lower-degree solvers when the leading coefficients vanish.
1057fn real_roots_quartic(c4: f64, c3: f64, c2: f64, c1: f64, c0: f64) -> Vec<f64> {
1058    // Degenerate leading coefficient → lower degree.
1059    if c4.abs() < 1e-14 {
1060        return real_roots_cubic(c3, c2, c1, c0);
1061    }
1062    // Monic: x⁴ + a x³ + b x² + c x + d.
1063    let (a, b, c, d) = (c3 / c4, c2 / c4, c1 / c4, c0 / c4);
1064    let eval = |z: Complex| -> Complex {
1065        // Horner.
1066        let mut acc = Complex::new(1.0, 0.0);
1067        acc = acc * z + Complex::new(a, 0.0);
1068        acc = acc * z + Complex::new(b, 0.0);
1069        acc = acc * z + Complex::new(c, 0.0);
1070        acc * z + Complex::new(d, 0.0)
1071    };
1072    // Durand–Kerner: four roots seeded on a circle, iterated to convergence.
1073    let seed = Complex::new(0.4, 0.9);
1074    let mut r = [
1075        Complex::new(1.0, 0.0),
1076        seed,
1077        seed * seed,
1078        seed * seed * seed,
1079    ];
1080    for _ in 0..100 {
1081        let mut max_step = 0.0_f64;
1082        for i in 0..4 {
1083            let mut denom = Complex::new(1.0, 0.0);
1084            for j in 0..4 {
1085                if i != j {
1086                    denom = denom * (r[i] - r[j]);
1087                }
1088            }
1089            if denom.norm() < 1e-300 {
1090                continue;
1091            }
1092            let step = eval(r[i]) / denom;
1093            r[i] = r[i] - step;
1094            max_step = max_step.max(step.norm());
1095        }
1096        if max_step < 1e-14 {
1097            break;
1098        }
1099    }
1100    // Keep roots with negligible imaginary part AND a small REAL-polynomial
1101    // residual — Durand–Kerner stops after a fixed iteration cap whether or not
1102    // it converged, so a non-converged iterate could otherwise be returned as a
1103    // spurious root. Evaluate the monic quartic at each candidate (real part) and
1104    // keep only |p(x)| below a magnitude-scaled tolerance; de-dup near-equal
1105    // roots (a double root converges to two near-identical iterates).
1106    let p_real = |x: f64| -> f64 { (((x + a) * x + b) * x + c) * x + d };
1107    let mut out: Vec<f64> = Vec::new();
1108    for z in r {
1109        if z.im.abs() >= 1e-7 {
1110            continue;
1111        }
1112        let x = z.re;
1113        // Residual tolerance scales with the polynomial's coefficient magnitude
1114        // and |x|^4 so large-coefficient quartics are not over-rejected.
1115        let scale = 1.0 + a.abs() + b.abs() + c.abs() + d.abs() + x.abs().powi(4);
1116        if p_real(x).abs() > 1e-6 * scale {
1117            continue;
1118        }
1119        if out.iter().any(|&y| (y - x).abs() < 1e-9 * (1.0 + x.abs())) {
1120            continue;
1121        }
1122        out.push(x);
1123    }
1124    out
1125}
1126
1127/// Real roots of `a x³ + b x² + c x + d` (Cardano), with quadratic fallback.
1128fn real_roots_cubic(a: f64, b: f64, c: f64, d: f64) -> Vec<f64> {
1129    if a.abs() < 1e-14 {
1130        return real_roots_quadratic(b, c, d);
1131    }
1132    // Depressed cubic t³ + p t + q via x = t − b/(3a).
1133    let (b, c, d) = (b / a, c / a, d / a);
1134    let p = c - b * b / 3.0;
1135    let q = 2.0 * b * b * b / 27.0 - b * c / 3.0 + d;
1136    let shift = -b / 3.0;
1137    let disc = q * q / 4.0 + p * p * p / 27.0;
1138    if disc > 1e-14 {
1139        let sq = disc.sqrt();
1140        let u = (-q / 2.0 + sq).cbrt();
1141        let v = (-q / 2.0 - sq).cbrt();
1142        vec![u + v + shift]
1143    } else if disc < -1e-14 {
1144        // Three real roots (trigonometric).
1145        let m = 2.0 * (-p / 3.0).sqrt();
1146        let theta = (3.0 * q / (p * m)).clamp(-1.0, 1.0).acos() / 3.0;
1147        (0..3)
1148            .map(|k| {
1149                m.mul_add(
1150                    (theta - 2.0 * std::f64::consts::PI * f64::from(k) / 3.0).cos(),
1151                    shift,
1152                )
1153            })
1154            .collect()
1155    } else {
1156        // Repeated roots.
1157        let u = (-q / 2.0).cbrt();
1158        vec![2.0 * u + shift, -u + shift]
1159    }
1160}
1161
1162/// Real roots of `a x² + b x + c`, with linear fallback.
1163fn real_roots_quadratic(a: f64, b: f64, c: f64) -> Vec<f64> {
1164    if a.abs() < 1e-14 {
1165        if b.abs() < 1e-14 {
1166            return Vec::new();
1167        }
1168        return vec![-c / b];
1169    }
1170    let disc = b * b - 4.0 * a * c;
1171    if disc < 0.0 {
1172        Vec::new()
1173    } else {
1174        let sq = disc.sqrt();
1175        vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
1176    }
1177}
1178
1179/// Minimal complex number for the quartic root finder.
1180#[derive(Clone, Copy)]
1181struct Complex {
1182    re: f64,
1183    im: f64,
1184}
1185
1186impl Complex {
1187    const fn new(re: f64, im: f64) -> Self {
1188        Self { re, im }
1189    }
1190    fn norm(self) -> f64 {
1191        self.re.hypot(self.im)
1192    }
1193}
1194
1195impl std::ops::Add for Complex {
1196    type Output = Self;
1197    fn add(self, o: Self) -> Self {
1198        Self::new(self.re + o.re, self.im + o.im)
1199    }
1200}
1201
1202impl std::ops::Sub for Complex {
1203    type Output = Self;
1204    fn sub(self, o: Self) -> Self {
1205        Self::new(self.re - o.re, self.im - o.im)
1206    }
1207}
1208
1209impl std::ops::Mul for Complex {
1210    type Output = Self;
1211    fn mul(self, o: Self) -> Self {
1212        Self::new(
1213            self.re.mul_add(o.re, -(self.im * o.im)),
1214            self.re.mul_add(o.im, self.im * o.re),
1215        )
1216    }
1217}
1218
1219impl std::ops::Div for Complex {
1220    type Output = Self;
1221    fn div(self, o: Self) -> Self {
1222        let den = o.re.mul_add(o.re, o.im * o.im);
1223        Self::new(
1224            self.re.mul_add(o.re, self.im * o.im) / den,
1225            self.im.mul_add(o.re, -(self.re * o.im)) / den,
1226        )
1227    }
1228}
1229
1230/// Build intersection curves from a collection of ordered 3D points.
1231///
1232/// If there are enough points, fits a NURBS curve through them.
1233fn build_curves_from_points(
1234    points_3d: &[Point3],
1235    ipoints: Vec<IntersectionPoint>,
1236) -> Result<Vec<IntersectionCurve>, MathError> {
1237    if points_3d.len() < 2 {
1238        return Ok(vec![]);
1239    }
1240
1241    let degree = 3.min(points_3d.len() - 1);
1242    let curve = interpolate(points_3d, degree)?;
1243    Ok(vec![IntersectionCurve {
1244        curve,
1245        points: ipoints,
1246    }])
1247}
1248
1249// -- Analytic-Analytic Intersection -------------------------------------------
1250
1251/// Intersect two analytic surfaces using a general marching approach.
1252///
1253/// Seeds intersection points by sampling both parameter spaces on a grid,
1254/// then marches along the intersection curve using the cross product of
1255/// the two surface normals as the tangent direction.
1256///
1257/// # Errors
1258///
1259/// Returns an error if curve fitting fails.
1260#[allow(
1261    clippy::cast_precision_loss,
1262    clippy::too_many_lines,
1263    clippy::similar_names,
1264    clippy::unnecessary_wraps,
1265    clippy::type_complexity
1266)]
1267pub fn intersect_analytic_analytic(
1268    a: AnalyticSurface<'_>,
1269    b: AnalyticSurface<'_>,
1270    grid_res: usize,
1271) -> Result<Vec<IntersectionCurve>, MathError> {
1272    intersect_analytic_analytic_bounded(a, b, grid_res, None, None)
1273}
1274
1275/// Intersect two analytic surfaces with optional v-range overrides.
1276///
1277/// When `v_range_hint_a` or `v_range_hint_b` is `Some((min, max))`, the
1278/// marching algorithm searches that v-range instead of the hardcoded default.
1279/// This is essential for cylinders and cones whose default v-range is small
1280/// (-1..1 or 0.01..2) but whose actual face may extend much further.
1281///
1282/// # Errors
1283///
1284/// Returns `MathError` if algebraic intersection fails or marching diverges.
1285pub fn intersect_analytic_analytic_bounded(
1286    a: AnalyticSurface<'_>,
1287    b: AnalyticSurface<'_>,
1288    grid_res: usize,
1289    v_range_hint_a: Option<(f64, f64)>,
1290    v_range_hint_b: Option<(f64, f64)>,
1291) -> Result<Vec<IntersectionCurve>, MathError> {
1292    // Try algebraic specialization for known surface pairs before falling
1293    // back to the general marching approach.
1294    if let Some(result) = try_algebraic_intersection(&a, &b, v_range_hint_a, v_range_hint_b)? {
1295        return Ok(result);
1296    }
1297
1298    let (surf_a, norm_a, u_range_a, default_v_a) = surface_closures(&a);
1299    let (surf_b, norm_b, u_range_b, default_v_b) = surface_closures(&b);
1300    let v_range_a = v_range_hint_a.unwrap_or(default_v_a);
1301    let v_range_b = v_range_hint_b.unwrap_or(default_v_b);
1302
1303    // Compute characteristic surface dimensions for adaptive parameters.
1304    let diag_a = {
1305        let p00 = surf_a(u_range_a.0, v_range_a.0);
1306        let p11 = surf_a(u_range_a.1, v_range_a.1);
1307        (p00 - p11).length()
1308    };
1309    let diag_b = {
1310        let p00 = surf_b(u_range_b.0, v_range_b.0);
1311        let p11 = surf_b(u_range_b.1, v_range_b.1);
1312        (p00 - p11).length()
1313    };
1314    let char_size = diag_a.min(diag_b).max(0.1);
1315
1316    // Sample surface A on a grid. For each grid point, project it
1317    // analytically onto surface B to find the closest point, then check
1318    // if the distance is below threshold (indicating near-intersection).
1319    #[allow(clippy::type_complexity)]
1320    let mut seeds: Vec<(Point3, (f64, f64), (f64, f64))> = Vec::new();
1321    // Coarse threshold scales with the surface size — the distance from
1322    // a grid point on A to its projection on B can be large even near
1323    // the intersection (e.g., sphere R=2 and cylinder R=1 → gap ≈ 1).
1324    let seed_threshold = diag_a.max(diag_b).max(1.0) * 0.5;
1325    let mut min_dist = f64::INFINITY;
1326
1327    #[allow(clippy::cast_precision_loss)]
1328    for ia in 0..grid_res {
1329        for ja in 0..grid_res {
1330            let ua =
1331                u_range_a.0 + (u_range_a.1 - u_range_a.0) * (ia as f64 + 0.5) / (grid_res as f64);
1332            let va =
1333                v_range_a.0 + (v_range_a.1 - v_range_a.0) * (ja as f64 + 0.5) / (grid_res as f64);
1334
1335            let pa = surf_a(ua, va);
1336
1337            // Analytically project onto surface B.
1338            let (ub, vb) = project_analytic(&b, pa, u_range_b, v_range_b);
1339            let pb = surf_b(ub, vb);
1340            let dist = (pa - pb).length();
1341            min_dist = min_dist.min(dist);
1342
1343            if dist < seed_threshold {
1344                // Use the coarse seed directly. The marching algorithm
1345                // corrects positions at each step via projection, so seeds
1346                // don't need to be on the exact intersection — they just
1347                // need to be close enough for the marcher to converge.
1348                let mid = Point3::new(
1349                    (pa.x() + pb.x()) * 0.5,
1350                    (pa.y() + pb.y()) * 0.5,
1351                    (pa.z() + pb.z()) * 0.5,
1352                );
1353                seeds.push((mid, (ua, va), (ub, vb)));
1354            }
1355        }
1356    }
1357
1358    // Cheap rejection: the grid samples surface A; the closest sample's
1359    // distance to B lower-bounds how near the two bounded patches come. A
1360    // transversal crossing puts a sample within ~one grid cell of it
1361    // (distance on the order of a cell), so if even the nearest sample is
1362    // several cells away the patches cannot cross — skip the expensive
1363    // marching and return empty. Result-preserving: non-crossing pairs
1364    // already march to nothing, just slowly (this is the gridfinity lip's
1365    // ~80 inner-wall × outer-wall pairs that dominate pavefiller time).
1366    let reject_dist = (char_size / grid_res as f64) * 3.0;
1367    if min_dist > reject_dist {
1368        return Ok(vec![]);
1369    }
1370
1371    if seeds.is_empty() {
1372        return Ok(vec![]);
1373    }
1374
1375    // Aggressively deduplicate seeds — we only need 1-2 per intersection
1376    // branch. Scale dedup radius to ~2% of characteristic surface size
1377    // (at least 10× the march step size) to avoid redundant marches.
1378    let march_step = (char_size * 0.02).clamp(0.005, 0.5);
1379    let dedup_radius = march_step * 10.0;
1380    let mut unique_seeds = Vec::new();
1381    for seed in &seeds {
1382        let dominated = unique_seeds
1383            .iter()
1384            .any(|s: &(Point3, (f64, f64), (f64, f64))| (s.0 - seed.0).length() < dedup_radius);
1385        if !dominated {
1386            unique_seeds.push(*seed);
1387        }
1388    }
1389
1390    // March from each seed.
1391    let mut curves = Vec::new();
1392    let mut used_seeds = vec![false; unique_seeds.len()];
1393
1394    for si in 0..unique_seeds.len() {
1395        if used_seeds[si] {
1396            continue;
1397        }
1398        used_seeds[si] = true;
1399
1400        let march_result = march_analytic_intersection(
1401            &a,
1402            &b,
1403            surf_a.as_ref(),
1404            norm_a.as_ref(),
1405            surf_b.as_ref(),
1406            norm_b.as_ref(),
1407            unique_seeds[si].0,
1408            u_range_a,
1409            v_range_a,
1410            u_range_b,
1411            v_range_b,
1412            march_step,
1413            is_u_periodic(&a),
1414            is_u_periodic(&b),
1415        );
1416
1417        if march_result.len() >= 2 {
1418            for (sj, other) in unique_seeds.iter().enumerate() {
1419                if !used_seeds[sj]
1420                    && march_result
1421                        .iter()
1422                        .any(|p| (*p - other.0).length() < dedup_radius)
1423                {
1424                    used_seeds[sj] = true;
1425                }
1426            }
1427
1428            let ipts: Vec<IntersectionPoint> = march_result
1429                .iter()
1430                .map(|&pt| IntersectionPoint {
1431                    point: pt,
1432                    param1: (0.0, 0.0),
1433                    param2: (0.0, 0.0),
1434                })
1435                .collect();
1436
1437            let degree = 3.min(march_result.len() - 1);
1438            if let Ok(curve) = interpolate(&march_result, degree) {
1439                curves.push(IntersectionCurve {
1440                    curve,
1441                    points: ipts,
1442                });
1443            }
1444        }
1445    }
1446
1447    Ok(curves)
1448}
1449
1450/// Try algebraic (closed-form or semi-algebraic) intersection for known
1451/// surface pairs before falling back to general marching.
1452///
1453/// Returns `Some(curves)` if a specialized method exists, `None` otherwise.
1454///
1455/// Currently handles:
1456/// - **Sphere-sphere**: intersection is a circle (plane through the two centers)
1457/// - **Coaxial cylinders**: same axis → circle(s) or empty
1458/// - **Sphere-cylinder**: reduce to quadratic in one parameter
1459#[allow(clippy::too_many_lines)]
1460fn try_algebraic_intersection(
1461    a: &AnalyticSurface<'_>,
1462    b: &AnalyticSurface<'_>,
1463    v_range_a: Option<(f64, f64)>,
1464    v_range_b: Option<(f64, f64)>,
1465) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1466    match (a, b) {
1467        (AnalyticSurface::Cone(cone), AnalyticSurface::Cylinder(cyl)) => {
1468            algebraic_parallel_cone_cylinder(cone, cyl, v_range_a, v_range_b)
1469        }
1470        (AnalyticSurface::Cylinder(cyl), AnalyticSurface::Cone(cone)) => {
1471            algebraic_parallel_cone_cylinder(cone, cyl, v_range_b, v_range_a)
1472        }
1473        (AnalyticSurface::Sphere(s1), AnalyticSurface::Sphere(s2)) => {
1474            algebraic_sphere_sphere(s1, s2).map(Some)
1475        }
1476        (AnalyticSurface::Cylinder(c1), AnalyticSurface::Cylinder(c2)) => {
1477            let axis_dot = c1.axis().dot(c2.axis()).abs();
1478            if axis_dot > 1.0 - 1e-10 {
1479                // Axes are parallel — check if they're the same line.
1480                let delta = c2.origin() - c1.origin();
1481                let delta_vec = Vec3::new(delta.x(), delta.y(), delta.z());
1482                let along = delta_vec.dot(c1.axis());
1483                let perp = (delta_vec - c1.axis() * along).length();
1484                if perp < 1e-8 {
1485                    // Coaxial: same axis, different radii → no intersection
1486                    // (unless equal radius → degenerate overlap, skip)
1487                    if (c1.radius() - c2.radius()).abs() < 1e-8 {
1488                        return Ok(None); // Overlapping — let marcher handle
1489                    }
1490                    return Ok(Some(vec![])); // Coaxial, different radii
1491                }
1492            }
1493            // Non-coaxial: algebraic quadratic in v.
1494            algebraic_cylinder_cylinder(c1, c2)
1495        }
1496        // Sphere-cylinder (both orderings).
1497        (AnalyticSurface::Sphere(s), AnalyticSurface::Cylinder(c)) => {
1498            algebraic_sphere_cylinder(s, c, true)
1499        }
1500        (AnalyticSurface::Cylinder(c), AnalyticSurface::Sphere(s)) => {
1501            algebraic_sphere_cylinder(s, c, false)
1502        }
1503        (AnalyticSurface::Cone(c1), AnalyticSurface::Cone(c2)) => algebraic_cone_cone(c1, c2),
1504        (AnalyticSurface::Torus(t), AnalyticSurface::Cylinder(c)) => {
1505            Ok(parallel_axis_torus_cylinder(t, c, true))
1506        }
1507        (AnalyticSurface::Cylinder(c), AnalyticSurface::Torus(t)) => {
1508            Ok(parallel_axis_torus_cylinder(t, c, false))
1509        }
1510        _ => Ok(None),
1511    }
1512}
1513
1514/// A torus and a cylinder whose axes are parallel but distinct (a drill
1515/// through a ring parallel to its axis), traced along the cylinder's
1516/// rulings. A ruling stays at one distance `ρ` from the torus axis, so it
1517/// meets the tube where `(ρ − R)² + z² = r²`: a quadratic in its axial
1518/// parameter. `None` for any other pair (tilted or coaxial axes) and when
1519/// the sampling misses a window narrower than itself.
1520fn parallel_axis_torus_cylinder(
1521    torus: &ToroidalSurface,
1522    cyl: &CylindricalSurface,
1523    torus_first: bool,
1524) -> Option<Vec<IntersectionCurve>> {
1525    let axis = torus.z_axis();
1526    let along = cyl.axis().dot(axis);
1527    if along.abs() < 1.0 - 1e-10 {
1528        return None;
1529    }
1530    let offset = cyl.origin() - torus.center();
1531    if (offset - axis * offset.dot(axis)).length() < Tolerance::new().linear {
1532        return None;
1533    }
1534    let (major, minor) = (torus.major_radius(), torus.minor_radius());
1535    let roots = |u: f64| {
1536        let q = cyl.evaluate(u, 0.0) - torus.center();
1537        let height = q.dot(axis);
1538        let rho = (q - axis * height).length();
1539        let reach = minor * minor - (rho - major) * (rho - major);
1540        ruling_quadratic(1.0, 2.0 * along.signum() * height, height * height - reach)
1541    };
1542    let samples = ruling_samples(cyl, &roots);
1543    let loops = if samples.iter().all(Option::is_some) {
1544        closed_ruling_loops(&samples)
1545    } else {
1546        partial_ruling_loops(cyl, &roots, &samples)
1547    };
1548    if loops.is_empty() {
1549        return None;
1550    }
1551    Some(fit_ruling_loops(&loops, |p| {
1552        in_order(torus.project_point(p), cyl.project_point(p), torus_first)
1553    }))
1554}
1555
1556/// Exact coaxial cone-cone intersection: returns the shared circle.
1557///
1558/// Two cones that share an axis are concentric circles at every axial
1559/// station, so they meet only where their radii are equal. Each cone's
1560/// radius is linear in the axial coordinate `t` (measured along the shared
1561/// axis from cone 1's apex): `r1 = m1·t` and `r2 = m2·σ·(t − d2)`, where
1562/// `m_i = cot(half_angle_i)`, `σ = sign(axis2·axis1)`, and `d2` is cone 2's
1563/// apex position in that coordinate. Equating gives a single crossing `t*`
1564/// → one circle (the shared rim). The general marcher mishandles this case:
1565/// at the radii-crossing the surfaces are nearly tangent, so a grid-seeded
1566/// march fragments the clean circle into dozens of degenerate micro-curves.
1567///
1568/// Returns `Some(vec![circle])` for a genuine crossing, `Some(vec![])` when
1569/// the cones do not meet (parallel radius lines or a crossing on the wrong
1570/// nappe), and `None` for the identical-cone overlap or a degenerate
1571/// (near-flat) cone — both of which fall through to the general path.
1572/// Parallel-but-offset axes with equal half-angle tangents reduce to a
1573/// radical-plane conic (`offset_parallel_cone_cone`); other offset
1574/// configurations defer to the marcher with `None`.
1575///
1576/// # Errors
1577///
1578/// Returns [`MathError`] if the shared-rim `Circle3D` cannot be constructed
1579/// (e.g. a non-finite center or radius from a malformed cone).
1580pub fn exact_cone_cone(
1581    c1: &ConicalSurface,
1582    c2: &ConicalSurface,
1583) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1584    let axis = c1.axis();
1585    let axis2 = c2.axis();
1586
1587    // Coaxial check: parallel axes and the second apex lies on the first axis.
1588    if axis.dot(axis2).abs() < 1.0 - 1e-10 {
1589        return Ok(None); // Non-coaxial: quartic curve, let the marcher handle.
1590    }
1591    let apex1 = c1.apex();
1592    let apex2 = c2.apex();
1593    let delta = apex2 - apex1;
1594    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1595    let along = delta_v.dot(axis);
1596    if (delta_v - axis * along).length() > 1e-8 {
1597        return offset_parallel_cone_cone(c1, c2);
1598    }
1599
1600    let (s1, s2) = (c1.half_angle().sin(), c2.half_angle().sin());
1601    if s1.abs() < 1e-12 || s2.abs() < 1e-12 {
1602        return Ok(None); // Degenerate (near-flat) cone.
1603    }
1604    let m1 = c1.half_angle().cos() / s1;
1605    let m2 = c2.half_angle().cos() / s2;
1606    let sigma = if axis.dot(axis2) >= 0.0 { 1.0 } else { -1.0 };
1607    let d2 = along; // apex2 position along `axis`, measured from apex1.
1608
1609    let denom = m1 - m2 * sigma;
1610    if denom.abs() < 1e-12 {
1611        // Parallel radius lines: identical cones (coincident apex, same opening)
1612        // overlap — defer to the general/same-domain path; otherwise no meeting.
1613        if sigma > 0.0 && d2.abs() < 1e-9 {
1614            return Ok(None);
1615        }
1616        return Ok(Some(vec![]));
1617    }
1618
1619    let t_star = (-m2 * sigma * d2) / denom;
1620    let radius = m1 * t_star;
1621    if radius < 1e-12 {
1622        return Ok(Some(vec![])); // Crossing on the wrong nappe / no real circle.
1623    }
1624
1625    let center = Point3::new(
1626        apex1.x() + axis.x() * t_star,
1627        apex1.y() + axis.y() * t_star,
1628        apex1.z() + axis.z() * t_star,
1629    );
1630    let circle = Circle3D::new(center, axis, radius)?;
1631    Ok(Some(vec![ExactIntersectionCurve::Circle(circle)]))
1632}
1633
1634/// Parallel-axis (or anti-parallel), offset-apex cones with equal half-angle
1635/// tangents: subtracting the two quadric equations cancels both the radial
1636/// and the axial quadratic terms (their coefficients depend only on
1637/// `tan²(half_angle)`), so every intersection point lies on a plane — the
1638/// degenerate member of the quadric pencil — and plane ∩ cone is an exact
1639/// conic. The gridfinity spacer lip fuse hits this exactly: opposed 45°
1640/// corner cones offset 0.25mm, which the marcher shreds into ~64 closed
1641/// micro-loops per pair (#1570). Unequal angles keep a genuine quadratic
1642/// term, and an unbounded section (hyperbola/parabola) has no closed-form
1643/// win over the marcher — both defer with `None`.
1644fn offset_parallel_cone_cone(
1645    c1: &ConicalSurface,
1646    c2: &ConicalSurface,
1647) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1648    if c1.half_angle().sin().abs() < 1e-12 || c2.half_angle().sin().abs() < 1e-12 {
1649        return Ok(None); // Degenerate (near-flat) cone, as in the coaxial path.
1650    }
1651    let t1 = c1.half_angle().tan();
1652    let t2 = c2.half_angle().tan();
1653    if !t1.is_finite() || !t2.is_finite() {
1654        return Ok(None);
1655    }
1656    if (t1 - t2).abs() > 1e-9 * (1.0 + t1.abs().max(t2.abs())) {
1657        return Ok(None);
1658    }
1659
1660    let w = c1.axis();
1661    let apex1 = c1.apex();
1662    let apex2 = c2.apex();
1663    let delta = apex2 - apex1;
1664    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1665    let s = delta_v.dot(w);
1666    let tm = 0.5 * (t1 + t2);
1667    let k = 1.0 + tm * tm;
1668
1669    // In the apex1 frame each cone is |P|² − k(P·w)² = 0 (shifted by δ for
1670    // cone 2; the axis SIGN drops out since only (P·w)² appears). Their
1671    // difference: P·(2δ − 2ksw) = |δ|² − ks².
1672    let n = (delta_v - w * (k * s)) * 2.0;
1673    let n_len = n.length();
1674    if n_len < 1e-12 {
1675        return Ok(None);
1676    }
1677    let n_hat = n * (1.0 / n_len);
1678    let d = (dot_np(n, apex1) + delta_v.dot(delta_v) - k * s * s) / n_len;
1679
1680    // `exact_plane_cone` already rejects sections on cone 1's phantom nappe;
1681    // cone 2's nappe must be checked here. A conic on the shared quadric
1682    // pencil cannot cross between nappes except exactly through apex 2, so
1683    // sampled quarter-points either all pass or all fail; a mixed verdict
1684    // means an apex-touching degeneracy — defer to the marcher.
1685    let axis2 = c2.axis();
1686    let scale = 1.0 + delta_v.length();
1687    let mut out = Vec::new();
1688    for curve in exact_plane_cone(c1, n_hat, d)? {
1689        let samples: Vec<Point3> = match &curve {
1690            ExactIntersectionCurve::Circle(c) => (0..4)
1691                .map(|i| crate::traits::ParametricCurve::evaluate(c, TAU * f64::from(i) / 4.0))
1692                .collect(),
1693            ExactIntersectionCurve::Ellipse(e) => (0..4)
1694                .map(|i| crate::traits::ParametricCurve::evaluate(e, TAU * f64::from(i) / 4.0))
1695                .collect(),
1696            ExactIntersectionCurve::Points(_) => return Ok(None),
1697        };
1698        let on_real_nappe = |p: &Point3| {
1699            let rel = *p - apex2;
1700            Vec3::new(rel.x(), rel.y(), rel.z()).dot(axis2) >= -1e-9 * scale
1701        };
1702        let hits = samples.iter().filter(|p| on_real_nappe(p)).count();
1703        match hits {
1704            0 => {}
1705            4 => out.push(curve),
1706            _ => return Ok(None),
1707        }
1708    }
1709    Ok(Some(out))
1710}
1711
1712/// Exact coaxial cone-cylinder intersection: returns the shared circle.
1713///
1714/// A cone and a cylinder sharing an axis are concentric circles at every
1715/// axial station, so they meet only where the cone's radius equals the
1716/// cylinder's. The cone radius is linear in the axial coordinate `t` from its
1717/// apex (`r = m·t`, `m = cot(half_angle)`), the cylinder radius is the
1718/// constant `R`, so `m·t = R` gives a single crossing `t*` → one circle. This
1719/// is the gridfinity lip's top knife edge (inner tapered corner = cone, outer
1720/// corner = cylinder, concentric, radii matching at `Z_PEAK`); the general
1721/// marcher fragments that near-tangent contact into dozens of degenerate
1722/// micro-curves.
1723///
1724/// Returns `Some(vec![circle])` for a genuine crossing, `Some(vec![])` when
1725/// the crossing degenerates to the apex, and `None` (defer to the marcher)
1726/// when the surfaces are not coaxial or the cone is near-flat / near-axial.
1727///
1728/// # Errors
1729///
1730/// Returns [`MathError`] if the shared `Circle3D` cannot be constructed.
1731pub fn exact_cone_cylinder(
1732    cone: &ConicalSurface,
1733    cyl: &CylindricalSurface,
1734) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1735    let axis = cone.axis();
1736    let cyl_axis = cyl.axis();
1737
1738    // Coaxial check: parallel axes and the cone apex on the cylinder's axis.
1739    if axis.dot(cyl_axis).abs() < 1.0 - 1e-10 {
1740        return Ok(None);
1741    }
1742    let apex = cone.apex();
1743    let delta = apex - cyl.origin();
1744    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1745    let along = delta_v.dot(cyl_axis);
1746    if (delta_v - cyl_axis * along).length() > 1e-8 {
1747        return Ok(None);
1748    }
1749
1750    let s = cone.half_angle().sin();
1751    if s.abs() < 1e-12 {
1752        return Ok(None); // near-flat cone.
1753    }
1754    let m = cone.half_angle().cos() / s; // dr/dt along the cone axis.
1755    if m.abs() < 1e-12 {
1756        return Ok(None); // near-axial cone: radius ~constant.
1757    }
1758
1759    let t_star = cyl.radius() / m; // where the cone radius m·t equals R.
1760    if t_star.abs() < 1e-12 {
1761        return Ok(Some(vec![])); // crossing at the apex — no real circle.
1762    }
1763    let center = Point3::new(
1764        apex.x() + axis.x() * t_star,
1765        apex.y() + axis.y() * t_star,
1766        apex.z() + axis.z() * t_star,
1767    );
1768    let circle = Circle3D::new(center, axis, cyl.radius())?;
1769    Ok(Some(vec![ExactIntersectionCurve::Circle(circle)]))
1770}
1771
1772/// Algebraic cone-cone intersection (NURBS form for the general bounded
1773/// path). Delegates to [`exact_cone_cone`] and samples each exact conic
1774/// (coaxial circle or offset-parallel radical-plane ellipse) into an
1775/// interpolated NURBS `IntersectionCurve`, mirroring the
1776/// sphere-cylinder algebraic path. phase FF prefers the exact circle form
1777/// directly (so the section edge links to the coincident boundary), but a
1778/// caller of `intersect_analytic_analytic_bounded` still gets one clean
1779/// curve instead of the marcher's fragments.
1780fn algebraic_cone_cone(
1781    c1: &ConicalSurface,
1782    c2: &ConicalSurface,
1783) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1784    let Some(exacts) = exact_cone_cone(c1, c2)? else {
1785        return Ok(None);
1786    };
1787    let mut curves = Vec::new();
1788    for exact in exacts {
1789        let n_samples = 33;
1790        let mut positions = Vec::with_capacity(n_samples);
1791        let mut points = Vec::with_capacity(n_samples);
1792        #[allow(clippy::cast_precision_loss)]
1793        for i in 0..n_samples {
1794            let theta = TAU * i as f64 / (n_samples - 1) as f64;
1795            let pt = match &exact {
1796                ExactIntersectionCurve::Circle(circle) => {
1797                    crate::traits::ParametricCurve::evaluate(circle, theta)
1798                }
1799                ExactIntersectionCurve::Ellipse(ellipse) => {
1800                    crate::traits::ParametricCurve::evaluate(ellipse, theta)
1801                }
1802                ExactIntersectionCurve::Points(_) => break,
1803            };
1804            positions.push(pt);
1805            points.push(IntersectionPoint {
1806                point: pt,
1807                param1: (0.0, 0.0),
1808                param2: (0.0, 0.0),
1809            });
1810        }
1811        if positions.is_empty() {
1812            continue;
1813        }
1814        let degree = 3.min(positions.len() - 1);
1815        let curve = interpolate(&positions, degree)?;
1816        curves.push(IntersectionCurve { curve, points });
1817    }
1818    Ok(Some(curves))
1819}
1820
1821/// Exact coaxial sphere-cylinder intersection: returns the shared circle(s).
1822///
1823/// A sphere of radius `R` centered at `C` and a cylinder of radius `r` whose
1824/// axis passes through `C` meet in concentric circles of radius `r` at the
1825/// axial stations where `sqrt(R² − z²) = r`, i.e. `z = ±sqrt(R² − r²)`
1826/// measured from `C` along the axis. A proper crossing yields two circles; a
1827/// tangent contact (`r = R`) yields one; a cylinder wider than the sphere, or
1828/// a non-coaxial configuration (quartic curve), yields none/defers.
1829///
1830/// Mirrors [`exact_cone_cylinder`] so phase FF can emit the section as an
1831/// exact `Circle3D` (which the closed-circle split + seam adoption recognise)
1832/// rather than the marcher's NURBS fragments.
1833///
1834/// Returns `Some(vec![..])` (0, 1, or 2 circles) for the coaxial case, and
1835/// `None` (defer to the general marcher) when the axes are not coaxial.
1836///
1837/// # Errors
1838///
1839/// Returns [`MathError`] if a shared `Circle3D` cannot be constructed.
1840pub fn exact_sphere_cylinder(
1841    sphere: &SphericalSurface,
1842    cyl: &CylindricalSurface,
1843) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1844    let sc = sphere.center();
1845    let r_sphere = sphere.radius();
1846    let co = cyl.origin();
1847    let axis = cyl.axis();
1848    let r_cyl = cyl.radius();
1849
1850    // Project sphere center onto the cylinder axis.
1851    let delta = sc - co;
1852    let delta_vec = Vec3::new(delta.x(), delta.y(), delta.z());
1853    let along = delta_vec.dot(axis);
1854    let perp_vec = delta_vec - axis * along;
1855    let d_perp = perp_vec.length();
1856
1857    // Non-coaxial sphere-cylinder intersections produce quartic curves;
1858    // defer those to the general marcher.
1859    if d_perp > 1e-7 {
1860        return Ok(None);
1861    }
1862
1863    // Coaxial: the sphere center lies on the cylinder axis. No real circle
1864    // when the cylinder is wider than the sphere or they are tangent-internal.
1865    if r_cyl > r_sphere + 1e-10 {
1866        return Ok(Some(vec![]));
1867    }
1868    let z_sq = r_sphere * r_sphere - r_cyl * r_cyl;
1869    if z_sq < 0.0 {
1870        return Ok(Some(vec![]));
1871    }
1872    let z = z_sq.sqrt();
1873
1874    // The sphere center projected onto the axis is the midpoint of the two
1875    // section circles, each offset by ±z along the axis with radius `r_cyl`.
1876    let center_axis_pt = Point3::new(
1877        co.x() + axis.x() * along,
1878        co.y() + axis.y() * along,
1879        co.z() + axis.z() * along,
1880    );
1881
1882    let mut circles = Vec::new();
1883    let offsets: &[f64] = if z < 1e-10 { &[0.0] } else { &[z, -z] };
1884    for &z_offset in offsets {
1885        let center = Point3::new(
1886            center_axis_pt.x() + axis.x() * z_offset,
1887            center_axis_pt.y() + axis.y() * z_offset,
1888            center_axis_pt.z() + axis.z() * z_offset,
1889        );
1890        let circle = Circle3D::new(center, axis, r_cyl)?;
1891        circles.push(ExactIntersectionCurve::Circle(circle));
1892    }
1893    Ok(Some(circles))
1894}
1895
1896/// Algebraic sphere-cylinder intersection (NURBS form for the general bounded
1897/// path). A coaxial pair delegates to [`exact_sphere_cylinder`] and samples
1898/// each exact circle into an interpolated NURBS `IntersectionCurve`. phase FF
1899/// prefers the exact circle form directly (so the section edge links to the
1900/// coincident boundary and the closed-circle splitter can carve the spherical
1901/// band), but a caller of `intersect_analytic_analytic_bounded` still gets
1902/// clean curves instead of the marcher's fragments. Any other pair is traced
1903/// along the cylinder's rulings ([`off_axis_sphere_cylinder`]).
1904fn algebraic_sphere_cylinder(
1905    sphere: &SphericalSurface,
1906    cyl: &CylindricalSurface,
1907    sphere_first: bool,
1908) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1909    let Some(exacts) = exact_sphere_cylinder(sphere, cyl)? else {
1910        return Ok(off_axis_sphere_cylinder(sphere, cyl, sphere_first));
1911    };
1912
1913    let mut curves = Vec::new();
1914    for exact in exacts {
1915        let ExactIntersectionCurve::Circle(circle) = exact else {
1916            continue;
1917        };
1918        let n_samples = 33;
1919        let mut points = Vec::with_capacity(n_samples);
1920        let mut positions = Vec::with_capacity(n_samples);
1921        #[allow(clippy::cast_precision_loss)]
1922        for i in 0..n_samples {
1923            let theta = TAU * i as f64 / (n_samples - 1) as f64;
1924            let pt = crate::traits::ParametricCurve::evaluate(&circle, theta);
1925            positions.push(pt);
1926            let (param1, param2) = in_order(
1927                sphere.project_point(pt),
1928                cyl.project_point(pt),
1929                sphere_first,
1930            );
1931            points.push(IntersectionPoint {
1932                point: pt,
1933                param1,
1934                param2,
1935            });
1936        }
1937        let degree = 3.min(positions.len() - 1);
1938        let curve = interpolate(&positions, degree)?;
1939        curves.push(IntersectionCurve { curve, points });
1940    }
1941
1942    Ok(Some(curves))
1943}
1944
1945/// A sphere and a cylinder whose axis misses the sphere's centre (a drill
1946/// entering a ball off its axis), traced along the cylinder's rulings: the
1947/// ruling `c(u) + v·a` meets the sphere where `v² + 2(q·a)·v + |q|² − R² = 0`,
1948/// with `q = c(u) − C`. When every ruling meets the sphere (the cylinder
1949/// passes wholly through it) the roots trace an entry and an exit loop;
1950/// otherwise each window of meeting rulings carries one loop. `Some(empty)`
1951/// when the two cannot meet, `None` when the sampling misses a window
1952/// narrower than itself.
1953fn off_axis_sphere_cylinder(
1954    sphere: &SphericalSurface,
1955    cyl: &CylindricalSurface,
1956    sphere_first: bool,
1957) -> Option<Vec<IntersectionCurve>> {
1958    let (centre, radius) = (sphere.center(), sphere.radius());
1959    let axis = cyl.axis();
1960    let offset = centre - cyl.origin();
1961    let axis_distance = (offset - axis * offset.dot(axis)).length();
1962    let lin_tol = Tolerance::new().linear;
1963    if axis_distance > radius + cyl.radius() + lin_tol
1964        || axis_distance + radius < cyl.radius() - lin_tol
1965    {
1966        return Some(Vec::new());
1967    }
1968    let roots = |u: f64| {
1969        let q = cyl.evaluate(u, 0.0) - centre;
1970        ruling_quadratic(1.0, 2.0 * q.dot(axis), q.dot(q) - radius * radius)
1971    };
1972    let samples = ruling_samples(cyl, &roots);
1973    let loops = if samples.iter().all(Option::is_some) {
1974        closed_ruling_loops(&samples)
1975    } else {
1976        partial_ruling_loops(cyl, &roots, &samples)
1977    };
1978    if loops.is_empty() {
1979        return None;
1980    }
1981    Some(fit_ruling_loops(&loops, |p| {
1982        in_order(sphere.project_point(p), cyl.project_point(p), sphere_first)
1983    }))
1984}
1985
1986/// Parameters on the pair's first and second surfaces, from those on `a`
1987/// and `b` and whether `a` came first.
1988const fn in_order(a: (f64, f64), b: (f64, f64), a_first: bool) -> ((f64, f64), (f64, f64)) {
1989    if a_first { (a, b) } else { (b, a) }
1990}
1991
1992/// Algebraic cylinder-cylinder intersection for non-coaxial cylinders.
1993///
1994/// For two cylinders with axes that are NOT parallel, the intersection
1995/// consists of up to two closed space curves. These are found by
1996/// parameterizing one cylinder's angular coordinate `u ∈ [0, 2π]` and
1997/// solving a quadratic in the axial parameter `v` to find where each
1998/// "ring" of cylinder A sits on cylinder B.
1999///
2000/// The quadratic is:
2001///   `v²·(1 - α²) + 2v·(q·a₁ - α·q·a₂) + (|q|² - (q·a₂)² - r₂²) = 0`
2002/// where `α = a₁·a₂`, `q(u)` is the radial point on cylinder 1 minus
2003/// cylinder 2's origin, `a₁`/`a₂` are the cylinder axes, and `r₂` is
2004/// cylinder 2's radius.
2005#[allow(clippy::too_many_lines, clippy::unnecessary_wraps)]
2006fn algebraic_cylinder_cylinder(
2007    c1: &CylindricalSurface,
2008    c2: &CylindricalSurface,
2009) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
2010    let alpha = c1.axis().dot(c2.axis());
2011    let a_coeff = 1.0 - alpha * alpha;
2012
2013    // Should only be called for non-parallel axes.
2014    if a_coeff.abs() < 1e-12 {
2015        return Ok(None);
2016    }
2017
2018    let r1 = c1.radius();
2019    let r2 = c2.radius();
2020    let o1 = c1.origin();
2021    let o2 = c2.origin();
2022    let a1 = c1.axis();
2023    let a2 = c2.axis();
2024
2025    // Separation check: distance between axes vs sum of radii.
2026    // Closest approach of two skew lines:
2027    let delta = Vec3::new(o1.x() - o2.x(), o1.y() - o2.y(), o1.z() - o2.z());
2028    let cross = a1.cross(a2);
2029    let cross_len = cross.length();
2030    if cross_len > 1e-12 {
2031        let axis_dist = delta.dot(cross).abs() / cross_len;
2032        if axis_dist > r1 + r2 + Tolerance::new().linear {
2033            return Ok(Some(vec![])); // No intersection
2034        }
2035    }
2036
2037    // Solve every ruling of one cylinder against the other. When EVERY
2038    // ruling of the swept cylinder meets the other (the thinner of two
2039    // crossing tubes), the two roots trace the curve's two closed loops.
2040    // Swept the other way only a window of rulings meets, and each root
2041    // traces an open arc of one loop.
2042    let roots = |sweep: &CylindricalSurface, other: &CylindricalSurface| {
2043        let (o, a, radius) = (other.origin(), other.axis(), other.radius());
2044        let alpha = sweep.axis().dot(a);
2045        let quad = 1.0 - alpha * alpha;
2046        let (axis, sweep) = (sweep.axis(), sweep.clone());
2047        move |u: f64| {
2048            let q = sweep.evaluate(u, 0.0) - o;
2049            let (q_a1, q_a2) = (q.dot(axis), q.dot(a));
2050            let b = 2.0 * (q_a1 - alpha * q_a2);
2051            let c = q.dot(q) - q_a2 * q_a2 - radius * radius;
2052            ruling_quadratic(quad, b, c)
2053        }
2054    };
2055    let (roots1, roots2) = (roots(c1, c2), roots(c2, c1));
2056    let samples1 = ruling_samples(c1, &roots1);
2057    let loops = if samples1.iter().all(Option::is_some) {
2058        closed_ruling_loops(&samples1)
2059    } else {
2060        let samples2 = ruling_samples(c2, &roots2);
2061        if samples2.iter().all(Option::is_some) {
2062            closed_ruling_loops(&samples2)
2063        } else if samples1.iter().any(Option::is_some) {
2064            partial_ruling_loops(c1, &roots1, &samples1)
2065        } else {
2066            partial_ruling_loops(c2, &roots2, &samples2)
2067        }
2068    };
2069    if loops.is_empty() {
2070        return Ok(None);
2071    }
2072    Ok(Some(fit_ruling_loops(&loops, |p| {
2073        (c1.project_point(p), c2.project_point(p))
2074    })))
2075}
2076
2077/// Rulings sampled around a swept cylinder, half a step off u = 0 so the
2078/// branches of a self-touching curve (equal crossing cylinders) do not share
2079/// a sample.
2080const RULING_SAMPLES: usize = 128;
2081
2082#[allow(clippy::cast_precision_loss)]
2083fn ruling_u(i: usize) -> f64 {
2084    TAU * (i as f64 + 0.5) / RULING_SAMPLES as f64
2085}
2086
2087/// The discriminant and roots of `quad·v² + b·v + c = 0`.
2088fn ruling_quadratic(quad: f64, b: f64, c: f64) -> (f64, f64, f64) {
2089    let disc = b * b - 4.0 * quad * c;
2090    let root = disc.max(0.0).sqrt();
2091    (disc, (-b + root) / (2.0 * quad), (-b - root) / (2.0 * quad))
2092}
2093
2094/// The two points where each sampled ruling of `sweep` meets the other
2095/// surface, from `roots(u)` (the discriminant and the two axial parameters),
2096/// or `None` for a ruling that misses it.
2097fn ruling_samples(
2098    sweep: &CylindricalSurface,
2099    roots: &impl Fn(f64) -> (f64, f64, f64),
2100) -> Vec<Option<(Point3, Point3)>> {
2101    let lin_tol = Tolerance::new().linear;
2102    (0..RULING_SAMPLES)
2103        .map(|i| {
2104            let u = ruling_u(i);
2105            let (disc, vp, vm) = roots(u);
2106            (disc >= -lin_tol).then(|| (sweep.evaluate(u, vp), sweep.evaluate(u, vm)))
2107        })
2108        .collect()
2109}
2110
2111/// Every ruling meets the other surface: each root traces a closed loop.
2112fn closed_ruling_loops(samples: &[Option<(Point3, Point3)>]) -> Vec<Vec<Point3>> {
2113    let mut plus: Vec<Point3> = samples.iter().flatten().map(|s| s.0).collect();
2114    let mut minus: Vec<Point3> = samples.iter().flatten().map(|s| s.1).collect();
2115    plus.push(plus[0]);
2116    minus.push(minus[0]);
2117    vec![plus, minus]
2118}
2119
2120/// Only windows of rulings meet the other surface: each cyclic window
2121/// carries one loop, out along one root and back along the other, the two
2122/// joined where the discriminant vanishes. Empty when no sample meets it (a
2123/// window narrower than the sampling).
2124fn partial_ruling_loops(
2125    sweep: &CylindricalSurface,
2126    roots: &impl Fn(f64) -> (f64, f64, f64),
2127    samples: &[Option<(Point3, Point3)>],
2128) -> Vec<Vec<Point3>> {
2129    let branch_point = |inside: usize, outside: usize| -> Point3 {
2130        let (mut lo, mut hi) = (ruling_u(inside), ruling_u(outside));
2131        if (hi - lo).abs() > std::f64::consts::PI {
2132            hi += if hi < lo { TAU } else { -TAU };
2133        }
2134        for _ in 0..60 {
2135            let mid = 0.5 * (lo + hi);
2136            if roots(mid).0 >= 0.0 {
2137                lo = mid;
2138            } else {
2139                hi = mid;
2140            }
2141        }
2142        let (_, vp, vm) = roots(lo);
2143        sweep.evaluate(lo, 0.5 * (vp + vm))
2144    };
2145    let Some(first_gap) = samples.iter().position(Option::is_none) else {
2146        return Vec::new();
2147    };
2148    let mut loops = Vec::new();
2149    let mut k = 0;
2150    while k < RULING_SAMPLES {
2151        let i = (first_gap + k) % RULING_SAMPLES;
2152        if samples[i].is_none() {
2153            k += 1;
2154            continue;
2155        }
2156        let start = i;
2157        let mut run = Vec::new();
2158        while k < RULING_SAMPLES {
2159            let j = (first_gap + k) % RULING_SAMPLES;
2160            let Some(pair) = samples[j] else { break };
2161            run.push(pair);
2162            k += 1;
2163        }
2164        let end = (start + run.len() - 1) % RULING_SAMPLES;
2165        let head = branch_point(start, (start + RULING_SAMPLES - 1) % RULING_SAMPLES);
2166        let tail = branch_point(end, (end + 1) % RULING_SAMPLES);
2167        let mut pts = vec![head];
2168        pts.extend(run.iter().map(|p| p.0));
2169        pts.push(tail);
2170        pts.extend(run.iter().rev().map(|p| p.1));
2171        pts.push(head);
2172        loops.push(pts);
2173    }
2174    loops
2175}
2176
2177/// Cubic interpolants through the swept loops, with `params(p)` giving each
2178/// point's parameters on the two surfaces.
2179fn fit_ruling_loops(
2180    loops: &[Vec<Point3>],
2181    params: impl Fn(Point3) -> ((f64, f64), (f64, f64)),
2182) -> Vec<IntersectionCurve> {
2183    let mut curves = Vec::new();
2184    for pts in loops {
2185        if pts.len() < 4 {
2186            continue;
2187        }
2188        let ipts: Vec<IntersectionPoint> = pts
2189            .iter()
2190            .map(|&p| {
2191                let (param1, param2) = params(p);
2192                IntersectionPoint {
2193                    point: p,
2194                    param1,
2195                    param2,
2196                }
2197            })
2198            .collect();
2199        let degree = 3.min(pts.len() - 1);
2200        if let Ok(curve) = interpolate(pts, degree) {
2201            curves.push(IntersectionCurve {
2202                curve,
2203                points: ipts,
2204            });
2205        }
2206    }
2207    curves
2208}
2209
2210/// Algebraic cone-cylinder intersection for PARALLEL (or antiparallel) axes.
2211///
2212/// When the axes are parallel, every plane perpendicular to them cuts the cone
2213/// in a circle of radius `rho = v * cos(half_angle)` about a FIXED centre and
2214/// the cylinder in a circle of radius `R` about a second FIXED centre, so the
2215/// axis separation `d` is constant in `v`. Two coplanar circles meet at
2216/// `u = phi0 +/- acos((d^2 + rho^2 - R^2) / (2*d*rho))`, giving two branches
2217/// parameterised exactly by the cone's own `v`. The branches exist only where
2218/// `rho` lies in `[|d - R|, d + R]`, which bounds the curve naturally.
2219///
2220/// This replaces the general grid-seeded marcher for the configuration, which
2221/// mis-handles it badly: seeds are accepted anywhere within half the surface
2222/// diagonal of the partner, the march-result dedup only consumes seeds the
2223/// traced polyline passes near, and the survivors are dozens of overlapping
2224/// partial traces of the same curve. Those fragments carry no usable in-face
2225/// span, so a cone corner-round crossed by a boss cylinder never splits (a
2226/// counterbore/countersink meeting a pad — the gridfinity lightweight base).
2227///
2228/// Returns `None` (defer to the caller's other paths) when the axes are not
2229/// parallel, or when they are coaxial — a coaxial pair degenerates to shared
2230/// circles, which [`exact_cone_cylinder`] emits exactly and phase FF calls
2231/// directly. Note that `intersect_analytic_analytic_bounded` does NOT consult
2232/// `exact_cone_cylinder`, so a coaxial pair reaching this path through that
2233/// caller falls through to the marcher; only the FF path gets the exact circles.
2234// Result-wrapped to match the other `try_algebraic_intersection` arms' shape.
2235#[allow(clippy::unnecessary_wraps)]
2236fn algebraic_parallel_cone_cylinder(
2237    cone: &ConicalSurface,
2238    cyl: &CylindricalSurface,
2239    v_range_cone: Option<(f64, f64)>,
2240    v_range_cyl: Option<(f64, f64)>,
2241) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
2242    let axis = cone.axis();
2243    if axis.dot(cyl.axis()).abs() < 1.0 - 1e-10 {
2244        return Ok(None); // Skew/oblique — general marcher.
2245    }
2246
2247    let apex = cone.apex();
2248    let delta = cyl.origin() - apex;
2249    let along = delta.dot(axis);
2250    let perp = delta - axis * along;
2251    let d = perp.length();
2252    if d < 1e-9 {
2253        return Ok(None); // Coaxial — `exact_cone_cylinder` owns this.
2254    }
2255
2256    let (e1, e2) = (cone.x_axis(), cone.y_axis());
2257    let phi0 = perp.dot(e2).atan2(perp.dot(e1));
2258
2259    let (sin_t, cos_t) = cone.half_angle().sin_cos();
2260    if cos_t < 1e-12 || sin_t < 1e-12 {
2261        return Ok(None);
2262    }
2263    let r = cyl.radius();
2264
2265    // Branch existence: |d - R| <= rho <= d + R, with rho = v * cos(half_angle).
2266    let mut v_min = (d - r).abs() / cos_t;
2267    let mut v_max = (d + r) / cos_t;
2268    if v_max <= v_min {
2269        return Ok(Some(vec![]));
2270    }
2271
2272    // Narrow the sampled span to the faces' own extents so the fixed sample
2273    // budget resolves the in-face part of the curve rather than spreading over
2274    // a loop that mostly lies off both patches. A face's crossing can be a
2275    // fraction of a degree of the cone's sweep (the corner-round case above),
2276    // and an unnarrowed sampling puts fewer than one sample across it.
2277    let mut lo = v_min;
2278    let mut hi = v_max;
2279    // Clip EXACTLY to the hints, not to a padded window: an endpoint that lands
2280    // exactly on the face's own v-limit lies ON that boundary rim, so the
2281    // downstream pave machinery anchors it to the rim edge instead of leaving
2282    // the section dangling just past the face.
2283    if let Some((a, b)) = v_range_cone {
2284        let (a, b) = if a <= b { (a, b) } else { (b, a) };
2285        lo = lo.max(a);
2286        hi = hi.min(b);
2287    }
2288    if let Some((a, b)) = v_range_cyl {
2289        // The cylinder's v is a signed distance along its axis from its origin;
2290        // convert both ends to the cone's v via the shared axial direction.
2291        let flip = cyl.axis().dot(axis);
2292        let to_cone_v = |cv: f64| (along + cv * flip) / sin_t;
2293        let (a, b) = (to_cone_v(a), to_cone_v(b));
2294        let (a, b) = if a <= b { (a, b) } else { (b, a) };
2295        lo = lo.max(a);
2296        hi = hi.min(b);
2297    }
2298    v_min = lo.max(v_min);
2299    v_max = hi.min(v_max);
2300    if v_max - v_min <= 1e-12 {
2301        return Ok(Some(vec![]));
2302    }
2303
2304    let n_samples = 128;
2305    let mut plus: Vec<Point3> = Vec::with_capacity(n_samples + 1);
2306    let mut minus: Vec<Point3> = Vec::with_capacity(n_samples + 1);
2307    #[allow(clippy::cast_precision_loss)]
2308    for i in 0..=n_samples {
2309        let v = v_min + (v_max - v_min) * (i as f64) / (n_samples as f64);
2310        let rho = v * cos_t;
2311        if rho < 1e-12 {
2312            // The apex. `cos_alpha` has rho in its denominator, so it is only
2313            // meaningful in the limit: it tends to 0 (alpha -> pi/2) when the
2314            // cylinder passes exactly through the apex (d == R), and diverges
2315            // otherwise — where the clamp would manufacture a spurious alpha of
2316            // 0 or pi. So keep the apex only in the d == R case, where it is a
2317            // genuine point of the intersection and the shared endpoint at
2318            // which the two branches meet.
2319            if (d - r).abs() < 1e-12 {
2320                let apex = cone.evaluate(phi0, v);
2321                plus.push(apex);
2322                minus.push(apex);
2323            }
2324            continue;
2325        }
2326        let cos_alpha = ((d * d + rho * rho - r * r) / (2.0 * d * rho)).clamp(-1.0, 1.0);
2327        let alpha = cos_alpha.acos();
2328        plus.push(cone.evaluate(phi0 + alpha, v));
2329        minus.push(cone.evaluate(phi0 - alpha, v));
2330    }
2331
2332    let mut curves = Vec::new();
2333    for pts in [&plus, &minus] {
2334        // Fewer than four samples in range means this branch does not cross the
2335        // bounded region at all (the other branch may still).
2336        if pts.len() < 4 {
2337            continue;
2338        }
2339        let ipts: Vec<IntersectionPoint> = pts
2340            .iter()
2341            .map(|&p| IntersectionPoint {
2342                point: p,
2343                param1: cone.project_point(p),
2344                param2: cyl.project_point(p),
2345            })
2346            .collect();
2347        let degree = 3.min(pts.len() - 1);
2348        match interpolate(pts, degree) {
2349            Ok(curve) => curves.push(IntersectionCurve {
2350                curve,
2351                points: ipts,
2352            }),
2353            // Emitting only the branch that happened to fit would starve the
2354            // section chain of exactly the piece this path exists to supply —
2355            // the same silent half-answer the marcher's fragments produced.
2356            // Defer the whole pair to the caller's other paths instead.
2357            Err(_) => return Ok(None),
2358        }
2359    }
2360
2361    Ok(Some(curves))
2362}
2363
2364/// Algebraic sphere-sphere intersection.
2365///
2366/// Two spheres intersect in a circle lying in the radical plane.
2367/// The radical plane is perpendicular to the line connecting the centers,
2368/// at a distance d1 from center1 where:
2369///   d1 = (D² + R1² - R2²) / (2D)
2370/// and D is the distance between centers.
2371fn algebraic_sphere_sphere(
2372    s1: &SphericalSurface,
2373    s2: &SphericalSurface,
2374) -> Result<Vec<IntersectionCurve>, MathError> {
2375    let c1 = s1.center();
2376    let c2 = s2.center();
2377    let r1 = s1.radius();
2378    let r2 = s2.radius();
2379
2380    let delta = c2 - c1;
2381    let d_sq = delta.x() * delta.x() + delta.y() * delta.y() + delta.z() * delta.z();
2382    let d = d_sq.sqrt();
2383
2384    if d < 1e-12 {
2385        // Concentric spheres: no intersection (unless same radius → degenerate).
2386        return Ok(vec![]);
2387    }
2388
2389    // Check separation conditions.
2390    if d > r1 + r2 + 1e-10 {
2391        return Ok(vec![]); // Too far apart
2392    }
2393    if d + r2.min(r1) + 1e-10 < r1.max(r2) {
2394        return Ok(vec![]); // One inside the other
2395    }
2396
2397    // Distance from c1 to the radical plane along the center line.
2398    let d1 = (d_sq + r1 * r1 - r2 * r2) / (2.0 * d);
2399
2400    // Radius of the intersection circle.
2401    let r_circle_sq = r1 * r1 - d1 * d1;
2402    if r_circle_sq < 0.0 {
2403        // Tangent or no intersection (numerical noise).
2404        if r_circle_sq > -1e-10 {
2405            // Tangent: single point.
2406            let axis = Vec3::new(delta.x() / d, delta.y() / d, delta.z() / d);
2407            let tangent_pt = Point3::new(
2408                c1.x() + axis.x() * d1,
2409                c1.y() + axis.y() * d1,
2410                c1.z() + axis.z() * d1,
2411            );
2412            let ipt = IntersectionPoint {
2413                point: tangent_pt,
2414                param1: (0.0, 0.0),
2415                param2: (0.0, 0.0),
2416            };
2417            // Single-point "curve" — not very useful but correct.
2418            return Ok(vec![IntersectionCurve {
2419                curve: interpolate(&[tangent_pt, tangent_pt], 1)?,
2420                points: vec![ipt],
2421            }]);
2422        }
2423        return Ok(vec![]);
2424    }
2425
2426    let r_circle = r_circle_sq.sqrt();
2427    let axis = Vec3::new(delta.x() / d, delta.y() / d, delta.z() / d);
2428    let center = Point3::new(
2429        c1.x() + axis.x() * d1,
2430        c1.y() + axis.y() * d1,
2431        c1.z() + axis.z() * d1,
2432    );
2433
2434    // Build a reference frame for the circle.
2435    let basis = Frame3::from_normal(center, axis)?;
2436    let u_dir = basis.x;
2437    let v_dir = basis.y;
2438
2439    // Sample the circle for the IntersectionCurve representation.
2440    let n_samples = 33; // Odd for symmetry
2441    let mut points = Vec::with_capacity(n_samples);
2442    let mut positions = Vec::with_capacity(n_samples);
2443    #[allow(clippy::cast_precision_loss)]
2444    for i in 0..n_samples {
2445        let theta = TAU * i as f64 / (n_samples - 1) as f64;
2446        let (sin_t, cos_t) = theta.sin_cos();
2447        let pt = Point3::new(
2448            center.x() + (u_dir.x() * cos_t + v_dir.x() * sin_t) * r_circle,
2449            center.y() + (u_dir.y() * cos_t + v_dir.y() * sin_t) * r_circle,
2450            center.z() + (u_dir.z() * cos_t + v_dir.z() * sin_t) * r_circle,
2451        );
2452        positions.push(pt);
2453        points.push(IntersectionPoint {
2454            point: pt,
2455            param1: (0.0, 0.0),
2456            param2: (0.0, 0.0),
2457        });
2458    }
2459
2460    let degree = 3.min(positions.len() - 1);
2461    let curve = interpolate(&positions, degree)?;
2462
2463    Ok(vec![IntersectionCurve { curve, points }])
2464}
2465
2466/// Newton correction: project a point back onto the intersection curve
2467/// of two analytic surfaces. Solves the 3×3 system:
2468///   δ · na = -da  (eliminate distance to surface A)
2469///   δ · nb = -db  (eliminate distance to surface B)
2470///   δ · t  = 0    (minimal correction, perpendicular to tangent)
2471#[allow(clippy::too_many_arguments)]
2472fn correct_to_intersection(
2473    a: &AnalyticSurface<'_>,
2474    b: &AnalyticSurface<'_>,
2475    surf_a: &dyn Fn(f64, f64) -> Point3,
2476    norm_a: &dyn Fn(f64, f64) -> Vec3,
2477    surf_b: &dyn Fn(f64, f64) -> Point3,
2478    norm_b: &dyn Fn(f64, f64) -> Vec3,
2479    point: Point3,
2480    u_range_a: (f64, f64),
2481    v_range_a: (f64, f64),
2482    u_range_b: (f64, f64),
2483    v_range_b: (f64, f64),
2484    max_iters: usize,
2485) -> Point3 {
2486    let mut p = point;
2487    for _ in 0..max_iters {
2488        let (ua, va) = project_analytic(a, p, u_range_a, v_range_a);
2489        let (ub, vb) = project_analytic(b, p, u_range_b, v_range_b);
2490        let pa = surf_a(ua, va);
2491        let pb = surf_b(ub, vb);
2492        let na = norm_a(ua, va);
2493        let nb = norm_b(ub, vb);
2494        let pv = Vec3::new(p.x(), p.y(), p.z());
2495
2496        let da = (pv - Vec3::new(pa.x(), pa.y(), pa.z())).dot(na);
2497        let db = (pv - Vec3::new(pb.x(), pb.y(), pb.z())).dot(nb);
2498
2499        if da.abs() < 1e-7 && db.abs() < 1e-7 {
2500            break;
2501        }
2502
2503        let t = na.cross(nb);
2504        let t_len = t.length();
2505        if t_len < 1e-10 {
2506            // Surfaces are tangent — fall back to midpoint.
2507            return Point3::new(
2508                (pa.x() + pb.x()) * 0.5,
2509                (pa.y() + pb.y()) * 0.5,
2510                (pa.z() + pb.z()) * 0.5,
2511            );
2512        }
2513        let t_hat = t * (1.0 / t_len);
2514
2515        // Solve [na; nb; t_hat] · δ = [-da, -db, 0] via Cramer's rule.
2516        let det = na.x() * (nb.y() * t_hat.z() - nb.z() * t_hat.y())
2517            - na.y() * (nb.x() * t_hat.z() - nb.z() * t_hat.x())
2518            + na.z() * (nb.x() * t_hat.y() - nb.y() * t_hat.x());
2519        if det.abs() < 1e-15 {
2520            return Point3::new(
2521                (pa.x() + pb.x()) * 0.5,
2522                (pa.y() + pb.y()) * 0.5,
2523                (pa.z() + pb.z()) * 0.5,
2524            );
2525        }
2526        let inv = 1.0 / det;
2527        // Cramer's rule: replace each column of A with rhs = (-da, -db, 0).
2528        let dx = inv
2529            * (-da * (nb.y() * t_hat.z() - nb.z() * t_hat.y())
2530                + db * (na.y() * t_hat.z() - na.z() * t_hat.y()));
2531        let dy = inv
2532            * (da * (nb.x() * t_hat.z() - nb.z() * t_hat.x())
2533                - db * (na.x() * t_hat.z() - na.z() * t_hat.x()));
2534        let dz = inv
2535            * (-da * (nb.x() * t_hat.y() - nb.y() * t_hat.x())
2536                + db * (na.x() * t_hat.y() - na.y() * t_hat.x()));
2537        let candidate = Point3::new(p.x() + dx, p.y() + dy, p.z() + dz);
2538
2539        // Divergence guard: if the correction moves farther from both
2540        // surfaces, abandon Newton and return the best point so far.
2541        let (uc, vc) = project_analytic(a, candidate, u_range_a, v_range_a);
2542        let (ud, vd) = project_analytic(b, candidate, u_range_b, v_range_b);
2543        let pc_a = surf_a(uc, vc);
2544        let pc_b = surf_b(ud, vd);
2545        let cv = Vec3::new(candidate.x(), candidate.y(), candidate.z());
2546        let da_new = (cv - Vec3::new(pc_a.x(), pc_a.y(), pc_a.z()))
2547            .dot(norm_a(uc, vc))
2548            .abs();
2549        let db_new = (cv - Vec3::new(pc_b.x(), pc_b.y(), pc_b.z()))
2550            .dot(norm_b(ud, vd))
2551            .abs();
2552        if da_new > da.abs() && db_new > db.abs() {
2553            return p;
2554        }
2555
2556        p = candidate;
2557    }
2558    p
2559}
2560
2561/// March along the intersection of two surfaces from a seed point.
2562///
2563/// Uses the cross product of surface normals as the tangent direction
2564/// and projects back onto both surfaces using analytical projection
2565/// (for cylinders/spheres) or grid search (fallback).
2566#[allow(clippy::too_many_arguments)]
2567fn march_analytic_intersection(
2568    a: &AnalyticSurface<'_>,
2569    b: &AnalyticSurface<'_>,
2570    surf_a: &dyn Fn(f64, f64) -> Point3,
2571    norm_a: &dyn Fn(f64, f64) -> Vec3,
2572    surf_b: &dyn Fn(f64, f64) -> Point3,
2573    norm_b: &dyn Fn(f64, f64) -> Vec3,
2574    seed: Point3,
2575    u_range_a: (f64, f64),
2576    v_range_a: (f64, f64),
2577    u_range_b: (f64, f64),
2578    v_range_b: (f64, f64),
2579    initial_step: f64,
2580    u_periodic_a: bool,
2581    u_periodic_b: bool,
2582) -> Vec<Point3> {
2583    let max_steps = 500;
2584    let h_min = 1e-6;
2585    let h_max = initial_step * 4.0;
2586    // Fixed closure threshold: the adaptive step `h` varies with curvature
2587    // and can shrink below the actual miss distance at the seed re-approach.
2588    // Use `initial_step * 5` to robustly detect closure on the first pass.
2589    let closure_dist = initial_step * 5.0;
2590    // Angular thresholds for curvature-adaptive stepping.
2591    let max_angle = 10.0_f64.to_radians();
2592    let min_angle = 2.0_f64.to_radians();
2593
2594    // March forward from seed, collecting points.
2595    let mut forward = Vec::new();
2596    // March backward from seed, collecting points (reversed at end).
2597    let mut backward = Vec::new();
2598
2599    for (direction, points) in [(1.0_f64, &mut forward), (-1.0_f64, &mut backward)] {
2600        let mut current = seed;
2601        let mut h = initial_step;
2602        let mut prev_tangent: Option<Vec3> = None;
2603
2604        for _ in 0..max_steps {
2605            let (ua, va) = project_analytic(a, current, u_range_a, v_range_a);
2606            let (ub, vb) = project_analytic(b, current, u_range_b, v_range_b);
2607
2608            let na = norm_a(ua, va);
2609            let nb = norm_b(ub, vb);
2610
2611            let tangent = na.cross(nb);
2612            let t_len = tangent.length();
2613            if t_len < 1e-10 {
2614                break;
2615            }
2616            let t_dir = tangent * (direction / t_len);
2617
2618            // Curvature-adaptive step: check angular deviation from previous tangent.
2619            if let Some(prev_t) = prev_tangent {
2620                let cos_angle = prev_t.dot(t_dir).clamp(-1.0, 1.0);
2621                let angle = cos_angle.acos();
2622                if angle > max_angle && h > h_min {
2623                    h = (h * 0.5).max(h_min);
2624                } else if angle < min_angle {
2625                    h = (h * 2.0).min(h_max);
2626                }
2627            }
2628            prev_tangent = Some(t_dir);
2629
2630            let next = Point3::new(
2631                h.mul_add(t_dir.x(), current.x()),
2632                h.mul_add(t_dir.y(), current.y()),
2633                h.mul_add(t_dir.z(), current.z()),
2634            );
2635
2636            let (ua2, va2) = project_analytic(a, next, u_range_a, v_range_a);
2637            let (ub2, vb2) = project_analytic(b, next, u_range_b, v_range_b);
2638
2639            let pa = surf_a(ua2, va2);
2640            let pb = surf_b(ub2, vb2);
2641            let mid = Point3::new(
2642                (pa.x() + pb.x()) * 0.5,
2643                (pa.y() + pb.y()) * 0.5,
2644                (pa.z() + pb.z()) * 0.5,
2645            );
2646            let out_a = (!u_periodic_a && (ua2 <= u_range_a.0 || ua2 >= u_range_a.1))
2647                || va2 <= v_range_a.0
2648                || va2 >= v_range_a.1;
2649            let out_b = (!u_periodic_b && (ub2 <= u_range_b.0 || ub2 >= u_range_b.1))
2650                || vb2 <= v_range_b.0
2651                || vb2 >= v_range_b.1;
2652
2653            if out_a || out_b {
2654                break;
2655            }
2656
2657            // Check for loop closure — if we've collected enough points and
2658            // the current point is close to the seed, the curve is closed.
2659            // Require ≥10 steps to avoid premature closure near the seed.
2660            let dist_to_seed = (mid - seed).length();
2661            if points.len() > 10 && dist_to_seed < closure_dist {
2662                points.push(seed);
2663                break;
2664            }
2665
2666            points.push(mid);
2667            current = mid;
2668        }
2669    }
2670
2671    // Assemble result: backward (reversed) + seed + forward
2672    backward.reverse();
2673    let mut result = backward;
2674    result.push(seed);
2675    result.append(&mut forward);
2676
2677    // Refine all points onto the intersection curve via Newton correction.
2678    for pt in &mut result {
2679        *pt = correct_to_intersection(
2680            a, b, surf_a, norm_a, surf_b, norm_b, *pt, u_range_a, v_range_a, u_range_b, v_range_b,
2681            5,
2682        );
2683    }
2684
2685    result
2686}
2687
2688/// Project a 3D point onto an analytic surface using the surface's
2689/// analytical projection method. Falls back to grid search for surface
2690/// types without analytical projection.
2691fn project_analytic(
2692    surface: &AnalyticSurface<'_>,
2693    point: Point3,
2694    u_range: (f64, f64),
2695    v_range: (f64, f64),
2696) -> (f64, f64) {
2697    match surface {
2698        AnalyticSurface::Cylinder(cyl) => {
2699            let (u, v) = cyl.project_point(point);
2700            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2701        }
2702        AnalyticSurface::Sphere(sphere) => {
2703            let (u, v) = sphere.project_point(point);
2704            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2705        }
2706        AnalyticSurface::Cone(cone) => {
2707            let (u, v) = cone.project_point(point);
2708            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2709        }
2710        AnalyticSurface::Torus(torus) => {
2711            let (u, v) = torus.project_point(point);
2712            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2713        }
2714    }
2715}
2716
2717/// Returns `true` if the surface's u-parameter is periodic (wraps around 2π).
2718/// All current `AnalyticSurface` variants have periodic u — this is trivially
2719/// true today but exists as a guard for future non-periodic analytic types.
2720fn is_u_periodic(surface: &AnalyticSurface<'_>) -> bool {
2721    matches!(
2722        surface,
2723        AnalyticSurface::Cylinder(_)
2724            | AnalyticSurface::Cone(_)
2725            | AnalyticSurface::Sphere(_)
2726            | AnalyticSurface::Torus(_)
2727    )
2728}
2729
2730/// Extract closures and parameter ranges for an analytic surface.
2731#[allow(clippy::type_complexity)]
2732fn surface_closures<'a>(
2733    surface: &'a AnalyticSurface<'a>,
2734) -> (
2735    Box<dyn Fn(f64, f64) -> Point3 + 'a>,
2736    Box<dyn Fn(f64, f64) -> Vec3 + 'a>,
2737    (f64, f64),
2738    (f64, f64),
2739) {
2740    match surface {
2741        AnalyticSurface::Cylinder(cyl) => (
2742            Box::new(|u, v| cyl.evaluate(u, v)),
2743            Box::new(|u, v| cyl.normal(u, v)),
2744            (0.0, TAU),
2745            (-1.0, 1.0),
2746        ),
2747        AnalyticSurface::Cone(cone) => (
2748            Box::new(|u, v| cone.evaluate(u, v)),
2749            Box::new(|u, v| cone.normal(u, v)),
2750            (0.0, TAU),
2751            (0.01, 2.0),
2752        ),
2753        AnalyticSurface::Sphere(sphere) => (
2754            Box::new(|u, v| sphere.evaluate(u, v)),
2755            Box::new(|u, v| sphere.normal(u, v)),
2756            (0.0, TAU),
2757            (-FRAC_PI_2, FRAC_PI_2),
2758        ),
2759        AnalyticSurface::Torus(torus) => (
2760            Box::new(|u, v| torus.evaluate(u, v)),
2761            Box::new(|u, v| torus.normal(u, v)),
2762            (0.0, TAU),
2763            (0.0, TAU),
2764        ),
2765    }
2766}
2767
2768#[cfg(test)]
2769#[allow(clippy::unwrap_used, clippy::expect_used)]
2770mod tests {
2771    use super::*;
2772    use crate::tolerance::Tolerance;
2773
2774    #[test]
2775    fn plane_cylinder_perpendicular() {
2776        let cyl =
2777            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 2.0)
2778                .unwrap();
2779
2780        // Horizontal plane at z=3 -- produces a circle at height 3.
2781        let curves = intersect_plane_cylinder(&cyl, Vec3::new(0.0, 0.0, 1.0), 3.0).unwrap();
2782        assert!(!curves.is_empty(), "should find intersection curve");
2783        assert!(
2784            curves[0].points.len() > 10,
2785            "should have many sample points"
2786        );
2787
2788        let tol = Tolerance::loose();
2789        for pt in &curves[0].points {
2790            assert!(
2791                tol.approx_eq(pt.point.z(), 3.0),
2792                "z should be ~3.0, got {}",
2793                pt.point.z()
2794            );
2795            let r = pt.point.x().hypot(pt.point.y());
2796            assert!(tol.approx_eq(r, 2.0), "radius should be ~2.0, got {r}");
2797        }
2798    }
2799
2800    #[test]
2801    fn plane_sphere_equator() {
2802        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 3.0).unwrap();
2803
2804        let curves = intersect_plane_sphere(&sphere, Vec3::new(0.0, 0.0, 1.0), 0.0).unwrap();
2805        assert!(!curves.is_empty());
2806
2807        let tol = Tolerance::loose();
2808        for pt in &curves[0].points {
2809            assert!(
2810                tol.approx_eq(pt.point.z(), 0.0),
2811                "z should be ~0, got {}",
2812                pt.point.z()
2813            );
2814            let r = pt.point.x().hypot(pt.point.y());
2815            assert!(tol.approx_eq(r, 3.0), "radius should be ~3.0, got {r}");
2816        }
2817    }
2818
2819    #[test]
2820    fn plane_sphere_no_intersection() {
2821        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
2822
2823        let curves = intersect_plane_sphere(&sphere, Vec3::new(0.0, 0.0, 1.0), 5.0).unwrap();
2824        assert!(curves.is_empty());
2825    }
2826
2827    #[test]
2828    fn plane_cone_cross_section() {
2829        let cone = ConicalSurface::new(
2830            Point3::new(0.0, 0.0, 0.0),
2831            Vec3::new(0.0, 0.0, 1.0),
2832            std::f64::consts::FRAC_PI_4,
2833        )
2834        .unwrap();
2835
2836        let curves = intersect_plane_cone(&cone, Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
2837        assert!(!curves.is_empty(), "should find intersection with cone");
2838    }
2839
2840    /// The 1u gridfinity spacer lip fuse corner (#1570): the body's lip
2841    /// recess cone (45 deg, opening downward) meets the tool's lip cone
2842    /// (45 deg, opening upward) with axes offset 0.25mm in x and y. Equal
2843    /// half-angle tangents put the whole intersection on the radical plane,
2844    /// so the section is one exact ellipse; the marcher shredded this into
2845    /// ~64 closed micro-loops per pair.
2846    #[test]
2847    fn offset_parallel_equal_angle_cones_give_one_exact_ellipse() {
2848        let c1 = ConicalSurface::new(
2849            Point3::new(
2850                -16.999_999_999_999_975,
2851                -16.999_999_999_999_975,
2852                5.849_999_999_999_951,
2853            ),
2854            Vec3::new(0.0, 0.0, -1.0),
2855            0.785_398_163_397_433_5,
2856        )
2857        .unwrap();
2858        let c2 = ConicalSurface::new(
2859            Point3::new(
2860                -16.750_000_000_000_036,
2861                -16.750_000_000_000_018,
2862                0.749_999_999_999_881,
2863            ),
2864            Vec3::new(0.0, 0.0, 1.0),
2865            0.785_398_163_397_467_6,
2866        )
2867        .unwrap();
2868
2869        let curves = exact_cone_cone(&c1, &c2)
2870            .unwrap()
2871            .expect("offset parallel equal-angle cones must take the radical-plane path");
2872        assert_eq!(curves.len(), 1, "expected exactly one section conic");
2873        assert!(
2874            matches!(curves[0], ExactIntersectionCurve::Ellipse(_)),
2875            "expected an ellipse section, got {:?}",
2876            curves[0]
2877        );
2878        let ExactIntersectionCurve::Ellipse(ellipse) = &curves[0] else {
2879            return;
2880        };
2881
2882        // Every sample must lie on BOTH cones: distance to the axis equals
2883        // tan(half_angle) times the axial distance from the apex, on the
2884        // real nappe of each.
2885        for i in 0..16 {
2886            let p = crate::traits::ParametricCurve::evaluate(ellipse, TAU * f64::from(i) / 16.0);
2887            for (cone, label) in [(&c1, "c1"), (&c2, "c2")] {
2888                let rel = p - cone.apex();
2889                let rel_v = Vec3::new(rel.x(), rel.y(), rel.z());
2890                let axial = rel_v.dot(cone.axis());
2891                let radial = (rel_v - cone.axis() * axial).length();
2892                assert!(
2893                    axial > 0.0,
2894                    "{label}: sample on phantom nappe (axial {axial})"
2895                );
2896                let expect = cone.half_angle().tan() * axial;
2897                assert!(
2898                    (radial - expect).abs() < 1e-9,
2899                    "{label}: sample off surface by {}",
2900                    (radial - expect).abs()
2901                );
2902            }
2903        }
2904    }
2905
2906    /// Opposed cones whose real nappes occupy disjoint half-spaces share a
2907    /// radical-plane conic only on the phantom nappe — the exact path must
2908    /// report a definitive empty intersection, not defer to the marcher.
2909    #[test]
2910    fn offset_parallel_cones_opening_apart_have_no_real_intersection() {
2911        let c1 = ConicalSurface::new(
2912            Point3::new(0.0, 0.0, 5.0),
2913            Vec3::new(0.0, 0.0, -1.0),
2914            std::f64::consts::FRAC_PI_4,
2915        )
2916        .unwrap();
2917        let c2 = ConicalSurface::new(
2918            Point3::new(0.25, 0.25, 20.0),
2919            Vec3::new(0.0, 0.0, 1.0),
2920            std::f64::consts::FRAC_PI_4,
2921        )
2922        .unwrap();
2923        let curves = exact_cone_cone(&c1, &c2)
2924            .unwrap()
2925            .expect("radical-plane path");
2926        assert!(curves.is_empty(), "disjoint nappes must yield no curves");
2927    }
2928
2929    /// Unequal half-angles keep a quadratic term in the pencil — no plane
2930    /// reduction exists, so the exact path must defer to the marcher.
2931    #[test]
2932    fn offset_parallel_cones_with_unequal_angles_defer() {
2933        let c1 = ConicalSurface::new(
2934            Point3::new(0.0, 0.0, 5.0),
2935            Vec3::new(0.0, 0.0, -1.0),
2936            std::f64::consts::FRAC_PI_4,
2937        )
2938        .unwrap();
2939        let c2 = ConicalSurface::new(Point3::new(0.25, 0.25, 0.5), Vec3::new(0.0, 0.0, 1.0), 0.6)
2940            .unwrap();
2941        assert!(exact_cone_cone(&c1, &c2).unwrap().is_none());
2942    }
2943
2944    #[test]
2945    fn coaxial_cones_cross_at_single_circle() {
2946        // Two coaxial truncated cones (outer base r10->top r8, inner r9->r8
2947        // over height 10) cross where their radii match: z=10, r=8. The
2948        // intersection must be ONE clean circle, not the dozens of degenerate
2949        // micro-curves the general marcher produces at near-tangency.
2950        let outer = ConicalSurface::new(
2951            Point3::new(0.0, 0.0, 50.0),
2952            Vec3::new(0.0, 0.0, -1.0),
2953            5.0_f64.atan(),
2954        )
2955        .unwrap();
2956        let inner = ConicalSurface::new(
2957            Point3::new(0.0, 0.0, 90.0),
2958            Vec3::new(0.0, 0.0, -1.0),
2959            10.0_f64.atan(),
2960        )
2961        .unwrap();
2962
2963        let curves = intersect_analytic_analytic_bounded(
2964            AnalyticSurface::Cone(&outer),
2965            AnalyticSurface::Cone(&inner),
2966            32,
2967            None,
2968            None,
2969        )
2970        .unwrap();
2971
2972        assert_eq!(
2973            curves.len(),
2974            1,
2975            "coaxial cones crossing at one circle must yield exactly one curve, got {}",
2976            curves.len()
2977        );
2978        for p in &curves[0].points {
2979            let r = p.point.x().hypot(p.point.y());
2980            assert!(
2981                (p.point.z() - 10.0).abs() < 1e-6 && (r - 8.0).abs() < 1e-6,
2982                "intersection point off the expected z=10,r=8 circle: {:?}",
2983                p.point
2984            );
2985        }
2986    }
2987
2988    #[test]
2989    fn plane_torus_cross_section() {
2990        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 5.0, 1.0).unwrap();
2991
2992        let curves = intersect_plane_torus(&torus, Vec3::new(0.0, 0.0, 1.0), 0.0).unwrap();
2993        assert!(
2994            !curves.is_empty(),
2995            "should find intersection curves with torus"
2996        );
2997    }
2998
2999    /// Signed distance of a point to a z-axis torus centred at the origin:
3000    /// `sqrt((sqrt(x^2+y^2) - R)^2 + z^2) - r`.
3001    fn torus_implicit(p: Point3, major: f64, minor: f64) -> f64 {
3002        let rho = p.x().hypot(p.y());
3003        ((rho - major).hypot(p.z())) - minor
3004    }
3005
3006    /// The gridfinity lightweight base's failing corner, reduced: a cavity
3007    /// corner-round cone (apex below the floor, 45 deg, axis +z) crossed by a
3008    /// parallel-axis boss cylinder. The general marcher returned ~49 overlapping
3009    /// partial traces of one curve here; the algebraic path must return exactly
3010    /// the two branches, each ON both surfaces and inside the cone's v-hint.
3011    #[test]
3012    fn parallel_cone_cylinder_gives_two_exact_branches() {
3013        use crate::traits::ParametricCurve;
3014        let cone = ConicalSurface::new(
3015            Point3::new(-5.45, -36.55, -4.85),
3016            Vec3::new(0.0, 0.0, 1.0),
3017            std::f64::consts::FRAC_PI_4,
3018        )
3019        .unwrap();
3020        let cyl = CylindricalSurface::new(
3021            Point3::new(-8.0, -34.0, -5.0),
3022            Vec3::new(0.0, 0.0, 1.0),
3023            4.45,
3024        )
3025        .unwrap();
3026        // The cone face spans z in [-3.8, -3.0]; v = (z - apex_z) / sin(45 deg).
3027        let v_hint = (1.484_924_240_492_058, 2.616_295_090_390_43);
3028        let curves = intersect_analytic_analytic_bounded(
3029            AnalyticSurface::Cone(&cone),
3030            AnalyticSurface::Cylinder(&cyl),
3031            32,
3032            Some(v_hint),
3033            Some((0.0, 2.5)),
3034        )
3035        .unwrap();
3036
3037        assert_eq!(curves.len(), 2, "expected exactly the two branches");
3038        for c in &curves {
3039            let (t0, t1) = c.curve.domain();
3040            for k in 0..=32 {
3041                let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
3042                let p = ParametricCurve::evaluate(&c.curve, t);
3043                // On the cylinder: radial distance from its axis is the radius.
3044                let radial = ((p.x() + 8.0).powi(2) + (p.y() + 34.0).powi(2)).sqrt();
3045                assert!((radial - 4.45).abs() < 1e-6, "off cylinder: {radial}");
3046                // On the cone: radial distance from its axis is z - apex_z.
3047                let cone_r = ((p.x() + 5.45).powi(2) + (p.y() + 36.55).powi(2)).sqrt();
3048                assert!((cone_r - (p.z() + 4.85)).abs() < 1e-6, "off cone at {p:?}");
3049                // Inside the cone face's own v-window (the hint is respected).
3050                assert!(p.z() >= -3.8 - 1e-9 && p.z() <= -3.0 + 1e-9, "z={}", p.z());
3051            }
3052        }
3053    }
3054
3055    /// A coaxial pair has no radical line; the algebraic path must defer rather
3056    /// than divide by a zero axis separation.
3057    #[test]
3058    fn coaxial_cone_cylinder_defers_to_other_paths() {
3059        let cone = ConicalSurface::new(
3060            Point3::new(0.0, 0.0, 0.0),
3061            Vec3::new(0.0, 0.0, 1.0),
3062            std::f64::consts::FRAC_PI_4,
3063        )
3064        .unwrap();
3065        let cyl =
3066            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 2.0)
3067                .unwrap();
3068        assert!(
3069            algebraic_parallel_cone_cylinder(&cone, &cyl, None, None)
3070                .unwrap()
3071                .is_none()
3072        );
3073    }
3074
3075    #[test]
3076    fn oblique_cone_cylinder_defers_to_other_paths() {
3077        let cone = ConicalSurface::new(
3078            Point3::new(0.0, 0.0, 0.0),
3079            Vec3::new(0.0, 0.0, 1.0),
3080            std::f64::consts::FRAC_PI_4,
3081        )
3082        .unwrap();
3083        let cyl =
3084            CylindricalSurface::new(Point3::new(3.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
3085                .unwrap();
3086        assert!(
3087            algebraic_parallel_cone_cylinder(&cone, &cyl, None, None)
3088                .unwrap()
3089                .is_none()
3090        );
3091    }
3092
3093    #[test]
3094    fn plane_torus_lobe_closes_and_stays_on_surface() {
3095        use crate::traits::ParametricCurve;
3096        let (major, minor) = (10.0, 3.0);
3097        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), major, minor).unwrap();
3098
3099        // The census cutting planes (y=-4, x=6) each cut the +x and -x tube lobes
3100        // in a CLOSED oval. The greedy marcher stops one grid step short of
3101        // closing; the wrap-close must make every fitted lobe close exactly.
3102        for (n, d) in [
3103            (Vec3::new(0.0, -1.0, 0.0), 4.0),  // y = -4
3104            (Vec3::new(-1.0, 0.0, 0.0), -6.0), // x = 6
3105            (Vec3::new(0.0, 0.0, 1.0), 0.0),   // z = 0 -> two concentric circles
3106        ] {
3107            let curves = intersect_plane_torus(&torus, n, d).unwrap();
3108            assert!(!curves.is_empty(), "plane n={n:?} d={d} found no curves");
3109            for c in &curves {
3110                let p0 = ParametricCurve::evaluate(&c.curve, 0.0);
3111                let p1 = ParametricCurve::evaluate(&c.curve, 1.0);
3112                assert!(
3113                    (p0 - p1).length() < 1e-7,
3114                    "lobe not closed: gap={} (n={n:?} d={d})",
3115                    (p0 - p1).length()
3116                );
3117                // Every fitted sample stays on the torus (shape-preserving).
3118                for k in 0..=64 {
3119                    let t = f64::from(k) / 64.0;
3120                    let p = ParametricCurve::evaluate(&c.curve, t);
3121                    assert!(
3122                        torus_implicit(p, major, minor).abs() < 1e-2,
3123                        "off-surface point {p:?} implicit={}",
3124                        torus_implicit(p, major, minor)
3125                    );
3126                }
3127            }
3128        }
3129    }
3130
3131    #[test]
3132    fn plane_torus_inner_tangent_figure_eight_stays_open() {
3133        use crate::traits::ParametricCurve;
3134        let (major, minor) = (10.0, 3.0);
3135        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), major, minor).unwrap();
3136
3137        // A plane tangent to the inner equator (x = major - minor = 7) cuts a
3138        // self-touching figure-eight. The marcher traces it as a single chain
3139        // whose end lands on the opposite lobe — FAR from its start (gap is many
3140        // point-spacings). The wrap-close must NOT force-close this into a wrong
3141        // loop; it must stay OPEN so a self-touching curve is never sealed.
3142        let curves =
3143            intersect_plane_torus(&torus, Vec3::new(-1.0, 0.0, 0.0), -(major - minor)).unwrap();
3144        assert!(!curves.is_empty(), "inner-tangent plane found no curves");
3145        let max_gap = curves
3146            .iter()
3147            .map(|c| {
3148                let p0 = ParametricCurve::evaluate(&c.curve, 0.0);
3149                let p1 = ParametricCurve::evaluate(&c.curve, 1.0);
3150                (p0 - p1).length()
3151            })
3152            .fold(0.0_f64, f64::max);
3153        assert!(
3154            max_gap > 1e-2,
3155            "figure-eight chain was wrongly force-closed (max end-gap={max_gap})"
3156        );
3157    }
3158
3159    #[test]
3160    fn line_torus_box_edge_crossing_is_exact() {
3161        // The census box edge x=6, y=-4 (z varying) crosses the torus (R=10,r=3)
3162        // at z = ±sqrt(r² − (rho−R)²), rho = hypot(6,4) ≈ 7.2111 → z ≈ ±1.1055.
3163        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 10.0, 3.0).unwrap();
3164        let ts = intersect_line_torus(
3165            &torus,
3166            Point3::new(6.0, -4.0, -5.0),
3167            Vec3::new(0.0, 0.0, 1.0),
3168        );
3169        // Vertical line through (6,-4) meets the tube twice.
3170        assert_eq!(ts.len(), 2, "expected 2 crossings, got {ts:?}");
3171        let zs: Vec<f64> = ts.iter().map(|t| -5.0 + t).collect();
3172        let rho = 6.0_f64.hypot(4.0);
3173        let z_exp = (9.0 - (rho - 10.0).powi(2)).sqrt();
3174        assert!(
3175            (zs[0] - (-z_exp)).abs() < 1e-9,
3176            "z0={} exp={}",
3177            zs[0],
3178            -z_exp
3179        );
3180        assert!((zs[1] - z_exp).abs() < 1e-9, "z1={} exp={}", zs[1], z_exp);
3181        // Each crossing lies on the torus.
3182        for &t in &ts {
3183            let p = Point3::new(6.0, -4.0, -5.0 + t);
3184            let rho = p.x().hypot(p.y());
3185            let impl_v = (rho - 10.0).hypot(p.z()) - 3.0;
3186            assert!(impl_v.abs() < 1e-9, "off-torus impl={impl_v}");
3187        }
3188    }
3189
3190    #[test]
3191    fn line_torus_miss_and_tangent() {
3192        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 10.0, 3.0).unwrap();
3193        // A vertical line at rho beyond the outer rim (x=20) misses entirely.
3194        let miss = intersect_line_torus(
3195            &torus,
3196            Point3::new(20.0, 0.0, 0.0),
3197            Vec3::new(0.0, 0.0, 1.0),
3198        );
3199        assert!(miss.is_empty(), "expected no crossings, got {miss:?}");
3200        // The z-axis (rho=0) passes through the hole — no intersection.
3201        let axis =
3202            intersect_line_torus(&torus, Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
3203        assert!(axis.is_empty(), "z-axis should miss the tube, got {axis:?}");
3204    }
3205
3206    #[test]
3207    fn dispatch_via_analytic_surface() {
3208        let cyl =
3209            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3210                .unwrap();
3211        let curves = intersect_plane_analytic(
3212            AnalyticSurface::Cylinder(&cyl),
3213            Vec3::new(0.0, 0.0, 1.0),
3214            0.0,
3215        )
3216        .unwrap();
3217        assert!(!curves.is_empty());
3218    }
3219
3220    #[test]
3221    fn perpendicular_cylinders_intersect() {
3222        let cyl_z =
3223            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3224                .unwrap();
3225        let cyl_x =
3226            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
3227                .unwrap();
3228
3229        let curves = intersect_analytic_analytic(
3230            AnalyticSurface::Cylinder(&cyl_z),
3231            AnalyticSurface::Cylinder(&cyl_x),
3232            16,
3233        )
3234        .unwrap();
3235
3236        assert!(
3237            !curves.is_empty(),
3238            "perpendicular cylinders should intersect"
3239        );
3240
3241        for c in &curves {
3242            assert!(
3243                c.points.len() >= 2,
3244                "intersection curve should have >= 2 points, got {}",
3245                c.points.len()
3246            );
3247        }
3248    }
3249
3250    /// Neither cylinder's rulings all meet the other: the curve is one loop
3251    /// joined at its two branch points.
3252    #[test]
3253    fn partially_overlapping_cylinders_meet_in_one_closed_loop() {
3254        let cyl_z =
3255            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3256                .unwrap();
3257        let cyl_x =
3258            CylindricalSurface::new(Point3::new(0.0, 1.2, 0.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
3259                .unwrap();
3260        let curves = algebraic_cylinder_cylinder(&cyl_z, &cyl_x)
3261            .unwrap()
3262            .unwrap();
3263        assert_eq!(curves.len(), 1);
3264        let curve = &curves[0].curve;
3265        let (t0, t1) = curve.domain();
3266        assert!((curve.evaluate(t0) - curve.evaluate(t1)).length() < 1e-9);
3267        let off = |p: Point3| {
3268            let on_z = (p.x().hypot(p.y()) - 1.0).abs();
3269            let on_x = ((p.y() - 1.2).hypot(p.z()) - 1.0).abs();
3270            on_z.max(on_x)
3271        };
3272        let worst = (0..=400)
3273            .map(|k| off(curve.evaluate(t0 + (t1 - t0) * f64::from(k) / 400.0)))
3274            .fold(0.0, f64::max);
3275        assert!(worst < 2e-4, "curve leaves the cylinders by {worst}");
3276    }
3277
3278    /// Near tangency the thick cylinder's window of rulings (0.02 either side
3279    /// of a quarter turn) falls between its samples; the thin one's sweep
3280    /// finds the loop.
3281    #[test]
3282    fn near_tangent_cylinders_find_their_loop_on_the_thinner_sweep() {
3283        let cyl_z =
3284            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3285                .unwrap();
3286        let cyl_x =
3287            CylindricalSurface::new(Point3::new(0.0, 1.1998, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.2)
3288                .unwrap();
3289        let curves = algebraic_cylinder_cylinder(&cyl_z, &cyl_x)
3290            .unwrap()
3291            .expect("the thin cylinder's sweep finds the loop");
3292        assert_eq!(curves.len(), 1);
3293    }
3294
3295    #[test]
3296    fn sphere_cylinder_intersect() {
3297        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 2.0).unwrap();
3298        let cyl =
3299            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3300                .unwrap();
3301
3302        let curves = intersect_analytic_analytic(
3303            AnalyticSurface::Sphere(&sphere),
3304            AnalyticSurface::Cylinder(&cyl),
3305            16,
3306        )
3307        .unwrap();
3308
3309        // A sphere of radius 2 and a cylinder of radius 1, both centered
3310        // at the origin, should intersect (the cylinder passes through
3311        // the sphere).
3312        assert!(!curves.is_empty(), "sphere and cylinder should intersect");
3313    }
3314
3315    #[test]
3316    fn exact_sphere_cylinder_coaxial_two_circles() {
3317        // Sphere r=6 at origin, coaxial cylinder r=3 along z: two latitude
3318        // circles at z = ±sqrt(36-9) = ±sqrt(27), each of radius 3.
3319        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 6.0).unwrap();
3320        let cyl =
3321            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0)
3322                .unwrap();
3323        let circles = exact_sphere_cylinder(&sphere, &cyl)
3324            .unwrap()
3325            .expect("coaxial case returns Some");
3326        assert_eq!(circles.len(), 2, "through-bore meets the sphere twice");
3327        let mut zs: Vec<f64> = circles
3328            .iter()
3329            .filter_map(|c| match c {
3330                ExactIntersectionCurve::Circle(circle) => {
3331                    assert!(
3332                        (circle.radius() - 3.0).abs() < 1e-9,
3333                        "rim radius == cyl radius"
3334                    );
3335                    Some(circle.center().z())
3336                }
3337                _ => None,
3338            })
3339            .collect();
3340        assert_eq!(zs.len(), 2, "both sections must be exact circles");
3341        zs.sort_by(f64::total_cmp);
3342        let z = 27.0_f64.sqrt();
3343        assert!((zs[0] + z).abs() < 1e-9 && (zs[1] - z).abs() < 1e-9);
3344    }
3345
3346    #[test]
3347    fn exact_sphere_cylinder_non_coaxial_defers() {
3348        // Cylinder axis offset from the sphere center → quartic curve, deferred.
3349        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 6.0).unwrap();
3350        let cyl =
3351            CylindricalSurface::new(Point3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0)
3352                .unwrap();
3353        assert!(
3354            exact_sphere_cylinder(&sphere, &cyl).unwrap().is_none(),
3355            "non-coaxial sphere/cylinder defers to the marcher"
3356        );
3357    }
3358
3359    /// Loops of an off-axis sphere-cylinder pair: `(count, worst distance
3360    /// from either surface)`.
3361    fn off_axis_loops(cylinder_origin: Point3, cylinder_radius: f64) -> (usize, f64) {
3362        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 2.0).unwrap();
3363        let cyl =
3364            CylindricalSurface::new(cylinder_origin, Vec3::new(0.0, 0.0, 1.0), cylinder_radius)
3365                .unwrap();
3366        let curves = algebraic_sphere_cylinder(&sphere, &cyl, true)
3367            .unwrap()
3368            .unwrap();
3369        let mut worst: f64 = 0.0;
3370        for c in &curves {
3371            for ip in &c.points {
3372                let on_sphere = sphere.evaluate(ip.param1.0, ip.param1.1);
3373                let on_cylinder = cyl.evaluate(ip.param2.0, ip.param2.1);
3374                worst = worst
3375                    .max((on_sphere - ip.point).length())
3376                    .max((on_cylinder - ip.point).length());
3377            }
3378            let (t0, t1) = c.curve.domain();
3379            assert!((c.curve.evaluate(t0) - c.curve.evaluate(t1)).length() < 1e-9);
3380            for k in 0..=400 {
3381                let p = c.curve.evaluate(t0 + (t1 - t0) * f64::from(k) / 400.0);
3382                let on_sphere = ((p - Point3::new(0.0, 0.0, 0.0)).length() - 2.0).abs();
3383                let on_cylinder = ((p.x() - cylinder_origin.x())
3384                    .hypot(p.y() - cylinder_origin.y())
3385                    - cylinder_radius)
3386                    .abs();
3387                worst = worst.max(on_sphere).max(on_cylinder);
3388            }
3389        }
3390        (curves.len(), worst)
3391    }
3392
3393    /// A drill off the ball's axis passes through it: an entry and an exit
3394    /// loop.
3395    #[test]
3396    fn off_axis_drill_through_a_sphere_meets_it_in_two_loops() {
3397        let (count, worst) = off_axis_loops(Point3::new(0.5, 0.0, 0.0), 0.2);
3398        assert_eq!(count, 2);
3399        assert!(worst < 1e-5, "loops leave the surfaces by {worst}");
3400    }
3401
3402    /// A cylinder over the ball's side: one loop joined at its branch points.
3403    #[test]
3404    fn cylinder_over_a_spheres_side_meets_it_in_one_loop() {
3405        let (count, worst) = off_axis_loops(Point3::new(1.8, 0.0, 0.0), 0.5);
3406        assert_eq!(count, 1);
3407        assert!(worst < 5e-4, "loop leaves the surfaces by {worst}");
3408    }
3409
3410    #[test]
3411    fn disjoint_cylinders_no_intersection() {
3412        let cyl_a =
3413            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.5)
3414                .unwrap();
3415        let cyl_b =
3416            CylindricalSurface::new(Point3::new(5.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.5)
3417                .unwrap();
3418
3419        let curves = intersect_analytic_analytic(
3420            AnalyticSurface::Cylinder(&cyl_a),
3421            AnalyticSurface::Cylinder(&cyl_b),
3422            16,
3423        )
3424        .unwrap();
3425
3426        assert!(curves.is_empty(), "disjoint cylinders should not intersect");
3427    }
3428
3429    // ── Oblique plane × cone conic (ellipse / parabola / hyperbola) ──────
3430
3431    /// Collect 3D points from a returned exact curve, sampling analytic forms.
3432    fn collect_points(curve: &ExactIntersectionCurve) -> Vec<Point3> {
3433        use crate::traits::ParametricCurve;
3434        match curve {
3435            ExactIntersectionCurve::Circle(c) => (0..=64)
3436                .map(|i| ParametricCurve::evaluate(c, TAU * f64::from(i) / 64.0))
3437                .collect(),
3438            ExactIntersectionCurve::Ellipse(e) => (0..=64)
3439                .map(|i| ParametricCurve::evaluate(e, TAU * f64::from(i) / 64.0))
3440                .collect(),
3441            ExactIntersectionCurve::Points(pts) => pts.clone(),
3442        }
3443    }
3444
3445    /// Assert every returned point lies on the plane and the cone surface, on
3446    /// the real (`v >= 0`) nappe, and within a sane axial bound.
3447    fn assert_on_plane_and_cone(
3448        curves: &[ExactIntersectionCurve],
3449        cone: &ConicalSurface,
3450        n: Vec3,
3451        d: f64,
3452        z_bound: (f64, f64),
3453    ) {
3454        assert!(!curves.is_empty(), "expected at least one section curve");
3455        let mut total = 0;
3456        for curve in curves {
3457            for p in collect_points(curve) {
3458                total += 1;
3459                let plane_err = (n.x() * p.x() + n.y() * p.y() + n.z() * p.z() - d).abs();
3460                assert!(
3461                    plane_err < 1e-9,
3462                    "point off plane by {plane_err:.2e}: {p:?}"
3463                );
3464                let (u, v) = cone.project_point(p);
3465                let q = cone.evaluate(u, v);
3466                let cone_err =
3467                    ((p.x() - q.x()).powi(2) + (p.y() - q.y()).powi(2) + (p.z() - q.z()).powi(2))
3468                        .sqrt();
3469                assert!(cone_err < 1e-7, "point off cone by {cone_err:.2e}: {p:?}");
3470                assert!(v >= -1e-9, "point on phantom nappe (v={v:.4}): {p:?}");
3471                assert!(
3472                    p.z() >= z_bound.0 - 1e-6 && p.z() <= z_bound.1 + 1e-6,
3473                    "point z={:.4} outside sane bound {z_bound:?}: {p:?}",
3474                    p.z()
3475                );
3476            }
3477        }
3478        assert!(total >= 8, "too few section points ({total})");
3479    }
3480
3481    #[test]
3482    fn oblique_plane_cone_ellipse_is_exact_and_on_both() {
3483        // 45°-half-angle cone (axis +z). A plane tilted only ~16.7° off horizontal
3484        // has plane-axis angle ≈ 73° > 45° (the cone's half-opening from axis) →
3485        // ellipse. Must come back as an exact Ellipse, fully on both surfaces.
3486        let cone = ConicalSurface::new(
3487            Point3::new(0.0, 0.0, 0.0),
3488            Vec3::new(0.0, 0.0, 1.0),
3489            std::f64::consts::FRAC_PI_4,
3490        )
3491        .unwrap();
3492        let n = Vec3::new(0.3, 0.0, 1.0).normalize().unwrap();
3493        // Plane through (0,0,5): d = n·(0,0,5).
3494        let d = n.z() * 5.0;
3495        let curves = exact_plane_cone(&cone, n, d).unwrap();
3496        assert!(
3497            curves
3498                .iter()
3499                .any(|c| matches!(c, ExactIntersectionCurve::Ellipse(_))),
3500            "oblique steep plane × cone must yield an exact Ellipse"
3501        );
3502        // The ellipse straddles z=5; with the 0.3 tilt the z-extent stays modest.
3503        assert_on_plane_and_cone(&curves, &cone, n, d, (0.0, 12.0));
3504    }
3505
3506    #[test]
3507    fn oblique_plane_cone_wrong_nappe_is_empty() {
3508        // Same ellipse-regime plane as above, but offset to the FAR side of the
3509        // apex (z=-5). The +z cone's real (v≥0) nappe is not met — only the
3510        // phantom v<0 nappe — so the result must be EMPTY, not a phantom ellipse.
3511        let cone = ConicalSurface::new(
3512            Point3::new(0.0, 0.0, 0.0),
3513            Vec3::new(0.0, 0.0, 1.0),
3514            std::f64::consts::FRAC_PI_4,
3515        )
3516        .unwrap();
3517        let n = Vec3::new(0.3, 0.0, 1.0).normalize().unwrap();
3518        let d = n.z() * -5.0;
3519        let curves = exact_plane_cone(&cone, n, d).unwrap();
3520        assert!(
3521            curves.is_empty(),
3522            "plane on the phantom-nappe side must yield no real curve, got {}",
3523            curves.len()
3524        );
3525    }
3526
3527    #[test]
3528    fn oblique_plane_cone_parabola_on_both_single_branch() {
3529        // Plane normal at exactly 45° to the axis (= the cone half-opening) → the
3530        // plane is parallel to a generator → parabola. One unbounded branch.
3531        let cone = ConicalSurface::new(
3532            Point3::new(0.0, 0.0, 0.0),
3533            Vec3::new(0.0, 0.0, 1.0),
3534            std::f64::consts::FRAC_PI_4,
3535        )
3536        .unwrap();
3537        let n = Vec3::new(1.0, 0.0, 1.0).normalize().unwrap();
3538        let d = n.x() * 3.0 + n.z() * 3.0; // through (3,0,3)
3539        let curves = exact_plane_cone(&cone, n, d).unwrap();
3540        assert_eq!(
3541            curves.len(),
3542            1,
3543            "a parabola is a single branch, got {}",
3544            curves.len()
3545        );
3546        // Bounded by r_max = 32·|e|; |e| here is O(few), so allow a wide z window.
3547        assert_on_plane_and_cone(&curves, &cone, n, d, (0.0, 400.0));
3548    }
3549
3550    #[test]
3551    fn oblique_plane_cone_hyperbola_real_nappe_only() {
3552        // Faithful scooplabel lip-foot geometry: a 45° cone with axis −z and
3553        // apex at (−59,−59,15.85) (a bin corner), cut by the upper ramp tread
3554        // plane n=(0,0.99518,0.09802), d=−58.36056. The plane is nearly parallel
3555        // to the axis (cos≈0.098) → plane-axis angle ≈ 5.6° < 45° → hyperbola.
3556        // The downward real nappe is hit by exactly one branch; the phantom
3557        // upward nappe (and the asymptote runaway) must NOT appear, and the arc
3558        // must stay near the apex (the plane is ~1.2 mm from it).
3559        let cone = ConicalSurface::new(
3560            Point3::new(-59.0, -59.0, 15.85),
3561            Vec3::new(0.0, 0.0, -1.0),
3562            std::f64::consts::FRAC_PI_4,
3563        )
3564        .unwrap();
3565        let n = Vec3::new(0.0, 0.995_18, 0.098_02).normalize().unwrap();
3566        let d = -58.360_56;
3567        let cos_theta = n.dot(cone.axis()).abs();
3568        assert!(cos_theta < 0.2, "expected a shallow (hyperbola) plane");
3569        let curves = exact_plane_cone(&cone, n, d).unwrap();
3570        // Real downward nappe only: never above the apex (z=15.85). The vertex is
3571        // ~1.2 mm from the apex, so the bounded arc stays within a few mm of it.
3572        assert_on_plane_and_cone(&curves, &cone, n, d, (5.0, 15.85));
3573        // Every returned curve is sampled Points (no false Circle/Ellipse).
3574        for c in &curves {
3575            assert!(
3576                matches!(c, ExactIntersectionCurve::Points(_)),
3577                "hyperbola must be sampled Points, not a closed conic"
3578            );
3579        }
3580    }
3581}