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        | (AnalyticSurface::Cylinder(c), AnalyticSurface::Sphere(s)) => {
1499            algebraic_sphere_cylinder(s, c)
1500        }
1501        (AnalyticSurface::Cone(c1), AnalyticSurface::Cone(c2)) => algebraic_cone_cone(c1, c2),
1502        _ => Ok(None),
1503    }
1504}
1505
1506/// Exact coaxial cone-cone intersection: returns the shared circle.
1507///
1508/// Two cones that share an axis are concentric circles at every axial
1509/// station, so they meet only where their radii are equal. Each cone's
1510/// radius is linear in the axial coordinate `t` (measured along the shared
1511/// axis from cone 1's apex): `r1 = m1·t` and `r2 = m2·σ·(t − d2)`, where
1512/// `m_i = cot(half_angle_i)`, `σ = sign(axis2·axis1)`, and `d2` is cone 2's
1513/// apex position in that coordinate. Equating gives a single crossing `t*`
1514/// → one circle (the shared rim). The general marcher mishandles this case:
1515/// at the radii-crossing the surfaces are nearly tangent, so a grid-seeded
1516/// march fragments the clean circle into dozens of degenerate micro-curves.
1517///
1518/// Returns `Some(vec![circle])` for a genuine crossing, `Some(vec![])` when
1519/// the cones do not meet (parallel radius lines or a crossing on the wrong
1520/// nappe), and `None` for the identical-cone overlap or a degenerate
1521/// (near-flat) cone — both of which fall through to the general path.
1522/// Parallel-but-offset axes with equal half-angle tangents reduce to a
1523/// radical-plane conic (`offset_parallel_cone_cone`); other offset
1524/// configurations defer to the marcher with `None`.
1525///
1526/// # Errors
1527///
1528/// Returns [`MathError`] if the shared-rim `Circle3D` cannot be constructed
1529/// (e.g. a non-finite center or radius from a malformed cone).
1530pub fn exact_cone_cone(
1531    c1: &ConicalSurface,
1532    c2: &ConicalSurface,
1533) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1534    let axis = c1.axis();
1535    let axis2 = c2.axis();
1536
1537    // Coaxial check: parallel axes and the second apex lies on the first axis.
1538    if axis.dot(axis2).abs() < 1.0 - 1e-10 {
1539        return Ok(None); // Non-coaxial: quartic curve, let the marcher handle.
1540    }
1541    let apex1 = c1.apex();
1542    let apex2 = c2.apex();
1543    let delta = apex2 - apex1;
1544    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1545    let along = delta_v.dot(axis);
1546    if (delta_v - axis * along).length() > 1e-8 {
1547        return offset_parallel_cone_cone(c1, c2);
1548    }
1549
1550    let (s1, s2) = (c1.half_angle().sin(), c2.half_angle().sin());
1551    if s1.abs() < 1e-12 || s2.abs() < 1e-12 {
1552        return Ok(None); // Degenerate (near-flat) cone.
1553    }
1554    let m1 = c1.half_angle().cos() / s1;
1555    let m2 = c2.half_angle().cos() / s2;
1556    let sigma = if axis.dot(axis2) >= 0.0 { 1.0 } else { -1.0 };
1557    let d2 = along; // apex2 position along `axis`, measured from apex1.
1558
1559    let denom = m1 - m2 * sigma;
1560    if denom.abs() < 1e-12 {
1561        // Parallel radius lines: identical cones (coincident apex, same opening)
1562        // overlap — defer to the general/same-domain path; otherwise no meeting.
1563        if sigma > 0.0 && d2.abs() < 1e-9 {
1564            return Ok(None);
1565        }
1566        return Ok(Some(vec![]));
1567    }
1568
1569    let t_star = (-m2 * sigma * d2) / denom;
1570    let radius = m1 * t_star;
1571    if radius < 1e-12 {
1572        return Ok(Some(vec![])); // Crossing on the wrong nappe / no real circle.
1573    }
1574
1575    let center = Point3::new(
1576        apex1.x() + axis.x() * t_star,
1577        apex1.y() + axis.y() * t_star,
1578        apex1.z() + axis.z() * t_star,
1579    );
1580    let circle = Circle3D::new(center, axis, radius)?;
1581    Ok(Some(vec![ExactIntersectionCurve::Circle(circle)]))
1582}
1583
1584/// Parallel-axis (or anti-parallel), offset-apex cones with equal half-angle
1585/// tangents: subtracting the two quadric equations cancels both the radial
1586/// and the axial quadratic terms (their coefficients depend only on
1587/// `tan²(half_angle)`), so every intersection point lies on a plane — the
1588/// degenerate member of the quadric pencil — and plane ∩ cone is an exact
1589/// conic. The gridfinity spacer lip fuse hits this exactly: opposed 45°
1590/// corner cones offset 0.25mm, which the marcher shreds into ~64 closed
1591/// micro-loops per pair (#1570). Unequal angles keep a genuine quadratic
1592/// term, and an unbounded section (hyperbola/parabola) has no closed-form
1593/// win over the marcher — both defer with `None`.
1594fn offset_parallel_cone_cone(
1595    c1: &ConicalSurface,
1596    c2: &ConicalSurface,
1597) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1598    if c1.half_angle().sin().abs() < 1e-12 || c2.half_angle().sin().abs() < 1e-12 {
1599        return Ok(None); // Degenerate (near-flat) cone, as in the coaxial path.
1600    }
1601    let t1 = c1.half_angle().tan();
1602    let t2 = c2.half_angle().tan();
1603    if !t1.is_finite() || !t2.is_finite() {
1604        return Ok(None);
1605    }
1606    if (t1 - t2).abs() > 1e-9 * (1.0 + t1.abs().max(t2.abs())) {
1607        return Ok(None);
1608    }
1609
1610    let w = c1.axis();
1611    let apex1 = c1.apex();
1612    let apex2 = c2.apex();
1613    let delta = apex2 - apex1;
1614    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1615    let s = delta_v.dot(w);
1616    let tm = 0.5 * (t1 + t2);
1617    let k = 1.0 + tm * tm;
1618
1619    // In the apex1 frame each cone is |P|² − k(P·w)² = 0 (shifted by δ for
1620    // cone 2; the axis SIGN drops out since only (P·w)² appears). Their
1621    // difference: P·(2δ − 2ksw) = |δ|² − ks².
1622    let n = (delta_v - w * (k * s)) * 2.0;
1623    let n_len = n.length();
1624    if n_len < 1e-12 {
1625        return Ok(None);
1626    }
1627    let n_hat = n * (1.0 / n_len);
1628    let d = (dot_np(n, apex1) + delta_v.dot(delta_v) - k * s * s) / n_len;
1629
1630    // `exact_plane_cone` already rejects sections on cone 1's phantom nappe;
1631    // cone 2's nappe must be checked here. A conic on the shared quadric
1632    // pencil cannot cross between nappes except exactly through apex 2, so
1633    // sampled quarter-points either all pass or all fail; a mixed verdict
1634    // means an apex-touching degeneracy — defer to the marcher.
1635    let axis2 = c2.axis();
1636    let scale = 1.0 + delta_v.length();
1637    let mut out = Vec::new();
1638    for curve in exact_plane_cone(c1, n_hat, d)? {
1639        let samples: Vec<Point3> = match &curve {
1640            ExactIntersectionCurve::Circle(c) => (0..4)
1641                .map(|i| crate::traits::ParametricCurve::evaluate(c, TAU * f64::from(i) / 4.0))
1642                .collect(),
1643            ExactIntersectionCurve::Ellipse(e) => (0..4)
1644                .map(|i| crate::traits::ParametricCurve::evaluate(e, TAU * f64::from(i) / 4.0))
1645                .collect(),
1646            ExactIntersectionCurve::Points(_) => return Ok(None),
1647        };
1648        let on_real_nappe = |p: &Point3| {
1649            let rel = *p - apex2;
1650            Vec3::new(rel.x(), rel.y(), rel.z()).dot(axis2) >= -1e-9 * scale
1651        };
1652        let hits = samples.iter().filter(|p| on_real_nappe(p)).count();
1653        match hits {
1654            0 => {}
1655            4 => out.push(curve),
1656            _ => return Ok(None),
1657        }
1658    }
1659    Ok(Some(out))
1660}
1661
1662/// Exact coaxial cone-cylinder intersection: returns the shared circle.
1663///
1664/// A cone and a cylinder sharing an axis are concentric circles at every
1665/// axial station, so they meet only where the cone's radius equals the
1666/// cylinder's. The cone radius is linear in the axial coordinate `t` from its
1667/// apex (`r = m·t`, `m = cot(half_angle)`), the cylinder radius is the
1668/// constant `R`, so `m·t = R` gives a single crossing `t*` → one circle. This
1669/// is the gridfinity lip's top knife edge (inner tapered corner = cone, outer
1670/// corner = cylinder, concentric, radii matching at `Z_PEAK`); the general
1671/// marcher fragments that near-tangent contact into dozens of degenerate
1672/// micro-curves.
1673///
1674/// Returns `Some(vec![circle])` for a genuine crossing, `Some(vec![])` when
1675/// the crossing degenerates to the apex, and `None` (defer to the marcher)
1676/// when the surfaces are not coaxial or the cone is near-flat / near-axial.
1677///
1678/// # Errors
1679///
1680/// Returns [`MathError`] if the shared `Circle3D` cannot be constructed.
1681pub fn exact_cone_cylinder(
1682    cone: &ConicalSurface,
1683    cyl: &CylindricalSurface,
1684) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1685    let axis = cone.axis();
1686    let cyl_axis = cyl.axis();
1687
1688    // Coaxial check: parallel axes and the cone apex on the cylinder's axis.
1689    if axis.dot(cyl_axis).abs() < 1.0 - 1e-10 {
1690        return Ok(None);
1691    }
1692    let apex = cone.apex();
1693    let delta = apex - cyl.origin();
1694    let delta_v = Vec3::new(delta.x(), delta.y(), delta.z());
1695    let along = delta_v.dot(cyl_axis);
1696    if (delta_v - cyl_axis * along).length() > 1e-8 {
1697        return Ok(None);
1698    }
1699
1700    let s = cone.half_angle().sin();
1701    if s.abs() < 1e-12 {
1702        return Ok(None); // near-flat cone.
1703    }
1704    let m = cone.half_angle().cos() / s; // dr/dt along the cone axis.
1705    if m.abs() < 1e-12 {
1706        return Ok(None); // near-axial cone: radius ~constant.
1707    }
1708
1709    let t_star = cyl.radius() / m; // where the cone radius m·t equals R.
1710    if t_star.abs() < 1e-12 {
1711        return Ok(Some(vec![])); // crossing at the apex — no real circle.
1712    }
1713    let center = Point3::new(
1714        apex.x() + axis.x() * t_star,
1715        apex.y() + axis.y() * t_star,
1716        apex.z() + axis.z() * t_star,
1717    );
1718    let circle = Circle3D::new(center, axis, cyl.radius())?;
1719    Ok(Some(vec![ExactIntersectionCurve::Circle(circle)]))
1720}
1721
1722/// Algebraic cone-cone intersection (NURBS form for the general bounded
1723/// path). Delegates to [`exact_cone_cone`] and samples each exact conic
1724/// (coaxial circle or offset-parallel radical-plane ellipse) into an
1725/// interpolated NURBS `IntersectionCurve`, mirroring the
1726/// sphere-cylinder algebraic path. phase FF prefers the exact circle form
1727/// directly (so the section edge links to the coincident boundary), but a
1728/// caller of `intersect_analytic_analytic_bounded` still gets one clean
1729/// curve instead of the marcher's fragments.
1730fn algebraic_cone_cone(
1731    c1: &ConicalSurface,
1732    c2: &ConicalSurface,
1733) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1734    let Some(exacts) = exact_cone_cone(c1, c2)? else {
1735        return Ok(None);
1736    };
1737    let mut curves = Vec::new();
1738    for exact in exacts {
1739        let n_samples = 33;
1740        let mut positions = Vec::with_capacity(n_samples);
1741        let mut points = Vec::with_capacity(n_samples);
1742        #[allow(clippy::cast_precision_loss)]
1743        for i in 0..n_samples {
1744            let theta = TAU * i as f64 / (n_samples - 1) as f64;
1745            let pt = match &exact {
1746                ExactIntersectionCurve::Circle(circle) => {
1747                    crate::traits::ParametricCurve::evaluate(circle, theta)
1748                }
1749                ExactIntersectionCurve::Ellipse(ellipse) => {
1750                    crate::traits::ParametricCurve::evaluate(ellipse, theta)
1751                }
1752                ExactIntersectionCurve::Points(_) => break,
1753            };
1754            positions.push(pt);
1755            points.push(IntersectionPoint {
1756                point: pt,
1757                param1: (0.0, 0.0),
1758                param2: (0.0, 0.0),
1759            });
1760        }
1761        if positions.is_empty() {
1762            continue;
1763        }
1764        let degree = 3.min(positions.len() - 1);
1765        let curve = interpolate(&positions, degree)?;
1766        curves.push(IntersectionCurve { curve, points });
1767    }
1768    Ok(Some(curves))
1769}
1770
1771/// Exact coaxial sphere-cylinder intersection: returns the shared circle(s).
1772///
1773/// A sphere of radius `R` centered at `C` and a cylinder of radius `r` whose
1774/// axis passes through `C` meet in concentric circles of radius `r` at the
1775/// axial stations where `sqrt(R² − z²) = r`, i.e. `z = ±sqrt(R² − r²)`
1776/// measured from `C` along the axis. A proper crossing yields two circles; a
1777/// tangent contact (`r = R`) yields one; a cylinder wider than the sphere, or
1778/// a non-coaxial configuration (quartic curve), yields none/defers.
1779///
1780/// Mirrors [`exact_cone_cylinder`] so phase FF can emit the section as an
1781/// exact `Circle3D` (which the closed-circle split + seam adoption recognise)
1782/// rather than the marcher's NURBS fragments.
1783///
1784/// Returns `Some(vec![..])` (0, 1, or 2 circles) for the coaxial case, and
1785/// `None` (defer to the general marcher) when the axes are not coaxial.
1786///
1787/// # Errors
1788///
1789/// Returns [`MathError`] if a shared `Circle3D` cannot be constructed.
1790pub fn exact_sphere_cylinder(
1791    sphere: &SphericalSurface,
1792    cyl: &CylindricalSurface,
1793) -> Result<Option<Vec<ExactIntersectionCurve>>, MathError> {
1794    let sc = sphere.center();
1795    let r_sphere = sphere.radius();
1796    let co = cyl.origin();
1797    let axis = cyl.axis();
1798    let r_cyl = cyl.radius();
1799
1800    // Project sphere center onto the cylinder axis.
1801    let delta = sc - co;
1802    let delta_vec = Vec3::new(delta.x(), delta.y(), delta.z());
1803    let along = delta_vec.dot(axis);
1804    let perp_vec = delta_vec - axis * along;
1805    let d_perp = perp_vec.length();
1806
1807    // Non-coaxial sphere-cylinder intersections produce quartic curves;
1808    // defer those to the general marcher.
1809    if d_perp > 1e-7 {
1810        return Ok(None);
1811    }
1812
1813    // Coaxial: the sphere center lies on the cylinder axis. No real circle
1814    // when the cylinder is wider than the sphere or they are tangent-internal.
1815    if r_cyl > r_sphere + 1e-10 {
1816        return Ok(Some(vec![]));
1817    }
1818    let z_sq = r_sphere * r_sphere - r_cyl * r_cyl;
1819    if z_sq < 0.0 {
1820        return Ok(Some(vec![]));
1821    }
1822    let z = z_sq.sqrt();
1823
1824    // The sphere center projected onto the axis is the midpoint of the two
1825    // section circles, each offset by ±z along the axis with radius `r_cyl`.
1826    let center_axis_pt = Point3::new(
1827        co.x() + axis.x() * along,
1828        co.y() + axis.y() * along,
1829        co.z() + axis.z() * along,
1830    );
1831
1832    let mut circles = Vec::new();
1833    let offsets: &[f64] = if z < 1e-10 { &[0.0] } else { &[z, -z] };
1834    for &z_offset in offsets {
1835        let center = Point3::new(
1836            center_axis_pt.x() + axis.x() * z_offset,
1837            center_axis_pt.y() + axis.y() * z_offset,
1838            center_axis_pt.z() + axis.z() * z_offset,
1839        );
1840        let circle = Circle3D::new(center, axis, r_cyl)?;
1841        circles.push(ExactIntersectionCurve::Circle(circle));
1842    }
1843    Ok(Some(circles))
1844}
1845
1846/// Algebraic sphere-cylinder intersection (NURBS form for the general bounded
1847/// path). Delegates to [`exact_sphere_cylinder`] and samples each exact circle
1848/// into an interpolated NURBS `IntersectionCurve`. phase FF prefers the exact
1849/// circle form directly (so the section edge links to the coincident boundary
1850/// and the closed-circle splitter can carve the spherical band), but a caller
1851/// of `intersect_analytic_analytic_bounded` still gets clean curves instead of
1852/// the marcher's fragments.
1853fn algebraic_sphere_cylinder(
1854    sphere: &SphericalSurface,
1855    cyl: &CylindricalSurface,
1856) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1857    let Some(exacts) = exact_sphere_cylinder(sphere, cyl)? else {
1858        return Ok(None);
1859    };
1860
1861    let mut curves = Vec::new();
1862    for exact in exacts {
1863        let ExactIntersectionCurve::Circle(circle) = exact else {
1864            continue;
1865        };
1866        let n_samples = 33;
1867        let mut points = Vec::with_capacity(n_samples);
1868        let mut positions = Vec::with_capacity(n_samples);
1869        #[allow(clippy::cast_precision_loss)]
1870        for i in 0..n_samples {
1871            let theta = TAU * i as f64 / (n_samples - 1) as f64;
1872            let pt = crate::traits::ParametricCurve::evaluate(&circle, theta);
1873            positions.push(pt);
1874            points.push(IntersectionPoint {
1875                point: pt,
1876                param1: (0.0, 0.0),
1877                param2: (0.0, 0.0),
1878            });
1879        }
1880        let degree = 3.min(positions.len() - 1);
1881        let curve = interpolate(&positions, degree)?;
1882        curves.push(IntersectionCurve { curve, points });
1883    }
1884
1885    Ok(Some(curves))
1886}
1887
1888/// Algebraic cylinder-cylinder intersection for non-coaxial cylinders.
1889///
1890/// For two cylinders with axes that are NOT parallel, the intersection
1891/// consists of up to two closed space curves. These are found by
1892/// parameterizing one cylinder's angular coordinate `u ∈ [0, 2π]` and
1893/// solving a quadratic in the axial parameter `v` to find where each
1894/// "ring" of cylinder A sits on cylinder B.
1895///
1896/// The quadratic is:
1897///   `v²·(1 - α²) + 2v·(q·a₁ - α·q·a₂) + (|q|² - (q·a₂)² - r₂²) = 0`
1898/// where `α = a₁·a₂`, `q(u)` is the radial point on cylinder 1 minus
1899/// cylinder 2's origin, `a₁`/`a₂` are the cylinder axes, and `r₂` is
1900/// cylinder 2's radius.
1901#[allow(clippy::too_many_lines, clippy::unnecessary_wraps)]
1902fn algebraic_cylinder_cylinder(
1903    c1: &CylindricalSurface,
1904    c2: &CylindricalSurface,
1905) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
1906    const SAMPLES: usize = 128;
1907    let alpha = c1.axis().dot(c2.axis());
1908    let a_coeff = 1.0 - alpha * alpha;
1909
1910    // Should only be called for non-parallel axes.
1911    if a_coeff.abs() < 1e-12 {
1912        return Ok(None);
1913    }
1914
1915    let r1 = c1.radius();
1916    let r2 = c2.radius();
1917    let o1 = c1.origin();
1918    let o2 = c2.origin();
1919    let a1 = c1.axis();
1920    let a2 = c2.axis();
1921
1922    // Separation check: distance between axes vs sum of radii.
1923    // Closest approach of two skew lines:
1924    let delta = Vec3::new(o1.x() - o2.x(), o1.y() - o2.y(), o1.z() - o2.z());
1925    let cross = a1.cross(a2);
1926    let cross_len = cross.length();
1927    if cross_len > 1e-12 {
1928        let axis_dist = delta.dot(cross).abs() / cross_len;
1929        if axis_dist > r1 + r2 + Tolerance::new().linear {
1930            return Ok(Some(vec![])); // No intersection
1931        }
1932    }
1933
1934    // Solve every ruling of one cylinder against the other: a ruling
1935    // `c(u) + v·a` meets the other cylinder where the quadratic in v has real
1936    // roots. When EVERY ruling of the swept cylinder meets the other (the
1937    // thinner of two crossing tubes), the two roots trace two whole closed
1938    // loops, the curve's two components. Swept the other way only a window
1939    // of rulings meets, and each root traces an open arc of one loop. The
1940    // samples sit half a step off u = 0 so the branches of a self-touching
1941    // curve (equal radii) do not share a sample.
1942    let lin_tol = Tolerance::new().linear;
1943    let ruling = |sweep: &CylindricalSurface, other: &CylindricalSurface, u: f64| {
1944        let (o, a, r2) = (other.origin(), other.axis(), other.radius());
1945        let alpha = sweep.axis().dot(a);
1946        let quad = 1.0 - alpha * alpha;
1947        let base = sweep.evaluate(u, 0.0);
1948        let q = Vec3::new(base.x() - o.x(), base.y() - o.y(), base.z() - o.z());
1949        let (q_a1, q_a2) = (q.dot(sweep.axis()), q.dot(a));
1950        let b = 2.0 * (q_a1 - alpha * q_a2);
1951        let c = q.dot(q) - q_a2 * q_a2 - r2 * r2;
1952        let disc = b * b - 4.0 * quad * c;
1953        let root = disc.max(0.0).sqrt();
1954        (disc, (-b + root) / (2.0 * quad), (-b - root) / (2.0 * quad))
1955    };
1956    #[allow(clippy::cast_precision_loss)]
1957    let u_at = |i: usize| TAU * (i as f64 + 0.5) / SAMPLES as f64;
1958    let sweep_samples = |sweep: &CylindricalSurface, other: &CylindricalSurface| {
1959        (0..SAMPLES)
1960            .map(|i| {
1961                let (disc, vp, vm) = ruling(sweep, other, u_at(i));
1962                (disc >= -lin_tol)
1963                    .then(|| (sweep.evaluate(u_at(i), vp), sweep.evaluate(u_at(i), vm)))
1964            })
1965            .collect::<Vec<_>>()
1966    };
1967    let closed_loops = |samples: &[Option<(Point3, Point3)>]| -> Vec<Vec<Point3>> {
1968        let mut plus: Vec<Point3> = samples.iter().flatten().map(|s| s.0).collect();
1969        let mut minus: Vec<Point3> = samples.iter().flatten().map(|s| s.1).collect();
1970        plus.push(plus[0]);
1971        minus.push(minus[0]);
1972        vec![plus, minus]
1973    };
1974
1975    let samples1 = sweep_samples(c1, c2);
1976    let loops = if samples1.iter().all(Option::is_some) {
1977        closed_loops(&samples1)
1978    } else {
1979        let samples2 = sweep_samples(c2, c1);
1980        if samples2.iter().all(Option::is_some) {
1981            closed_loops(&samples2)
1982        } else {
1983            // Partial overlap: each cyclic window of the swept cylinder's
1984            // rulings that meets the other carries one loop, out along one
1985            // root and back along the other, the two joined where the
1986            // discriminant vanishes. A window the sampling misses on both
1987            // sweeps (near tangency) defers to the general marcher.
1988            let (sweep, other, samples) = if samples1.iter().any(Option::is_some) {
1989                (c1, c2, samples1)
1990            } else if samples2.iter().any(Option::is_some) {
1991                (c2, c1, samples2)
1992            } else {
1993                return Ok(None);
1994            };
1995            let branch_point = |inside: usize, outside: usize| -> Point3 {
1996                let (mut lo, mut hi) = (u_at(inside), u_at(outside));
1997                if (hi - lo).abs() > std::f64::consts::PI {
1998                    hi += if hi < lo { TAU } else { -TAU };
1999                }
2000                for _ in 0..60 {
2001                    let mid = 0.5 * (lo + hi);
2002                    if ruling(sweep, other, mid).0 >= 0.0 {
2003                        lo = mid;
2004                    } else {
2005                        hi = mid;
2006                    }
2007                }
2008                let (_, vp, vm) = ruling(sweep, other, lo);
2009                sweep.evaluate(lo, 0.5 * (vp + vm))
2010            };
2011            let Some(first_gap) = samples.iter().position(Option::is_none) else {
2012                return Ok(None);
2013            };
2014            let mut loops = Vec::new();
2015            let mut k = 0;
2016            while k < SAMPLES {
2017                let i = (first_gap + k) % SAMPLES;
2018                if samples[i].is_none() {
2019                    k += 1;
2020                    continue;
2021                }
2022                let start = i;
2023                let mut run = Vec::new();
2024                while k < SAMPLES {
2025                    let j = (first_gap + k) % SAMPLES;
2026                    let Some(pair) = samples[j] else { break };
2027                    run.push(pair);
2028                    k += 1;
2029                }
2030                let end = (start + run.len() - 1) % SAMPLES;
2031                let head = branch_point(start, (start + SAMPLES - 1) % SAMPLES);
2032                let tail = branch_point(end, (end + 1) % SAMPLES);
2033                let mut pts = vec![head];
2034                pts.extend(run.iter().map(|p| p.0));
2035                pts.push(tail);
2036                pts.extend(run.iter().rev().map(|p| p.1));
2037                pts.push(head);
2038                loops.push(pts);
2039            }
2040            if loops.is_empty() {
2041                return Ok(None);
2042            }
2043            loops
2044        }
2045    };
2046
2047    let mut curves = Vec::new();
2048    for pts in &loops {
2049        if pts.len() < 4 {
2050            continue;
2051        }
2052        let ipts: Vec<IntersectionPoint> = pts
2053            .iter()
2054            .map(|&p| {
2055                let (u1, v1) = c1.project_point(p);
2056                let (u2, v2) = c2.project_point(p);
2057                IntersectionPoint {
2058                    point: p,
2059                    param1: (u1, v1),
2060                    param2: (u2, v2),
2061                }
2062            })
2063            .collect();
2064        let degree = 3.min(pts.len() - 1);
2065        if let Ok(curve) = interpolate(pts, degree) {
2066            curves.push(IntersectionCurve {
2067                curve,
2068                points: ipts,
2069            });
2070        }
2071    }
2072
2073    Ok(Some(curves))
2074}
2075
2076/// Algebraic cone-cylinder intersection for PARALLEL (or antiparallel) axes.
2077///
2078/// When the axes are parallel, every plane perpendicular to them cuts the cone
2079/// in a circle of radius `rho = v * cos(half_angle)` about a FIXED centre and
2080/// the cylinder in a circle of radius `R` about a second FIXED centre, so the
2081/// axis separation `d` is constant in `v`. Two coplanar circles meet at
2082/// `u = phi0 +/- acos((d^2 + rho^2 - R^2) / (2*d*rho))`, giving two branches
2083/// parameterised exactly by the cone's own `v`. The branches exist only where
2084/// `rho` lies in `[|d - R|, d + R]`, which bounds the curve naturally.
2085///
2086/// This replaces the general grid-seeded marcher for the configuration, which
2087/// mis-handles it badly: seeds are accepted anywhere within half the surface
2088/// diagonal of the partner, the march-result dedup only consumes seeds the
2089/// traced polyline passes near, and the survivors are dozens of overlapping
2090/// partial traces of the same curve. Those fragments carry no usable in-face
2091/// span, so a cone corner-round crossed by a boss cylinder never splits (a
2092/// counterbore/countersink meeting a pad — the gridfinity lightweight base).
2093///
2094/// Returns `None` (defer to the caller's other paths) when the axes are not
2095/// parallel, or when they are coaxial — a coaxial pair degenerates to shared
2096/// circles, which [`exact_cone_cylinder`] emits exactly and phase FF calls
2097/// directly. Note that `intersect_analytic_analytic_bounded` does NOT consult
2098/// `exact_cone_cylinder`, so a coaxial pair reaching this path through that
2099/// caller falls through to the marcher; only the FF path gets the exact circles.
2100// Result-wrapped to match the other `try_algebraic_intersection` arms' shape.
2101#[allow(clippy::unnecessary_wraps)]
2102fn algebraic_parallel_cone_cylinder(
2103    cone: &ConicalSurface,
2104    cyl: &CylindricalSurface,
2105    v_range_cone: Option<(f64, f64)>,
2106    v_range_cyl: Option<(f64, f64)>,
2107) -> Result<Option<Vec<IntersectionCurve>>, MathError> {
2108    let axis = cone.axis();
2109    if axis.dot(cyl.axis()).abs() < 1.0 - 1e-10 {
2110        return Ok(None); // Skew/oblique — general marcher.
2111    }
2112
2113    let apex = cone.apex();
2114    let delta = cyl.origin() - apex;
2115    let along = delta.dot(axis);
2116    let perp = delta - axis * along;
2117    let d = perp.length();
2118    if d < 1e-9 {
2119        return Ok(None); // Coaxial — `exact_cone_cylinder` owns this.
2120    }
2121
2122    let (e1, e2) = (cone.x_axis(), cone.y_axis());
2123    let phi0 = perp.dot(e2).atan2(perp.dot(e1));
2124
2125    let (sin_t, cos_t) = cone.half_angle().sin_cos();
2126    if cos_t < 1e-12 || sin_t < 1e-12 {
2127        return Ok(None);
2128    }
2129    let r = cyl.radius();
2130
2131    // Branch existence: |d - R| <= rho <= d + R, with rho = v * cos(half_angle).
2132    let mut v_min = (d - r).abs() / cos_t;
2133    let mut v_max = (d + r) / cos_t;
2134    if v_max <= v_min {
2135        return Ok(Some(vec![]));
2136    }
2137
2138    // Narrow the sampled span to the faces' own extents so the fixed sample
2139    // budget resolves the in-face part of the curve rather than spreading over
2140    // a loop that mostly lies off both patches. A face's crossing can be a
2141    // fraction of a degree of the cone's sweep (the corner-round case above),
2142    // and an unnarrowed sampling puts fewer than one sample across it.
2143    let mut lo = v_min;
2144    let mut hi = v_max;
2145    // Clip EXACTLY to the hints, not to a padded window: an endpoint that lands
2146    // exactly on the face's own v-limit lies ON that boundary rim, so the
2147    // downstream pave machinery anchors it to the rim edge instead of leaving
2148    // the section dangling just past the face.
2149    if let Some((a, b)) = v_range_cone {
2150        let (a, b) = if a <= b { (a, b) } else { (b, a) };
2151        lo = lo.max(a);
2152        hi = hi.min(b);
2153    }
2154    if let Some((a, b)) = v_range_cyl {
2155        // The cylinder's v is a signed distance along its axis from its origin;
2156        // convert both ends to the cone's v via the shared axial direction.
2157        let flip = cyl.axis().dot(axis);
2158        let to_cone_v = |cv: f64| (along + cv * flip) / sin_t;
2159        let (a, b) = (to_cone_v(a), to_cone_v(b));
2160        let (a, b) = if a <= b { (a, b) } else { (b, a) };
2161        lo = lo.max(a);
2162        hi = hi.min(b);
2163    }
2164    v_min = lo.max(v_min);
2165    v_max = hi.min(v_max);
2166    if v_max - v_min <= 1e-12 {
2167        return Ok(Some(vec![]));
2168    }
2169
2170    let n_samples = 128;
2171    let mut plus: Vec<Point3> = Vec::with_capacity(n_samples + 1);
2172    let mut minus: Vec<Point3> = Vec::with_capacity(n_samples + 1);
2173    #[allow(clippy::cast_precision_loss)]
2174    for i in 0..=n_samples {
2175        let v = v_min + (v_max - v_min) * (i as f64) / (n_samples as f64);
2176        let rho = v * cos_t;
2177        if rho < 1e-12 {
2178            // The apex. `cos_alpha` has rho in its denominator, so it is only
2179            // meaningful in the limit: it tends to 0 (alpha -> pi/2) when the
2180            // cylinder passes exactly through the apex (d == R), and diverges
2181            // otherwise — where the clamp would manufacture a spurious alpha of
2182            // 0 or pi. So keep the apex only in the d == R case, where it is a
2183            // genuine point of the intersection and the shared endpoint at
2184            // which the two branches meet.
2185            if (d - r).abs() < 1e-12 {
2186                let apex = cone.evaluate(phi0, v);
2187                plus.push(apex);
2188                minus.push(apex);
2189            }
2190            continue;
2191        }
2192        let cos_alpha = ((d * d + rho * rho - r * r) / (2.0 * d * rho)).clamp(-1.0, 1.0);
2193        let alpha = cos_alpha.acos();
2194        plus.push(cone.evaluate(phi0 + alpha, v));
2195        minus.push(cone.evaluate(phi0 - alpha, v));
2196    }
2197
2198    let mut curves = Vec::new();
2199    for pts in [&plus, &minus] {
2200        // Fewer than four samples in range means this branch does not cross the
2201        // bounded region at all (the other branch may still).
2202        if pts.len() < 4 {
2203            continue;
2204        }
2205        let ipts: Vec<IntersectionPoint> = pts
2206            .iter()
2207            .map(|&p| IntersectionPoint {
2208                point: p,
2209                param1: cone.project_point(p),
2210                param2: cyl.project_point(p),
2211            })
2212            .collect();
2213        let degree = 3.min(pts.len() - 1);
2214        match interpolate(pts, degree) {
2215            Ok(curve) => curves.push(IntersectionCurve {
2216                curve,
2217                points: ipts,
2218            }),
2219            // Emitting only the branch that happened to fit would starve the
2220            // section chain of exactly the piece this path exists to supply —
2221            // the same silent half-answer the marcher's fragments produced.
2222            // Defer the whole pair to the caller's other paths instead.
2223            Err(_) => return Ok(None),
2224        }
2225    }
2226
2227    Ok(Some(curves))
2228}
2229
2230/// Algebraic sphere-sphere intersection.
2231///
2232/// Two spheres intersect in a circle lying in the radical plane.
2233/// The radical plane is perpendicular to the line connecting the centers,
2234/// at a distance d1 from center1 where:
2235///   d1 = (D² + R1² - R2²) / (2D)
2236/// and D is the distance between centers.
2237fn algebraic_sphere_sphere(
2238    s1: &SphericalSurface,
2239    s2: &SphericalSurface,
2240) -> Result<Vec<IntersectionCurve>, MathError> {
2241    let c1 = s1.center();
2242    let c2 = s2.center();
2243    let r1 = s1.radius();
2244    let r2 = s2.radius();
2245
2246    let delta = c2 - c1;
2247    let d_sq = delta.x() * delta.x() + delta.y() * delta.y() + delta.z() * delta.z();
2248    let d = d_sq.sqrt();
2249
2250    if d < 1e-12 {
2251        // Concentric spheres: no intersection (unless same radius → degenerate).
2252        return Ok(vec![]);
2253    }
2254
2255    // Check separation conditions.
2256    if d > r1 + r2 + 1e-10 {
2257        return Ok(vec![]); // Too far apart
2258    }
2259    if d + r2.min(r1) + 1e-10 < r1.max(r2) {
2260        return Ok(vec![]); // One inside the other
2261    }
2262
2263    // Distance from c1 to the radical plane along the center line.
2264    let d1 = (d_sq + r1 * r1 - r2 * r2) / (2.0 * d);
2265
2266    // Radius of the intersection circle.
2267    let r_circle_sq = r1 * r1 - d1 * d1;
2268    if r_circle_sq < 0.0 {
2269        // Tangent or no intersection (numerical noise).
2270        if r_circle_sq > -1e-10 {
2271            // Tangent: single point.
2272            let axis = Vec3::new(delta.x() / d, delta.y() / d, delta.z() / d);
2273            let tangent_pt = Point3::new(
2274                c1.x() + axis.x() * d1,
2275                c1.y() + axis.y() * d1,
2276                c1.z() + axis.z() * d1,
2277            );
2278            let ipt = IntersectionPoint {
2279                point: tangent_pt,
2280                param1: (0.0, 0.0),
2281                param2: (0.0, 0.0),
2282            };
2283            // Single-point "curve" — not very useful but correct.
2284            return Ok(vec![IntersectionCurve {
2285                curve: interpolate(&[tangent_pt, tangent_pt], 1)?,
2286                points: vec![ipt],
2287            }]);
2288        }
2289        return Ok(vec![]);
2290    }
2291
2292    let r_circle = r_circle_sq.sqrt();
2293    let axis = Vec3::new(delta.x() / d, delta.y() / d, delta.z() / d);
2294    let center = Point3::new(
2295        c1.x() + axis.x() * d1,
2296        c1.y() + axis.y() * d1,
2297        c1.z() + axis.z() * d1,
2298    );
2299
2300    // Build a reference frame for the circle.
2301    let basis = Frame3::from_normal(center, axis)?;
2302    let u_dir = basis.x;
2303    let v_dir = basis.y;
2304
2305    // Sample the circle for the IntersectionCurve representation.
2306    let n_samples = 33; // Odd for symmetry
2307    let mut points = Vec::with_capacity(n_samples);
2308    let mut positions = Vec::with_capacity(n_samples);
2309    #[allow(clippy::cast_precision_loss)]
2310    for i in 0..n_samples {
2311        let theta = TAU * i as f64 / (n_samples - 1) as f64;
2312        let (sin_t, cos_t) = theta.sin_cos();
2313        let pt = Point3::new(
2314            center.x() + (u_dir.x() * cos_t + v_dir.x() * sin_t) * r_circle,
2315            center.y() + (u_dir.y() * cos_t + v_dir.y() * sin_t) * r_circle,
2316            center.z() + (u_dir.z() * cos_t + v_dir.z() * sin_t) * r_circle,
2317        );
2318        positions.push(pt);
2319        points.push(IntersectionPoint {
2320            point: pt,
2321            param1: (0.0, 0.0),
2322            param2: (0.0, 0.0),
2323        });
2324    }
2325
2326    let degree = 3.min(positions.len() - 1);
2327    let curve = interpolate(&positions, degree)?;
2328
2329    Ok(vec![IntersectionCurve { curve, points }])
2330}
2331
2332/// Newton correction: project a point back onto the intersection curve
2333/// of two analytic surfaces. Solves the 3×3 system:
2334///   δ · na = -da  (eliminate distance to surface A)
2335///   δ · nb = -db  (eliminate distance to surface B)
2336///   δ · t  = 0    (minimal correction, perpendicular to tangent)
2337#[allow(clippy::too_many_arguments)]
2338fn correct_to_intersection(
2339    a: &AnalyticSurface<'_>,
2340    b: &AnalyticSurface<'_>,
2341    surf_a: &dyn Fn(f64, f64) -> Point3,
2342    norm_a: &dyn Fn(f64, f64) -> Vec3,
2343    surf_b: &dyn Fn(f64, f64) -> Point3,
2344    norm_b: &dyn Fn(f64, f64) -> Vec3,
2345    point: Point3,
2346    u_range_a: (f64, f64),
2347    v_range_a: (f64, f64),
2348    u_range_b: (f64, f64),
2349    v_range_b: (f64, f64),
2350    max_iters: usize,
2351) -> Point3 {
2352    let mut p = point;
2353    for _ in 0..max_iters {
2354        let (ua, va) = project_analytic(a, p, u_range_a, v_range_a);
2355        let (ub, vb) = project_analytic(b, p, u_range_b, v_range_b);
2356        let pa = surf_a(ua, va);
2357        let pb = surf_b(ub, vb);
2358        let na = norm_a(ua, va);
2359        let nb = norm_b(ub, vb);
2360        let pv = Vec3::new(p.x(), p.y(), p.z());
2361
2362        let da = (pv - Vec3::new(pa.x(), pa.y(), pa.z())).dot(na);
2363        let db = (pv - Vec3::new(pb.x(), pb.y(), pb.z())).dot(nb);
2364
2365        if da.abs() < 1e-7 && db.abs() < 1e-7 {
2366            break;
2367        }
2368
2369        let t = na.cross(nb);
2370        let t_len = t.length();
2371        if t_len < 1e-10 {
2372            // Surfaces are tangent — fall back to midpoint.
2373            return Point3::new(
2374                (pa.x() + pb.x()) * 0.5,
2375                (pa.y() + pb.y()) * 0.5,
2376                (pa.z() + pb.z()) * 0.5,
2377            );
2378        }
2379        let t_hat = t * (1.0 / t_len);
2380
2381        // Solve [na; nb; t_hat] · δ = [-da, -db, 0] via Cramer's rule.
2382        let det = na.x() * (nb.y() * t_hat.z() - nb.z() * t_hat.y())
2383            - na.y() * (nb.x() * t_hat.z() - nb.z() * t_hat.x())
2384            + na.z() * (nb.x() * t_hat.y() - nb.y() * t_hat.x());
2385        if det.abs() < 1e-15 {
2386            return Point3::new(
2387                (pa.x() + pb.x()) * 0.5,
2388                (pa.y() + pb.y()) * 0.5,
2389                (pa.z() + pb.z()) * 0.5,
2390            );
2391        }
2392        let inv = 1.0 / det;
2393        // Cramer's rule: replace each column of A with rhs = (-da, -db, 0).
2394        let dx = inv
2395            * (-da * (nb.y() * t_hat.z() - nb.z() * t_hat.y())
2396                + db * (na.y() * t_hat.z() - na.z() * t_hat.y()));
2397        let dy = inv
2398            * (da * (nb.x() * t_hat.z() - nb.z() * t_hat.x())
2399                - db * (na.x() * t_hat.z() - na.z() * t_hat.x()));
2400        let dz = inv
2401            * (-da * (nb.x() * t_hat.y() - nb.y() * t_hat.x())
2402                + db * (na.x() * t_hat.y() - na.y() * t_hat.x()));
2403        let candidate = Point3::new(p.x() + dx, p.y() + dy, p.z() + dz);
2404
2405        // Divergence guard: if the correction moves farther from both
2406        // surfaces, abandon Newton and return the best point so far.
2407        let (uc, vc) = project_analytic(a, candidate, u_range_a, v_range_a);
2408        let (ud, vd) = project_analytic(b, candidate, u_range_b, v_range_b);
2409        let pc_a = surf_a(uc, vc);
2410        let pc_b = surf_b(ud, vd);
2411        let cv = Vec3::new(candidate.x(), candidate.y(), candidate.z());
2412        let da_new = (cv - Vec3::new(pc_a.x(), pc_a.y(), pc_a.z()))
2413            .dot(norm_a(uc, vc))
2414            .abs();
2415        let db_new = (cv - Vec3::new(pc_b.x(), pc_b.y(), pc_b.z()))
2416            .dot(norm_b(ud, vd))
2417            .abs();
2418        if da_new > da.abs() && db_new > db.abs() {
2419            return p;
2420        }
2421
2422        p = candidate;
2423    }
2424    p
2425}
2426
2427/// March along the intersection of two surfaces from a seed point.
2428///
2429/// Uses the cross product of surface normals as the tangent direction
2430/// and projects back onto both surfaces using analytical projection
2431/// (for cylinders/spheres) or grid search (fallback).
2432#[allow(clippy::too_many_arguments)]
2433fn march_analytic_intersection(
2434    a: &AnalyticSurface<'_>,
2435    b: &AnalyticSurface<'_>,
2436    surf_a: &dyn Fn(f64, f64) -> Point3,
2437    norm_a: &dyn Fn(f64, f64) -> Vec3,
2438    surf_b: &dyn Fn(f64, f64) -> Point3,
2439    norm_b: &dyn Fn(f64, f64) -> Vec3,
2440    seed: Point3,
2441    u_range_a: (f64, f64),
2442    v_range_a: (f64, f64),
2443    u_range_b: (f64, f64),
2444    v_range_b: (f64, f64),
2445    initial_step: f64,
2446    u_periodic_a: bool,
2447    u_periodic_b: bool,
2448) -> Vec<Point3> {
2449    let max_steps = 500;
2450    let h_min = 1e-6;
2451    let h_max = initial_step * 4.0;
2452    // Fixed closure threshold: the adaptive step `h` varies with curvature
2453    // and can shrink below the actual miss distance at the seed re-approach.
2454    // Use `initial_step * 5` to robustly detect closure on the first pass.
2455    let closure_dist = initial_step * 5.0;
2456    // Angular thresholds for curvature-adaptive stepping.
2457    let max_angle = 10.0_f64.to_radians();
2458    let min_angle = 2.0_f64.to_radians();
2459
2460    // March forward from seed, collecting points.
2461    let mut forward = Vec::new();
2462    // March backward from seed, collecting points (reversed at end).
2463    let mut backward = Vec::new();
2464
2465    for (direction, points) in [(1.0_f64, &mut forward), (-1.0_f64, &mut backward)] {
2466        let mut current = seed;
2467        let mut h = initial_step;
2468        let mut prev_tangent: Option<Vec3> = None;
2469
2470        for _ in 0..max_steps {
2471            let (ua, va) = project_analytic(a, current, u_range_a, v_range_a);
2472            let (ub, vb) = project_analytic(b, current, u_range_b, v_range_b);
2473
2474            let na = norm_a(ua, va);
2475            let nb = norm_b(ub, vb);
2476
2477            let tangent = na.cross(nb);
2478            let t_len = tangent.length();
2479            if t_len < 1e-10 {
2480                break;
2481            }
2482            let t_dir = tangent * (direction / t_len);
2483
2484            // Curvature-adaptive step: check angular deviation from previous tangent.
2485            if let Some(prev_t) = prev_tangent {
2486                let cos_angle = prev_t.dot(t_dir).clamp(-1.0, 1.0);
2487                let angle = cos_angle.acos();
2488                if angle > max_angle && h > h_min {
2489                    h = (h * 0.5).max(h_min);
2490                } else if angle < min_angle {
2491                    h = (h * 2.0).min(h_max);
2492                }
2493            }
2494            prev_tangent = Some(t_dir);
2495
2496            let next = Point3::new(
2497                h.mul_add(t_dir.x(), current.x()),
2498                h.mul_add(t_dir.y(), current.y()),
2499                h.mul_add(t_dir.z(), current.z()),
2500            );
2501
2502            let (ua2, va2) = project_analytic(a, next, u_range_a, v_range_a);
2503            let (ub2, vb2) = project_analytic(b, next, u_range_b, v_range_b);
2504
2505            let pa = surf_a(ua2, va2);
2506            let pb = surf_b(ub2, vb2);
2507            let mid = 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            let out_a = (!u_periodic_a && (ua2 <= u_range_a.0 || ua2 >= u_range_a.1))
2513                || va2 <= v_range_a.0
2514                || va2 >= v_range_a.1;
2515            let out_b = (!u_periodic_b && (ub2 <= u_range_b.0 || ub2 >= u_range_b.1))
2516                || vb2 <= v_range_b.0
2517                || vb2 >= v_range_b.1;
2518
2519            if out_a || out_b {
2520                break;
2521            }
2522
2523            // Check for loop closure — if we've collected enough points and
2524            // the current point is close to the seed, the curve is closed.
2525            // Require ≥10 steps to avoid premature closure near the seed.
2526            let dist_to_seed = (mid - seed).length();
2527            if points.len() > 10 && dist_to_seed < closure_dist {
2528                points.push(seed);
2529                break;
2530            }
2531
2532            points.push(mid);
2533            current = mid;
2534        }
2535    }
2536
2537    // Assemble result: backward (reversed) + seed + forward
2538    backward.reverse();
2539    let mut result = backward;
2540    result.push(seed);
2541    result.append(&mut forward);
2542
2543    // Refine all points onto the intersection curve via Newton correction.
2544    for pt in &mut result {
2545        *pt = correct_to_intersection(
2546            a, b, surf_a, norm_a, surf_b, norm_b, *pt, u_range_a, v_range_a, u_range_b, v_range_b,
2547            5,
2548        );
2549    }
2550
2551    result
2552}
2553
2554/// Project a 3D point onto an analytic surface using the surface's
2555/// analytical projection method. Falls back to grid search for surface
2556/// types without analytical projection.
2557fn project_analytic(
2558    surface: &AnalyticSurface<'_>,
2559    point: Point3,
2560    u_range: (f64, f64),
2561    v_range: (f64, f64),
2562) -> (f64, f64) {
2563    match surface {
2564        AnalyticSurface::Cylinder(cyl) => {
2565            let (u, v) = cyl.project_point(point);
2566            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2567        }
2568        AnalyticSurface::Sphere(sphere) => {
2569            let (u, v) = sphere.project_point(point);
2570            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2571        }
2572        AnalyticSurface::Cone(cone) => {
2573            let (u, v) = cone.project_point(point);
2574            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2575        }
2576        AnalyticSurface::Torus(torus) => {
2577            let (u, v) = torus.project_point(point);
2578            (u.clamp(u_range.0, u_range.1), v.clamp(v_range.0, v_range.1))
2579        }
2580    }
2581}
2582
2583/// Returns `true` if the surface's u-parameter is periodic (wraps around 2π).
2584/// All current `AnalyticSurface` variants have periodic u — this is trivially
2585/// true today but exists as a guard for future non-periodic analytic types.
2586fn is_u_periodic(surface: &AnalyticSurface<'_>) -> bool {
2587    matches!(
2588        surface,
2589        AnalyticSurface::Cylinder(_)
2590            | AnalyticSurface::Cone(_)
2591            | AnalyticSurface::Sphere(_)
2592            | AnalyticSurface::Torus(_)
2593    )
2594}
2595
2596/// Extract closures and parameter ranges for an analytic surface.
2597#[allow(clippy::type_complexity)]
2598fn surface_closures<'a>(
2599    surface: &'a AnalyticSurface<'a>,
2600) -> (
2601    Box<dyn Fn(f64, f64) -> Point3 + 'a>,
2602    Box<dyn Fn(f64, f64) -> Vec3 + 'a>,
2603    (f64, f64),
2604    (f64, f64),
2605) {
2606    match surface {
2607        AnalyticSurface::Cylinder(cyl) => (
2608            Box::new(|u, v| cyl.evaluate(u, v)),
2609            Box::new(|u, v| cyl.normal(u, v)),
2610            (0.0, TAU),
2611            (-1.0, 1.0),
2612        ),
2613        AnalyticSurface::Cone(cone) => (
2614            Box::new(|u, v| cone.evaluate(u, v)),
2615            Box::new(|u, v| cone.normal(u, v)),
2616            (0.0, TAU),
2617            (0.01, 2.0),
2618        ),
2619        AnalyticSurface::Sphere(sphere) => (
2620            Box::new(|u, v| sphere.evaluate(u, v)),
2621            Box::new(|u, v| sphere.normal(u, v)),
2622            (0.0, TAU),
2623            (-FRAC_PI_2, FRAC_PI_2),
2624        ),
2625        AnalyticSurface::Torus(torus) => (
2626            Box::new(|u, v| torus.evaluate(u, v)),
2627            Box::new(|u, v| torus.normal(u, v)),
2628            (0.0, TAU),
2629            (0.0, TAU),
2630        ),
2631    }
2632}
2633
2634#[cfg(test)]
2635#[allow(clippy::unwrap_used, clippy::expect_used)]
2636mod tests {
2637    use super::*;
2638    use crate::tolerance::Tolerance;
2639
2640    #[test]
2641    fn plane_cylinder_perpendicular() {
2642        let cyl =
2643            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 2.0)
2644                .unwrap();
2645
2646        // Horizontal plane at z=3 -- produces a circle at height 3.
2647        let curves = intersect_plane_cylinder(&cyl, Vec3::new(0.0, 0.0, 1.0), 3.0).unwrap();
2648        assert!(!curves.is_empty(), "should find intersection curve");
2649        assert!(
2650            curves[0].points.len() > 10,
2651            "should have many sample points"
2652        );
2653
2654        let tol = Tolerance::loose();
2655        for pt in &curves[0].points {
2656            assert!(
2657                tol.approx_eq(pt.point.z(), 3.0),
2658                "z should be ~3.0, got {}",
2659                pt.point.z()
2660            );
2661            let r = pt.point.x().hypot(pt.point.y());
2662            assert!(tol.approx_eq(r, 2.0), "radius should be ~2.0, got {r}");
2663        }
2664    }
2665
2666    #[test]
2667    fn plane_sphere_equator() {
2668        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 3.0).unwrap();
2669
2670        let curves = intersect_plane_sphere(&sphere, Vec3::new(0.0, 0.0, 1.0), 0.0).unwrap();
2671        assert!(!curves.is_empty());
2672
2673        let tol = Tolerance::loose();
2674        for pt in &curves[0].points {
2675            assert!(
2676                tol.approx_eq(pt.point.z(), 0.0),
2677                "z should be ~0, got {}",
2678                pt.point.z()
2679            );
2680            let r = pt.point.x().hypot(pt.point.y());
2681            assert!(tol.approx_eq(r, 3.0), "radius should be ~3.0, got {r}");
2682        }
2683    }
2684
2685    #[test]
2686    fn plane_sphere_no_intersection() {
2687        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 1.0).unwrap();
2688
2689        let curves = intersect_plane_sphere(&sphere, Vec3::new(0.0, 0.0, 1.0), 5.0).unwrap();
2690        assert!(curves.is_empty());
2691    }
2692
2693    #[test]
2694    fn plane_cone_cross_section() {
2695        let cone = ConicalSurface::new(
2696            Point3::new(0.0, 0.0, 0.0),
2697            Vec3::new(0.0, 0.0, 1.0),
2698            std::f64::consts::FRAC_PI_4,
2699        )
2700        .unwrap();
2701
2702        let curves = intersect_plane_cone(&cone, Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
2703        assert!(!curves.is_empty(), "should find intersection with cone");
2704    }
2705
2706    /// The 1u gridfinity spacer lip fuse corner (#1570): the body's lip
2707    /// recess cone (45 deg, opening downward) meets the tool's lip cone
2708    /// (45 deg, opening upward) with axes offset 0.25mm in x and y. Equal
2709    /// half-angle tangents put the whole intersection on the radical plane,
2710    /// so the section is one exact ellipse; the marcher shredded this into
2711    /// ~64 closed micro-loops per pair.
2712    #[test]
2713    fn offset_parallel_equal_angle_cones_give_one_exact_ellipse() {
2714        let c1 = ConicalSurface::new(
2715            Point3::new(
2716                -16.999_999_999_999_975,
2717                -16.999_999_999_999_975,
2718                5.849_999_999_999_951,
2719            ),
2720            Vec3::new(0.0, 0.0, -1.0),
2721            0.785_398_163_397_433_5,
2722        )
2723        .unwrap();
2724        let c2 = ConicalSurface::new(
2725            Point3::new(
2726                -16.750_000_000_000_036,
2727                -16.750_000_000_000_018,
2728                0.749_999_999_999_881,
2729            ),
2730            Vec3::new(0.0, 0.0, 1.0),
2731            0.785_398_163_397_467_6,
2732        )
2733        .unwrap();
2734
2735        let curves = exact_cone_cone(&c1, &c2)
2736            .unwrap()
2737            .expect("offset parallel equal-angle cones must take the radical-plane path");
2738        assert_eq!(curves.len(), 1, "expected exactly one section conic");
2739        assert!(
2740            matches!(curves[0], ExactIntersectionCurve::Ellipse(_)),
2741            "expected an ellipse section, got {:?}",
2742            curves[0]
2743        );
2744        let ExactIntersectionCurve::Ellipse(ellipse) = &curves[0] else {
2745            return;
2746        };
2747
2748        // Every sample must lie on BOTH cones: distance to the axis equals
2749        // tan(half_angle) times the axial distance from the apex, on the
2750        // real nappe of each.
2751        for i in 0..16 {
2752            let p = crate::traits::ParametricCurve::evaluate(ellipse, TAU * f64::from(i) / 16.0);
2753            for (cone, label) in [(&c1, "c1"), (&c2, "c2")] {
2754                let rel = p - cone.apex();
2755                let rel_v = Vec3::new(rel.x(), rel.y(), rel.z());
2756                let axial = rel_v.dot(cone.axis());
2757                let radial = (rel_v - cone.axis() * axial).length();
2758                assert!(
2759                    axial > 0.0,
2760                    "{label}: sample on phantom nappe (axial {axial})"
2761                );
2762                let expect = cone.half_angle().tan() * axial;
2763                assert!(
2764                    (radial - expect).abs() < 1e-9,
2765                    "{label}: sample off surface by {}",
2766                    (radial - expect).abs()
2767                );
2768            }
2769        }
2770    }
2771
2772    /// Opposed cones whose real nappes occupy disjoint half-spaces share a
2773    /// radical-plane conic only on the phantom nappe — the exact path must
2774    /// report a definitive empty intersection, not defer to the marcher.
2775    #[test]
2776    fn offset_parallel_cones_opening_apart_have_no_real_intersection() {
2777        let c1 = ConicalSurface::new(
2778            Point3::new(0.0, 0.0, 5.0),
2779            Vec3::new(0.0, 0.0, -1.0),
2780            std::f64::consts::FRAC_PI_4,
2781        )
2782        .unwrap();
2783        let c2 = ConicalSurface::new(
2784            Point3::new(0.25, 0.25, 20.0),
2785            Vec3::new(0.0, 0.0, 1.0),
2786            std::f64::consts::FRAC_PI_4,
2787        )
2788        .unwrap();
2789        let curves = exact_cone_cone(&c1, &c2)
2790            .unwrap()
2791            .expect("radical-plane path");
2792        assert!(curves.is_empty(), "disjoint nappes must yield no curves");
2793    }
2794
2795    /// Unequal half-angles keep a quadratic term in the pencil — no plane
2796    /// reduction exists, so the exact path must defer to the marcher.
2797    #[test]
2798    fn offset_parallel_cones_with_unequal_angles_defer() {
2799        let c1 = ConicalSurface::new(
2800            Point3::new(0.0, 0.0, 5.0),
2801            Vec3::new(0.0, 0.0, -1.0),
2802            std::f64::consts::FRAC_PI_4,
2803        )
2804        .unwrap();
2805        let c2 = ConicalSurface::new(Point3::new(0.25, 0.25, 0.5), Vec3::new(0.0, 0.0, 1.0), 0.6)
2806            .unwrap();
2807        assert!(exact_cone_cone(&c1, &c2).unwrap().is_none());
2808    }
2809
2810    #[test]
2811    fn coaxial_cones_cross_at_single_circle() {
2812        // Two coaxial truncated cones (outer base r10->top r8, inner r9->r8
2813        // over height 10) cross where their radii match: z=10, r=8. The
2814        // intersection must be ONE clean circle, not the dozens of degenerate
2815        // micro-curves the general marcher produces at near-tangency.
2816        let outer = ConicalSurface::new(
2817            Point3::new(0.0, 0.0, 50.0),
2818            Vec3::new(0.0, 0.0, -1.0),
2819            5.0_f64.atan(),
2820        )
2821        .unwrap();
2822        let inner = ConicalSurface::new(
2823            Point3::new(0.0, 0.0, 90.0),
2824            Vec3::new(0.0, 0.0, -1.0),
2825            10.0_f64.atan(),
2826        )
2827        .unwrap();
2828
2829        let curves = intersect_analytic_analytic_bounded(
2830            AnalyticSurface::Cone(&outer),
2831            AnalyticSurface::Cone(&inner),
2832            32,
2833            None,
2834            None,
2835        )
2836        .unwrap();
2837
2838        assert_eq!(
2839            curves.len(),
2840            1,
2841            "coaxial cones crossing at one circle must yield exactly one curve, got {}",
2842            curves.len()
2843        );
2844        for p in &curves[0].points {
2845            let r = p.point.x().hypot(p.point.y());
2846            assert!(
2847                (p.point.z() - 10.0).abs() < 1e-6 && (r - 8.0).abs() < 1e-6,
2848                "intersection point off the expected z=10,r=8 circle: {:?}",
2849                p.point
2850            );
2851        }
2852    }
2853
2854    #[test]
2855    fn plane_torus_cross_section() {
2856        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 5.0, 1.0).unwrap();
2857
2858        let curves = intersect_plane_torus(&torus, Vec3::new(0.0, 0.0, 1.0), 0.0).unwrap();
2859        assert!(
2860            !curves.is_empty(),
2861            "should find intersection curves with torus"
2862        );
2863    }
2864
2865    /// Signed distance of a point to a z-axis torus centred at the origin:
2866    /// `sqrt((sqrt(x^2+y^2) - R)^2 + z^2) - r`.
2867    fn torus_implicit(p: Point3, major: f64, minor: f64) -> f64 {
2868        let rho = p.x().hypot(p.y());
2869        ((rho - major).hypot(p.z())) - minor
2870    }
2871
2872    /// The gridfinity lightweight base's failing corner, reduced: a cavity
2873    /// corner-round cone (apex below the floor, 45 deg, axis +z) crossed by a
2874    /// parallel-axis boss cylinder. The general marcher returned ~49 overlapping
2875    /// partial traces of one curve here; the algebraic path must return exactly
2876    /// the two branches, each ON both surfaces and inside the cone's v-hint.
2877    #[test]
2878    fn parallel_cone_cylinder_gives_two_exact_branches() {
2879        use crate::traits::ParametricCurve;
2880        let cone = ConicalSurface::new(
2881            Point3::new(-5.45, -36.55, -4.85),
2882            Vec3::new(0.0, 0.0, 1.0),
2883            std::f64::consts::FRAC_PI_4,
2884        )
2885        .unwrap();
2886        let cyl = CylindricalSurface::new(
2887            Point3::new(-8.0, -34.0, -5.0),
2888            Vec3::new(0.0, 0.0, 1.0),
2889            4.45,
2890        )
2891        .unwrap();
2892        // The cone face spans z in [-3.8, -3.0]; v = (z - apex_z) / sin(45 deg).
2893        let v_hint = (1.484_924_240_492_058, 2.616_295_090_390_43);
2894        let curves = intersect_analytic_analytic_bounded(
2895            AnalyticSurface::Cone(&cone),
2896            AnalyticSurface::Cylinder(&cyl),
2897            32,
2898            Some(v_hint),
2899            Some((0.0, 2.5)),
2900        )
2901        .unwrap();
2902
2903        assert_eq!(curves.len(), 2, "expected exactly the two branches");
2904        for c in &curves {
2905            let (t0, t1) = c.curve.domain();
2906            for k in 0..=32 {
2907                let t = (t1 - t0).mul_add(f64::from(k) / 32.0, t0);
2908                let p = ParametricCurve::evaluate(&c.curve, t);
2909                // On the cylinder: radial distance from its axis is the radius.
2910                let radial = ((p.x() + 8.0).powi(2) + (p.y() + 34.0).powi(2)).sqrt();
2911                assert!((radial - 4.45).abs() < 1e-6, "off cylinder: {radial}");
2912                // On the cone: radial distance from its axis is z - apex_z.
2913                let cone_r = ((p.x() + 5.45).powi(2) + (p.y() + 36.55).powi(2)).sqrt();
2914                assert!((cone_r - (p.z() + 4.85)).abs() < 1e-6, "off cone at {p:?}");
2915                // Inside the cone face's own v-window (the hint is respected).
2916                assert!(p.z() >= -3.8 - 1e-9 && p.z() <= -3.0 + 1e-9, "z={}", p.z());
2917            }
2918        }
2919    }
2920
2921    /// A coaxial pair has no radical line; the algebraic path must defer rather
2922    /// than divide by a zero axis separation.
2923    #[test]
2924    fn coaxial_cone_cylinder_defers_to_other_paths() {
2925        let cone = ConicalSurface::new(
2926            Point3::new(0.0, 0.0, 0.0),
2927            Vec3::new(0.0, 0.0, 1.0),
2928            std::f64::consts::FRAC_PI_4,
2929        )
2930        .unwrap();
2931        let cyl =
2932            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 2.0)
2933                .unwrap();
2934        assert!(
2935            algebraic_parallel_cone_cylinder(&cone, &cyl, None, None)
2936                .unwrap()
2937                .is_none()
2938        );
2939    }
2940
2941    #[test]
2942    fn oblique_cone_cylinder_defers_to_other_paths() {
2943        let cone = ConicalSurface::new(
2944            Point3::new(0.0, 0.0, 0.0),
2945            Vec3::new(0.0, 0.0, 1.0),
2946            std::f64::consts::FRAC_PI_4,
2947        )
2948        .unwrap();
2949        let cyl =
2950            CylindricalSurface::new(Point3::new(3.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
2951                .unwrap();
2952        assert!(
2953            algebraic_parallel_cone_cylinder(&cone, &cyl, None, None)
2954                .unwrap()
2955                .is_none()
2956        );
2957    }
2958
2959    #[test]
2960    fn plane_torus_lobe_closes_and_stays_on_surface() {
2961        use crate::traits::ParametricCurve;
2962        let (major, minor) = (10.0, 3.0);
2963        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), major, minor).unwrap();
2964
2965        // The census cutting planes (y=-4, x=6) each cut the +x and -x tube lobes
2966        // in a CLOSED oval. The greedy marcher stops one grid step short of
2967        // closing; the wrap-close must make every fitted lobe close exactly.
2968        for (n, d) in [
2969            (Vec3::new(0.0, -1.0, 0.0), 4.0),  // y = -4
2970            (Vec3::new(-1.0, 0.0, 0.0), -6.0), // x = 6
2971            (Vec3::new(0.0, 0.0, 1.0), 0.0),   // z = 0 -> two concentric circles
2972        ] {
2973            let curves = intersect_plane_torus(&torus, n, d).unwrap();
2974            assert!(!curves.is_empty(), "plane n={n:?} d={d} found no curves");
2975            for c in &curves {
2976                let p0 = ParametricCurve::evaluate(&c.curve, 0.0);
2977                let p1 = ParametricCurve::evaluate(&c.curve, 1.0);
2978                assert!(
2979                    (p0 - p1).length() < 1e-7,
2980                    "lobe not closed: gap={} (n={n:?} d={d})",
2981                    (p0 - p1).length()
2982                );
2983                // Every fitted sample stays on the torus (shape-preserving).
2984                for k in 0..=64 {
2985                    let t = f64::from(k) / 64.0;
2986                    let p = ParametricCurve::evaluate(&c.curve, t);
2987                    assert!(
2988                        torus_implicit(p, major, minor).abs() < 1e-2,
2989                        "off-surface point {p:?} implicit={}",
2990                        torus_implicit(p, major, minor)
2991                    );
2992                }
2993            }
2994        }
2995    }
2996
2997    #[test]
2998    fn plane_torus_inner_tangent_figure_eight_stays_open() {
2999        use crate::traits::ParametricCurve;
3000        let (major, minor) = (10.0, 3.0);
3001        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), major, minor).unwrap();
3002
3003        // A plane tangent to the inner equator (x = major - minor = 7) cuts a
3004        // self-touching figure-eight. The marcher traces it as a single chain
3005        // whose end lands on the opposite lobe — FAR from its start (gap is many
3006        // point-spacings). The wrap-close must NOT force-close this into a wrong
3007        // loop; it must stay OPEN so a self-touching curve is never sealed.
3008        let curves =
3009            intersect_plane_torus(&torus, Vec3::new(-1.0, 0.0, 0.0), -(major - minor)).unwrap();
3010        assert!(!curves.is_empty(), "inner-tangent plane found no curves");
3011        let max_gap = curves
3012            .iter()
3013            .map(|c| {
3014                let p0 = ParametricCurve::evaluate(&c.curve, 0.0);
3015                let p1 = ParametricCurve::evaluate(&c.curve, 1.0);
3016                (p0 - p1).length()
3017            })
3018            .fold(0.0_f64, f64::max);
3019        assert!(
3020            max_gap > 1e-2,
3021            "figure-eight chain was wrongly force-closed (max end-gap={max_gap})"
3022        );
3023    }
3024
3025    #[test]
3026    fn line_torus_box_edge_crossing_is_exact() {
3027        // The census box edge x=6, y=-4 (z varying) crosses the torus (R=10,r=3)
3028        // at z = ±sqrt(r² − (rho−R)²), rho = hypot(6,4) ≈ 7.2111 → z ≈ ±1.1055.
3029        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 10.0, 3.0).unwrap();
3030        let ts = intersect_line_torus(
3031            &torus,
3032            Point3::new(6.0, -4.0, -5.0),
3033            Vec3::new(0.0, 0.0, 1.0),
3034        );
3035        // Vertical line through (6,-4) meets the tube twice.
3036        assert_eq!(ts.len(), 2, "expected 2 crossings, got {ts:?}");
3037        let zs: Vec<f64> = ts.iter().map(|t| -5.0 + t).collect();
3038        let rho = 6.0_f64.hypot(4.0);
3039        let z_exp = (9.0 - (rho - 10.0).powi(2)).sqrt();
3040        assert!(
3041            (zs[0] - (-z_exp)).abs() < 1e-9,
3042            "z0={} exp={}",
3043            zs[0],
3044            -z_exp
3045        );
3046        assert!((zs[1] - z_exp).abs() < 1e-9, "z1={} exp={}", zs[1], z_exp);
3047        // Each crossing lies on the torus.
3048        for &t in &ts {
3049            let p = Point3::new(6.0, -4.0, -5.0 + t);
3050            let rho = p.x().hypot(p.y());
3051            let impl_v = (rho - 10.0).hypot(p.z()) - 3.0;
3052            assert!(impl_v.abs() < 1e-9, "off-torus impl={impl_v}");
3053        }
3054    }
3055
3056    #[test]
3057    fn line_torus_miss_and_tangent() {
3058        let torus = ToroidalSurface::new(Point3::new(0.0, 0.0, 0.0), 10.0, 3.0).unwrap();
3059        // A vertical line at rho beyond the outer rim (x=20) misses entirely.
3060        let miss = intersect_line_torus(
3061            &torus,
3062            Point3::new(20.0, 0.0, 0.0),
3063            Vec3::new(0.0, 0.0, 1.0),
3064        );
3065        assert!(miss.is_empty(), "expected no crossings, got {miss:?}");
3066        // The z-axis (rho=0) passes through the hole — no intersection.
3067        let axis =
3068            intersect_line_torus(&torus, Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0));
3069        assert!(axis.is_empty(), "z-axis should miss the tube, got {axis:?}");
3070    }
3071
3072    #[test]
3073    fn dispatch_via_analytic_surface() {
3074        let cyl =
3075            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3076                .unwrap();
3077        let curves = intersect_plane_analytic(
3078            AnalyticSurface::Cylinder(&cyl),
3079            Vec3::new(0.0, 0.0, 1.0),
3080            0.0,
3081        )
3082        .unwrap();
3083        assert!(!curves.is_empty());
3084    }
3085
3086    #[test]
3087    fn perpendicular_cylinders_intersect() {
3088        let cyl_z =
3089            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3090                .unwrap();
3091        let cyl_x =
3092            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
3093                .unwrap();
3094
3095        let curves = intersect_analytic_analytic(
3096            AnalyticSurface::Cylinder(&cyl_z),
3097            AnalyticSurface::Cylinder(&cyl_x),
3098            16,
3099        )
3100        .unwrap();
3101
3102        assert!(
3103            !curves.is_empty(),
3104            "perpendicular cylinders should intersect"
3105        );
3106
3107        for c in &curves {
3108            assert!(
3109                c.points.len() >= 2,
3110                "intersection curve should have >= 2 points, got {}",
3111                c.points.len()
3112            );
3113        }
3114    }
3115
3116    /// Neither cylinder's rulings all meet the other: the curve is one loop
3117    /// joined at its two branch points.
3118    #[test]
3119    fn partially_overlapping_cylinders_meet_in_one_closed_loop() {
3120        let cyl_z =
3121            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3122                .unwrap();
3123        let cyl_x =
3124            CylindricalSurface::new(Point3::new(0.0, 1.2, 0.0), Vec3::new(1.0, 0.0, 0.0), 1.0)
3125                .unwrap();
3126        let curves = algebraic_cylinder_cylinder(&cyl_z, &cyl_x)
3127            .unwrap()
3128            .unwrap();
3129        assert_eq!(curves.len(), 1);
3130        let curve = &curves[0].curve;
3131        let (t0, t1) = curve.domain();
3132        assert!((curve.evaluate(t0) - curve.evaluate(t1)).length() < 1e-9);
3133        let off = |p: Point3| {
3134            let on_z = (p.x().hypot(p.y()) - 1.0).abs();
3135            let on_x = ((p.y() - 1.2).hypot(p.z()) - 1.0).abs();
3136            on_z.max(on_x)
3137        };
3138        let worst = (0..=400)
3139            .map(|k| off(curve.evaluate(t0 + (t1 - t0) * f64::from(k) / 400.0)))
3140            .fold(0.0, f64::max);
3141        assert!(worst < 2e-4, "curve leaves the cylinders by {worst}");
3142    }
3143
3144    /// Near tangency the thick cylinder's window of rulings (0.02 either side
3145    /// of a quarter turn) falls between its samples; the thin one's sweep
3146    /// finds the loop.
3147    #[test]
3148    fn near_tangent_cylinders_find_their_loop_on_the_thinner_sweep() {
3149        let cyl_z =
3150            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3151                .unwrap();
3152        let cyl_x =
3153            CylindricalSurface::new(Point3::new(0.0, 1.1998, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.2)
3154                .unwrap();
3155        let curves = algebraic_cylinder_cylinder(&cyl_z, &cyl_x)
3156            .unwrap()
3157            .expect("the thin cylinder's sweep finds the loop");
3158        assert_eq!(curves.len(), 1);
3159    }
3160
3161    #[test]
3162    fn sphere_cylinder_intersect() {
3163        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 2.0).unwrap();
3164        let cyl =
3165            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0)
3166                .unwrap();
3167
3168        let curves = intersect_analytic_analytic(
3169            AnalyticSurface::Sphere(&sphere),
3170            AnalyticSurface::Cylinder(&cyl),
3171            16,
3172        )
3173        .unwrap();
3174
3175        // A sphere of radius 2 and a cylinder of radius 1, both centered
3176        // at the origin, should intersect (the cylinder passes through
3177        // the sphere).
3178        assert!(!curves.is_empty(), "sphere and cylinder should intersect");
3179    }
3180
3181    #[test]
3182    fn exact_sphere_cylinder_coaxial_two_circles() {
3183        // Sphere r=6 at origin, coaxial cylinder r=3 along z: two latitude
3184        // circles at z = ±sqrt(36-9) = ±sqrt(27), each of radius 3.
3185        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 6.0).unwrap();
3186        let cyl =
3187            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0)
3188                .unwrap();
3189        let circles = exact_sphere_cylinder(&sphere, &cyl)
3190            .unwrap()
3191            .expect("coaxial case returns Some");
3192        assert_eq!(circles.len(), 2, "through-bore meets the sphere twice");
3193        let mut zs: Vec<f64> = circles
3194            .iter()
3195            .filter_map(|c| match c {
3196                ExactIntersectionCurve::Circle(circle) => {
3197                    assert!(
3198                        (circle.radius() - 3.0).abs() < 1e-9,
3199                        "rim radius == cyl radius"
3200                    );
3201                    Some(circle.center().z())
3202                }
3203                _ => None,
3204            })
3205            .collect();
3206        assert_eq!(zs.len(), 2, "both sections must be exact circles");
3207        zs.sort_by(f64::total_cmp);
3208        let z = 27.0_f64.sqrt();
3209        assert!((zs[0] + z).abs() < 1e-9 && (zs[1] - z).abs() < 1e-9);
3210    }
3211
3212    #[test]
3213    fn exact_sphere_cylinder_non_coaxial_defers() {
3214        // Cylinder axis offset from the sphere center → quartic curve, deferred.
3215        let sphere = SphericalSurface::new(Point3::new(0.0, 0.0, 0.0), 6.0).unwrap();
3216        let cyl =
3217            CylindricalSurface::new(Point3::new(2.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 3.0)
3218                .unwrap();
3219        assert!(
3220            exact_sphere_cylinder(&sphere, &cyl).unwrap().is_none(),
3221            "non-coaxial sphere/cylinder defers to the marcher"
3222        );
3223    }
3224
3225    #[test]
3226    fn disjoint_cylinders_no_intersection() {
3227        let cyl_a =
3228            CylindricalSurface::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.5)
3229                .unwrap();
3230        let cyl_b =
3231            CylindricalSurface::new(Point3::new(5.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.5)
3232                .unwrap();
3233
3234        let curves = intersect_analytic_analytic(
3235            AnalyticSurface::Cylinder(&cyl_a),
3236            AnalyticSurface::Cylinder(&cyl_b),
3237            16,
3238        )
3239        .unwrap();
3240
3241        assert!(curves.is_empty(), "disjoint cylinders should not intersect");
3242    }
3243
3244    // ── Oblique plane × cone conic (ellipse / parabola / hyperbola) ──────
3245
3246    /// Collect 3D points from a returned exact curve, sampling analytic forms.
3247    fn collect_points(curve: &ExactIntersectionCurve) -> Vec<Point3> {
3248        use crate::traits::ParametricCurve;
3249        match curve {
3250            ExactIntersectionCurve::Circle(c) => (0..=64)
3251                .map(|i| ParametricCurve::evaluate(c, TAU * f64::from(i) / 64.0))
3252                .collect(),
3253            ExactIntersectionCurve::Ellipse(e) => (0..=64)
3254                .map(|i| ParametricCurve::evaluate(e, TAU * f64::from(i) / 64.0))
3255                .collect(),
3256            ExactIntersectionCurve::Points(pts) => pts.clone(),
3257        }
3258    }
3259
3260    /// Assert every returned point lies on the plane and the cone surface, on
3261    /// the real (`v >= 0`) nappe, and within a sane axial bound.
3262    fn assert_on_plane_and_cone(
3263        curves: &[ExactIntersectionCurve],
3264        cone: &ConicalSurface,
3265        n: Vec3,
3266        d: f64,
3267        z_bound: (f64, f64),
3268    ) {
3269        assert!(!curves.is_empty(), "expected at least one section curve");
3270        let mut total = 0;
3271        for curve in curves {
3272            for p in collect_points(curve) {
3273                total += 1;
3274                let plane_err = (n.x() * p.x() + n.y() * p.y() + n.z() * p.z() - d).abs();
3275                assert!(
3276                    plane_err < 1e-9,
3277                    "point off plane by {plane_err:.2e}: {p:?}"
3278                );
3279                let (u, v) = cone.project_point(p);
3280                let q = cone.evaluate(u, v);
3281                let cone_err =
3282                    ((p.x() - q.x()).powi(2) + (p.y() - q.y()).powi(2) + (p.z() - q.z()).powi(2))
3283                        .sqrt();
3284                assert!(cone_err < 1e-7, "point off cone by {cone_err:.2e}: {p:?}");
3285                assert!(v >= -1e-9, "point on phantom nappe (v={v:.4}): {p:?}");
3286                assert!(
3287                    p.z() >= z_bound.0 - 1e-6 && p.z() <= z_bound.1 + 1e-6,
3288                    "point z={:.4} outside sane bound {z_bound:?}: {p:?}",
3289                    p.z()
3290                );
3291            }
3292        }
3293        assert!(total >= 8, "too few section points ({total})");
3294    }
3295
3296    #[test]
3297    fn oblique_plane_cone_ellipse_is_exact_and_on_both() {
3298        // 45°-half-angle cone (axis +z). A plane tilted only ~16.7° off horizontal
3299        // has plane-axis angle ≈ 73° > 45° (the cone's half-opening from axis) →
3300        // ellipse. Must come back as an exact Ellipse, fully on both surfaces.
3301        let cone = ConicalSurface::new(
3302            Point3::new(0.0, 0.0, 0.0),
3303            Vec3::new(0.0, 0.0, 1.0),
3304            std::f64::consts::FRAC_PI_4,
3305        )
3306        .unwrap();
3307        let n = Vec3::new(0.3, 0.0, 1.0).normalize().unwrap();
3308        // Plane through (0,0,5): d = n·(0,0,5).
3309        let d = n.z() * 5.0;
3310        let curves = exact_plane_cone(&cone, n, d).unwrap();
3311        assert!(
3312            curves
3313                .iter()
3314                .any(|c| matches!(c, ExactIntersectionCurve::Ellipse(_))),
3315            "oblique steep plane × cone must yield an exact Ellipse"
3316        );
3317        // The ellipse straddles z=5; with the 0.3 tilt the z-extent stays modest.
3318        assert_on_plane_and_cone(&curves, &cone, n, d, (0.0, 12.0));
3319    }
3320
3321    #[test]
3322    fn oblique_plane_cone_wrong_nappe_is_empty() {
3323        // Same ellipse-regime plane as above, but offset to the FAR side of the
3324        // apex (z=-5). The +z cone's real (v≥0) nappe is not met — only the
3325        // phantom v<0 nappe — so the result must be EMPTY, not a phantom ellipse.
3326        let cone = ConicalSurface::new(
3327            Point3::new(0.0, 0.0, 0.0),
3328            Vec3::new(0.0, 0.0, 1.0),
3329            std::f64::consts::FRAC_PI_4,
3330        )
3331        .unwrap();
3332        let n = Vec3::new(0.3, 0.0, 1.0).normalize().unwrap();
3333        let d = n.z() * -5.0;
3334        let curves = exact_plane_cone(&cone, n, d).unwrap();
3335        assert!(
3336            curves.is_empty(),
3337            "plane on the phantom-nappe side must yield no real curve, got {}",
3338            curves.len()
3339        );
3340    }
3341
3342    #[test]
3343    fn oblique_plane_cone_parabola_on_both_single_branch() {
3344        // Plane normal at exactly 45° to the axis (= the cone half-opening) → the
3345        // plane is parallel to a generator → parabola. One unbounded branch.
3346        let cone = ConicalSurface::new(
3347            Point3::new(0.0, 0.0, 0.0),
3348            Vec3::new(0.0, 0.0, 1.0),
3349            std::f64::consts::FRAC_PI_4,
3350        )
3351        .unwrap();
3352        let n = Vec3::new(1.0, 0.0, 1.0).normalize().unwrap();
3353        let d = n.x() * 3.0 + n.z() * 3.0; // through (3,0,3)
3354        let curves = exact_plane_cone(&cone, n, d).unwrap();
3355        assert_eq!(
3356            curves.len(),
3357            1,
3358            "a parabola is a single branch, got {}",
3359            curves.len()
3360        );
3361        // Bounded by r_max = 32·|e|; |e| here is O(few), so allow a wide z window.
3362        assert_on_plane_and_cone(&curves, &cone, n, d, (0.0, 400.0));
3363    }
3364
3365    #[test]
3366    fn oblique_plane_cone_hyperbola_real_nappe_only() {
3367        // Faithful scooplabel lip-foot geometry: a 45° cone with axis −z and
3368        // apex at (−59,−59,15.85) (a bin corner), cut by the upper ramp tread
3369        // plane n=(0,0.99518,0.09802), d=−58.36056. The plane is nearly parallel
3370        // to the axis (cos≈0.098) → plane-axis angle ≈ 5.6° < 45° → hyperbola.
3371        // The downward real nappe is hit by exactly one branch; the phantom
3372        // upward nappe (and the asymptote runaway) must NOT appear, and the arc
3373        // must stay near the apex (the plane is ~1.2 mm from it).
3374        let cone = ConicalSurface::new(
3375            Point3::new(-59.0, -59.0, 15.85),
3376            Vec3::new(0.0, 0.0, -1.0),
3377            std::f64::consts::FRAC_PI_4,
3378        )
3379        .unwrap();
3380        let n = Vec3::new(0.0, 0.995_18, 0.098_02).normalize().unwrap();
3381        let d = -58.360_56;
3382        let cos_theta = n.dot(cone.axis()).abs();
3383        assert!(cos_theta < 0.2, "expected a shallow (hyperbola) plane");
3384        let curves = exact_plane_cone(&cone, n, d).unwrap();
3385        // Real downward nappe only: never above the apex (z=15.85). The vertex is
3386        // ~1.2 mm from the apex, so the bounded arc stays within a few mm of it.
3387        assert_on_plane_and_cone(&curves, &cone, n, d, (5.0, 15.85));
3388        // Every returned curve is sampled Points (no false Circle/Ellipse).
3389        for c in &curves {
3390            assert!(
3391                matches!(c, ExactIntersectionCurve::Points(_)),
3392                "hyperbola must be sampled Points, not a closed conic"
3393            );
3394        }
3395    }
3396}