Skip to main content

brep_kernel/geometry/analytic_surface/
intersect.rs

1use super::*;
2use super::recognition::{generatrix_is_meridional_half_ray, surface_scale};
3use super::revolution::{intersect_coaxial_revolutions, rotate_curve_about_axis};
4
5/// Exact plane data extracted from an affine patch.
6struct PlaneData {
7    origin: Vec3,
8    normal: Vec3,
9}
10
11fn plane_data(analytic: &AnalyticSurface) -> Option<PlaneData> {
12    match analytic {
13        AnalyticSurface::Plane {
14            origin,
15            u_dir,
16            v_dir,
17            ..
18        } => Some(PlaneData {
19            origin: *origin,
20            normal: u_dir.cross(*v_dir).normalized().ok()?,
21        }),
22        _ => None,
23    }
24}
25
26/// Apply a homogeneous 4x4-style map to a rational curve's control points.
27/// `map` produces the image (x', y', z', w') of each homogeneous control
28/// point; weights must come back strictly one-signed or the section is not
29/// an ellipse/circle and we refuse it.
30fn map_rational_curve(
31    curve: &NurbsCurve,
32    map: impl Fn(crate::Vec4) -> crate::Vec4,
33) -> Option<NurbsCurve> {
34    let mut controls = Vec::with_capacity(curve.control_points.len());
35    let mut sign = 0.0f64;
36    for point in &curve.control_points {
37        let image = map(*point);
38        if image.w == 0.0 || !image.w.is_finite() {
39            return None;
40        }
41        if sign == 0.0 {
42            sign = image.w.signum();
43        } else if image.w.signum() != sign {
44            return None;
45        }
46        controls.push(image);
47    }
48    if sign < 0.0 {
49        for point in &mut controls {
50            point.x = -point.x;
51            point.y = -point.y;
52            point.z = -point.z;
53            point.w = -point.w;
54        }
55    }
56    NurbsCurve::new(curve.degree, curve.knots.clone(), controls).ok()
57}
58
59/// The full u-circle of a revolution surface at radial distance `rho` and
60/// axial height `z`, built with the same arc construction as the surface.
61fn frame_circle(frame: &RevolutionFrame, rho: f64, z: f64) -> Option<NurbsCurve> {
62    make_arc(
63        frame.origin.add(frame.axis.scale(z)),
64        frame.x_axis,
65        frame.y_axis,
66        rho,
67        0.0,
68        std::f64::consts::TAU,
69    )
70    .ok()
71}
72
73/// Exact intersection curves for recognized analytic pairs, or `None` when
74/// the pair is not handled and the caller must fall back to SSI marching.
75/// `Some(vec![])` means "provably empty" and skips marching entirely.
76pub fn intersect_analytic_pair(
77    first: &NurbsSurface,
78    second: &NurbsSurface,
79    tolerance: f64,
80) -> Option<Vec<NurbsCurve>> {
81    intersect_analytic_pair_with(first, second, tolerance, ruled_gate())
82}
83
84/// Which predicate admits a general revolution's generatrix as the straight
85/// line of a cone, frustum or cylinder in [`intersect_analytic_pair`].
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub(crate) enum RuledGate {
88    /// The count proxy: degree 1, exactly two control points, unit weights.
89    Count,
90    /// What the section constructions consume: the generatrix's two ends, and
91    /// that the curve between them is the straight segment joining them
92    /// ([`NurbsCurve::straight_segment`]).
93    Geometry,
94}
95
96/// The gate the boolean runs: straightness, which is all the section
97/// constructions read. The count proxy declined a knot-refined, degree-elevated
98/// or re-weighted generatrix of the same cone and sent the pair to the marcher.
99/// Escape hatch for measurement: `BREP_RULED_GATE_GEOMETRY=0` runs the count
100/// gate.
101pub(crate) fn ruled_gate() -> RuledGate {
102    if std::env::var("BREP_RULED_GATE_GEOMETRY").as_deref() == Ok("0") {
103        RuledGate::Count
104    } else {
105        RuledGate::Geometry
106    }
107}
108
109/// [`intersect_analytic_pair`] under a named generatrix gate — what the fit
110/// census asks of the gate the boolean does NOT run.
111pub(crate) fn intersect_analytic_pair_with(
112    first: &NurbsSurface,
113    second: &NurbsSurface,
114    tolerance: f64,
115    gate: RuledGate,
116) -> Option<Vec<NurbsCurve>> {
117    if let Some(curves) = intersect_recognized_pair(first, second, tolerance, gate) {
118        return Some(curves);
119    }
120    if let Some(curves) = intersect_plane_affine_sphere(first, second, tolerance) {
121        return Some(curves);
122    }
123    if let Some(curves) = intersect_coaxial_revolutions(first, second, tolerance) {
124        return Some(curves);
125    }
126    // Only under the production gate: the count gate is the escape hatch that
127    // measures the old routing (`ruled_gate_admits_a_straight_generatrix_
128    // whatever_it_counts` reads it sending a re-weighted cone to the marcher).
129    if gate == RuledGate::Geometry && plane_revolution_section_enabled() {
130        if let Some(curves) = intersect_perpendicular_plane_revolution(first, second, tolerance) {
131            return Some(curves);
132        }
133        if let Some(curves) = intersect_perpendicular_plane_revolution(second, first, tolerance) {
134            return Some(curves);
135        }
136    }
137    if let Some(curves) = intersect_axial_plane_revolution(first, second, tolerance) {
138        return Some(curves);
139    }
140    intersect_axial_plane_revolution(second, first, tolerance)
141}
142
143/// PLANE x REVOLUTION SECTION (2026-11-06;
144/// `BREP_PLANE_REVOLUTION_SECTION`, default on, `=0` off). A plane square to the axis
145/// of a general surface of revolution (one no quadric arm reads: a revolved
146/// parabola, a revolved spline) meets it in one circle per height at which the
147/// generatrix crosses the plane, over the revolution's sweep. No arm read this
148/// pair, so it went to the marcher, which seeds on a grid and finds no loop
149/// much smaller than the faces: brep-bench
150/// `tangent/paraboloid-tip-a-ten-thousandth-high` (a size-8 paraboloid cut by
151/// z = 1e-4, a rim circle of radius 0.028) read "no boundary faces".
152///
153/// The lane answers only when every crossing is clean: each a sign change of
154/// the generatrix's height over the plane, transversal, and away from the
155/// generatrix's ends and from the axis, and the scan's crossings are as many
156/// as the generatrix's control points change sides of the plane (so none can
157/// hide between two stations). A touching height, a crossing at a
158/// rim the face already has as an edge, or no crossing at all keeps the pair
159/// on the paths it took before (`None`).
160fn plane_revolution_section_enabled() -> bool {
161    crate::switch!("BREP_PLANE_REVOLUTION_SECTION", true)
162}
163
164fn intersect_perpendicular_plane_revolution(
165    plane_surface: &NurbsSurface,
166    revolution: &NurbsSurface,
167    tolerance: f64,
168) -> Option<Vec<NurbsCurve>> {
169    let plane = plane_data(plane_surface.analytic()?)?;
170    if matches!(revolution.analytic(), Some(AnalyticSurface::Plane { .. })) {
171        return None;
172    }
173    let structure = super::revolution::revolution_structure(revolution)?;
174    let frame = &structure.frame;
175    if plane.normal.dot(frame.axis).abs() < 1.0 - 1e-12 {
176        return None;
177    }
178    let generatrix = &structure.generatrix;
179    let [start, end] = generatrix.domain().ok()?;
180    let height = |t: f64| -> Option<f64> { Some(generatrix.evaluate(t).ok()?.sub(plane.origin).dot(plane.normal)) };
181    let scale = surface_scale(revolution).max(1.0);
182    let band = tolerance.max(1e-9 * scale);
183    let spans = generatrix.knots.windows(2).filter(|pair| pair[1] > pair[0]).count().max(1);
184    let stations = 64 * spans;
185    let mut previous_t = start;
186    let mut previous = height(start)?;
187    // A generatrix end ON the plane is a rim the face has as an edge.
188    if previous.abs() <= band || height(end)?.abs() <= band {
189        return None;
190    }
191    let mut roots = Vec::new();
192    for index in 1..=stations {
193        let t = start + (end - start) * index as f64 / stations as f64;
194        let value = height(t)?;
195        if value.abs() <= band && index < stations {
196            // A station on the plane: a touching height or a crossing exactly
197            // here; the bisection below cannot tell them apart.
198            return None;
199        }
200        if (previous < 0.0) != (value < 0.0) {
201            let (mut lo, mut hi, mut lo_value) = (previous_t, t, previous);
202            for _ in 0..200 {
203                let middle = 0.5 * (lo + hi);
204                if middle <= lo || middle >= hi {
205                    break;
206                }
207                let middle_value = height(middle)?;
208                if (middle_value < 0.0) == (lo_value < 0.0) {
209                    lo = middle;
210                    lo_value = middle_value;
211                } else {
212                    hi = middle;
213                }
214            }
215            roots.push(0.5 * (lo + hi));
216        }
217        previous_t = t;
218        previous = value;
219    }
220    if roots.is_empty() {
221        return None;
222    }
223    // The height's numerator `sum w_i ((P_i - o).n) N_i(t)` is a B-spline, so
224    // it has no more roots than its coefficients have sign changes (variation
225    // diminishing). A count above the scan's means two crossings may share a
226    // station, which the scan cannot see: decline rather than drop a circle.
227    let mut coefficient_signs = Vec::new();
228    for control in &generatrix.control_points {
229        if control.w <= 0.0 {
230            return None;
231        }
232        let value = control.point().ok()?.sub(plane.origin).dot(plane.normal);
233        if value.abs() <= band {
234            return None;
235        }
236        coefficient_signs.push(value > 0.0);
237    }
238    let sign_changes = coefficient_signs.windows(2).filter(|pair| pair[0] != pair[1]).count();
239    if sign_changes != roots.len() {
240        return None;
241    }
242    let radius_floor = (tolerance * 10.0).max(1e-9 * scale);
243    let mut curves = Vec::new();
244    for root in roots {
245        let derivatives = generatrix.derivatives(root, 1).ok()?;
246        let tangent = derivatives.get(1)?.normalized().ok()?;
247        if tangent.dot(plane.normal).abs() <= 1e-9 {
248            return None;
249        }
250        let point = generatrix.evaluate(root).ok()?;
251        let (_, radius, axial) = frame.cylindrical(point);
252        if radius <= radius_floor {
253            return None;
254        }
255        let center = frame.origin.add(frame.axis.scale(axial));
256        curves.push(make_arc(center, frame.x_axis, frame.y_axis, radius, 0.0, structure.sweep).ok()?);
257    }
258    Some(curves)
259}
260
261/// Exact intersection of a plane CONTAINING the revolution axis with a
262/// surface of revolution: the meridian profile rotated to the (at most
263/// two) angles where the plane crosses the sweep. The marcher fits these
264/// as long noisy polylines otherwise (a revolve's start/end caps against
265/// the other operand's revolved walls in revolve-with-holes booleans).
266fn intersect_axial_plane_revolution(
267    plane_surface: &NurbsSurface,
268    revolved: &NurbsSurface,
269    _tolerance: f64,
270) -> Option<Vec<NurbsCurve>> {
271    let plane = match plane_surface.analytic()? {
272        AnalyticSurface::Plane {
273            origin,
274            u_dir,
275            v_dir,
276            ..
277        } => (*origin, u_dir.cross(*v_dir).normalized().ok()?),
278        _ => return None,
279    };
280    let structure = revolution_structure(revolved)?;
281    if !generatrix_is_meridional_half_ray(&structure.generatrix, &structure.frame) {
282        return None;
283    }
284    let scale = surface_scale(plane_surface)
285        .max(surface_scale(revolved))
286        .max(1.0);
287    let (plane_origin, plane_normal) = plane;
288    if structure.frame.axis.dot(plane_normal).abs() > 1e-9 {
289        return None;
290    }
291    if structure
292        .frame
293        .origin
294        .sub(plane_origin)
295        .dot(plane_normal)
296        .abs()
297        > 1e-9 * scale
298    {
299        return None;
300    }
301    let radial = structure.frame.axis.cross(plane_normal).normalized().ok()?;
302    let tau = std::f64::consts::TAU;
303    let mut curves = Vec::new();
304    for direction in [radial, radial.scale(-1.0)] {
305        let mut angle = direction
306            .dot(structure.frame.y_axis)
307            .atan2(direction.dot(structure.frame.x_axis));
308        if angle < -1e-9 {
309            angle += tau;
310        }
311        if angle <= structure.sweep + 1e-9 {
312            curves.push(rotate_curve_about_axis(
313                &structure.generatrix,
314                structure.frame.origin,
315                structure.frame.axis,
316                angle.clamp(0.0, structure.sweep),
317            )?);
318        }
319    }
320    Some(curves)
321}
322
323/// Plane x affine sphere (`geometry/affine_sphere.rs`, under
324/// `BREP_AFFINE_SPHERE`): the exact ellipse, either operand order. Nothing
325/// else pairs with an ellipsoid in closed form (sphere, cylinder and cone
326/// sections of it are quartics; the marcher keeps them).
327fn intersect_plane_affine_sphere(
328    first: &NurbsSurface,
329    second: &NurbsSurface,
330    tolerance: f64,
331) -> Option<Vec<NurbsCurve>> {
332    for (plane_surface, other) in [(first, second), (second, first)] {
333        let Some(ellipsoid) = other.affine_sphere() else { continue };
334        let Some(plane) = plane_surface.analytic().and_then(plane_data) else { continue };
335        return ellipsoid.plane_section(plane.origin, plane.normal, tolerance);
336    }
337    None
338}
339
340fn intersect_recognized_pair(
341    first: &NurbsSurface,
342    second: &NurbsSurface,
343    tolerance: f64,
344    gate: RuledGate,
345) -> Option<Vec<NurbsCurve>> {
346    let a = first.analytic()?;
347    let b = second.analytic()?;
348    if !matches!(b, AnalyticSurface::Plane { .. }) {
349        if let Some(plane) = plane_data(a) {
350            return intersect_plane_quadric(&plane, b, tolerance, gate);
351        }
352    }
353    if !matches!(a, AnalyticSurface::Plane { .. }) {
354        if let Some(plane) = plane_data(b) {
355            return intersect_plane_quadric(&plane, a, tolerance, gate);
356        }
357    }
358    // Sphere×sphere, whichever way each sphere is parameterized (a reflected
359    // sphere recognizes as a general `Revolution`; see `sphere_geometry`).
360    if let (Some((center_a, radius_a)), Some((center_b, radius_b))) =
361        (a.sphere_geometry(), b.sphere_geometry())
362    {
363        return intersect_sphere_sphere(center_a, radius_a, center_b, radius_b, tolerance);
364    }
365    // Plane×plane is deliberately NOT special-cased: the iso path
366    // already returns axis-aligned box intersections exactly, and a
367    // synthesized over-long line segment interacts poorly with the
368    // shared-parameter pcurve contract in process_curve (the pcurve is
369    // fitted over the face crossing only, so the curve's [t0,t1]
370    // subrange and the pcurve domain drift apart on long overhangs).
371    None
372}
373
374/// Frame/meridian data for any revolution with a straight-line generatrix:
375/// the `RuledRevolution` quadrics themselves, plus partial-sweep
376/// `Revolution`s whose generatrix the named gate admits as straight (fillet
377/// cutter walls are quarter cylinders of this shape). The plane
378/// intersectors only need the carrier geometry — the boolean trims the
379/// returned curves to the actual face domains afterwards.
380fn ruled_revolution_data(
381    quadric: &AnalyticSurface,
382    tolerance: f64,
383    gate: RuledGate,
384) -> Option<(RevolutionFrame, f64, f64, f64)> {
385    match quadric {
386        AnalyticSurface::RuledRevolution {
387            frame,
388            rho0,
389            rho1,
390            height,
391        } => Some((frame.clone(), *rho0, *rho1, *height)),
392        AnalyticSurface::Revolution {
393            frame, generatrix, ..
394        } => {
395            let (start, end) = match gate {
396                RuledGate::Count => {
397                    let controls = &generatrix.control_points;
398                    if generatrix.degree != 1
399                        || controls.len() != 2
400                        || (controls[0].w - 1.0).abs() > RECOGNITION_TOLERANCE
401                        || (controls[1].w - 1.0).abs() > RECOGNITION_TOLERANCE
402                    {
403                        return None;
404                    }
405                    (controls[0].point().ok()?, controls[1].point().ok()?)
406                }
407                RuledGate::Geometry => generatrix.straight_segment(tolerance)?,
408            };
409            let (_, rho0, z0) = frame.cylindrical(start);
410            let (_, rho1, z1) = frame.cylindrical(end);
411            let height = z1 - z0;
412            if height.abs() <= 1e-12 * (1.0 + rho0.abs().max(rho1.abs())) {
413                return None;
414            }
415            let origin = frame.origin.add(frame.axis.scale(z0));
416            Some((
417                RevolutionFrame {
418                    origin,
419                    ..frame.clone()
420                },
421                rho0,
422                rho1,
423                height,
424            ))
425        }
426        _ => None,
427    }
428}
429
430/// CENSUS ONLY (`BREP_FIT_CENSUS=1`): a description of `surface`'s generatrix
431/// when it is a general revolution the two generatrix gates answer
432/// differently, `None` otherwise.
433pub(crate) fn ruled_gate_census(surface: &NurbsSurface, tolerance: f64) -> Option<String> {
434    let quadric = surface.analytic()?;
435    let AnalyticSurface::Revolution { generatrix, .. } = quadric else {
436        return None;
437    };
438    let count = ruled_revolution_data(quadric, tolerance, RuledGate::Count).is_some();
439    let geometry = ruled_revolution_data(quadric, tolerance, RuledGate::Geometry).is_some();
440    if count == geometry {
441        return None;
442    }
443    let controls = &generatrix.control_points;
444    let points: Vec<Vec3> = controls
445        .iter()
446        .map(|control| control.point().unwrap_or_default())
447        .collect();
448    let (first, last) = (points[0], points[points.len() - 1]);
449    let direction = last.sub(first);
450    let length_squared = direction.length_squared();
451    let mut collinearity = 0.0_f64;
452    for point in &points {
453        collinearity = collinearity.max(if length_squared <= 0.0 {
454            point.sub(first).length()
455        } else {
456            let fraction = (point.sub(first).dot(direction) / length_squared).clamp(0.0, 1.0);
457            point.sub(first.add(direction.scale(fraction))).length()
458        });
459    }
460    let unit_weights = controls
461        .iter()
462        .all(|control| (control.w - 1.0).abs() <= RECOGNITION_TOLERANCE);
463    Some(format!(
464        "degree={} controls={} collinearity={collinearity:.3e} unit_weights={unit_weights} \
465         count_gate={count} geometry_gate={geometry}",
466        generatrix.degree,
467        controls.len()
468    ))
469}
470
471/// TRUE when a recognized frustum's radius drift is below the precision floor
472/// of the cone construction itself, so the plane section must take the
473/// CYLINDER branch. The cone branch builds its conic as a projective image of
474/// a base circle toward the apex at distance ~|height|·max|ρ|/|Δρ|; the map's
475/// floating-point error grows like that distance × machine epsilon, so for a
476/// near-cylinder frustum (t363: Δρ = 2.23e-12 over height 1500, apex ~1.5e16)
477/// the "exact analytic" section comes back up to ~1 mm off BOTH surfaces.
478/// Downstream, `build_pcurve_on_surface` honestly measures that deviation on
479/// the curved operand, drops every interior sample, and collapses the ring's
480/// pcurve to a single point — the face under-splits and the boolean fails
481/// with one-use edges / non-integral genus.
482///
483/// Branch selection by error balance: the cylinder approximation errs by at
484/// most |Δρ|, the cone construction by ~ε·|height|·max|ρ|/|Δρ|; prefer the
485/// cylinder when its error is smaller, i.e. Δρ² ≤ ε·|height|·max|ρ| (with a
486/// modest constant for the map's factors). This is a deterministic choice
487/// between two exact-in-infinite-precision constructions — not a tolerance —
488/// and can only ever pick the MORE accurate branch. The legacy absolute
489/// 1e-12 acceptance remains as a floor at the call site. Escape hatch:
490/// BREP_CONE_APEX_GUARD=0 restores the bare 1e-12 discrimination.
491fn degenerate_apex_frustum(rho0: f64, rho1: f64, height: f64) -> bool {
492    if std::env::var("BREP_CONE_APEX_GUARD").as_deref() == Ok("0") {
493        return false;
494    }
495    let delta = rho1 - rho0;
496    delta * delta <= 64.0 * f64::EPSILON * height.abs() * rho0.abs().max(rho1.abs())
497}
498
499/// The EXACT arc of `plane ∩ ruled revolution` spanning a given AZIMUTH range
500/// about the carrier's own frame — the arc-restricted, seam-anchorable form of
501/// the full sections `intersect_plane_quadric` returns.
502///
503/// ADDITION, not a change: `intersect_plane_quadric` is untouched and still
504/// answers every pair it answered before, bit for bit. This function exists
505/// because a direct edit needs the section as an EDGE, not as a carrier
506/// section, and an edge has endpoints: a closed rim must START at the face's
507/// own seam vertex (which need not sit at the carrier's `u = 0` — see the
508/// push-face seam fix), and an open rim is a sub-arc between two re-solved
509/// corners. `intersect_plane_quadric` always returns the whole section anchored
510/// at the frame's `x_axis`, so neither is expressible through it.
511///
512/// Both constructions below are the ones that function already uses, and both
513/// preserve AZIMUTH — the cylinder's map adds only an axial component, the
514/// cone's is a central projection from a point ON the axis — so "the arc from
515/// azimuth `a` through `sweep`" is well defined on the section itself and the
516/// anchoring is exact rather than fitted:
517///
518/// - **Cylinder** (`rho1 ≈ rho0`): the section is the AFFINE image of the base
519///   circle under the axial shear `X ↦ X + axis·(c − n·X)/(n·axis)`, which is
520///   linear in the homogeneous control point, so a rational-quadratic arc maps
521///   to a rational-quadratic arc on the SAME knot vector.
522/// - **Cone/frustum**: the section is the PROJECTIVE image from the apex,
523///   `X ↦ apex + (k/((X−apex)·n))·(X−apex)`, again linear in the homogeneous
524///   control point. Weight signs flip for a parabolic/hyperbolic section (the
525///   arc crosses the apex plane), and those are refused by name rather than
526///   silently mis-built.
527///
528/// `sweep` must be positive and at most a full turn; a reversed rim is built
529/// forward and reversed by the caller, which is what keeps the knot vector the
530/// arc construction's own.
531pub fn plane_ruled_section_arc(
532    plane_origin: Vec3,
533    plane_normal: Vec3,
534    frame: &RevolutionFrame,
535    rho0: f64,
536    rho1: f64,
537    height: f64,
538    start_azimuth: f64,
539    sweep: f64,
540    tolerance: f64,
541) -> Result<NurbsCurve, String> {
542    let tau = std::f64::consts::TAU;
543    if !(start_azimuth.is_finite() && sweep.is_finite()) {
544        return Err("plane_ruled_section_arc: non-finite azimuth range".into());
545    }
546    if sweep <= 1e-12 || sweep > tau + 1e-9 {
547        return Err(format!(
548            "plane_ruled_section_arc: the section arc sweeps {sweep:.6e} rad, which is not a \
549             positive arc of at most one turn — refusing"
550        ));
551    }
552    let normal = plane_normal.normalized()?;
553    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
554    let axis = frame.axis;
555    let is_cylinder = (rho1 - rho0).abs() <= 1e-9 * radius_scale
556        || degenerate_apex_frustum(rho0, rho1, height);
557    let curve = if is_cylinder {
558        let alignment = normal.dot(axis);
559        if alignment.abs() <= 1e-12 {
560            return Err(
561                "plane_ruled_section_arc: the plane is parallel to the cylinder axis, so the \
562                 section is a generatrix pair rather than a conic — refusing"
563                    .into(),
564            );
565        }
566        if rho0.abs() <= tolerance {
567            return Err("plane_ruled_section_arc: the cylinder carrier has no radius".into());
568        }
569        let base = make_arc(
570            frame.origin,
571            frame.x_axis,
572            frame.y_axis,
573            rho0.abs(),
574            start_azimuth,
575            start_azimuth + sweep,
576        )?;
577        let origin_dot = plane_origin.dot(normal);
578        map_rational_curve(&base, |p| {
579            let t = (origin_dot * p.w - Vec3::new(p.x, p.y, p.z).dot(normal)) / alignment;
580            crate::Vec4 {
581                x: p.x + axis.x * t,
582                y: p.y + axis.y * t,
583                z: p.z + axis.z * t,
584                w: p.w,
585            }
586        })
587        .ok_or_else(|| {
588            "plane_ruled_section_arc: the cylinder section's weights are not one-signed \
589             — refusing"
590                .to_string()
591        })?
592    } else {
593        let apex = frame.origin.add(axis.scale(height * rho0 / (rho0 - rho1)));
594        let k = plane_origin.sub(apex).dot(normal);
595        if k.abs() <= tolerance {
596            return Err(
597                "plane_ruled_section_arc: the plane passes through the cone apex, so the \
598                 section is a line pair rather than a conic — refusing"
599                    .into(),
600            );
601        }
602        let (reference_rho, reference_z) = if rho0.abs() > rho1.abs() {
603            (rho0.abs(), 0.0)
604        } else {
605            (rho1.abs(), height)
606        };
607        if reference_rho <= tolerance {
608            return Err("plane_ruled_section_arc: the cone carrier degenerates to its apex".into());
609        }
610        let base = make_arc(
611            frame.origin.add(axis.scale(reference_z)),
612            frame.x_axis,
613            frame.y_axis,
614            reference_rho,
615            start_azimuth,
616            start_azimuth + sweep,
617        )?;
618        map_rational_curve(&base, |p| {
619            let relative = Vec3::new(p.x - p.w * apex.x, p.y - p.w * apex.y, p.z - p.w * apex.z);
620            let w_new = relative.dot(normal);
621            let scaled = relative.scale(k);
622            crate::Vec4 {
623                x: apex.x * w_new + scaled.x,
624                y: apex.y * w_new + scaled.y,
625                z: apex.z * w_new + scaled.z,
626                w: w_new,
627            }
628        })
629        .ok_or_else(|| {
630            "plane_ruled_section_arc: the section crosses the cone's apex plane (a parabolic \
631             or hyperbolic branch, whose weights change sign) — refusing"
632                .to_string()
633        })?
634    };
635    // The construction is exact, so this measures rather than fits: every
636    // sample must sit on BOTH carriers, at the carrier's own radius for its own
637    // axial station and on the plane.
638    let [d0, d1] = curve.domain()?;
639    let slope = if height == 0.0 {
640        0.0
641    } else {
642        (rho1 - rho0) / height
643    };
644    for step in 0..=8 {
645        let point = curve.evaluate(d0 + (d1 - d0) * (step as f64 / 8.0))?;
646        let delta = point.sub(frame.origin);
647        let axial = delta.dot(axis);
648        let radial = delta.sub(axis.scale(axial)).length();
649        let off_ruled = (radial - (rho0 + slope * axial)).abs();
650        let off_plane = point.sub(plane_origin).dot(normal).abs();
651        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
652            return Err(format!(
653                "plane_ruled_section_arc: the section does not lie on both carriers (off ruled \
654                 {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
655            ));
656        }
657    }
658    Ok(curve)
659}
660
661fn intersect_plane_quadric(
662    plane: &PlaneData,
663    quadric: &AnalyticSurface,
664    tolerance: f64,
665    gate: RuledGate,
666) -> Option<Vec<NurbsCurve>> {
667    match quadric {
668        AnalyticSurface::RuledRevolution { .. } | AnalyticSurface::Revolution { .. }
669            if ruled_revolution_data(quadric, tolerance, gate).is_some() =>
670        {
671            let (frame, rho0, rho1, height) = ruled_revolution_data(quadric, tolerance, gate)?;
672            let (frame, rho0, rho1, height) = (&frame, &rho0, &rho1, &height);
673            let alignment = plane.normal.dot(frame.axis);
674            if alignment.abs() >= 1.0 - 1e-12 {
675                // Perpendicular plane: one circle at the plane's height.
676                let z = plane.origin.sub(frame.origin).dot(frame.axis);
677                let t = z / height;
678                if !(-1e-9..=1.0 + 1e-9).contains(&t) {
679                    return Some(Vec::new());
680                }
681                let rho = rho0 + (rho1 - rho0) * t.clamp(0.0, 1.0);
682                if rho <= tolerance {
683                    return Some(Vec::new());
684                }
685                return Some(vec![frame_circle(frame, rho, z)?]);
686            }
687            if (rho1 - rho0).abs() <= 1e-12 || degenerate_apex_frustum(*rho0, *rho1, *height) {
688                // Cylinder.
689                if alignment.abs() <= 1e-12 {
690                    // Plane parallel to the axis: zero, one, or two
691                    // generatrix lines at the circle/line crossing angles.
692                    return cylinder_parallel_plane_lines(plane, frame, *rho0, *height, tolerance);
693                }
694                // Oblique section: exact ellipse as the affine image of a
695                // base circle along the axis direction.
696                let circle = frame_circle(frame, *rho0, 0.0)?;
697                let denominator = alignment;
698                let origin_dot = plane.origin.dot(plane.normal);
699                let normal = plane.normal;
700                let axis = frame.axis;
701                let ellipse = map_rational_curve(&circle, |p| {
702                    let t = (origin_dot * p.w - Vec3::new(p.x, p.y, p.z).dot(normal)) / denominator;
703                    crate::Vec4 {
704                        x: p.x + axis.x * t,
705                        y: p.y + axis.y * t,
706                        z: p.z + axis.z * t,
707                        w: p.w,
708                    }
709                })?;
710                if !section_within_band(&ellipse, frame, *height, tolerance) {
711                    return Some(Vec::new());
712                }
713                return Some(vec![ellipse]);
714            }
715            // Cone/frustum: projective image of a base circle toward the
716            // apex. Weight signs flip for parabolic/hyperbolic sections and
717            // map_rational_curve refuses those (marcher fallback).
718            let apex_t = rho0 / (rho0 - rho1);
719            let apex = frame.origin.add(frame.axis.scale(height * apex_t));
720            let reference_rho = if rho0.abs() > rho1.abs() {
721                *rho0
722            } else {
723                *rho1
724            };
725            let reference_z = if rho0.abs() > rho1.abs() {
726                0.0
727            } else {
728                *height
729            };
730            let circle = frame_circle(frame, reference_rho, reference_z)?;
731            let k = plane.origin.sub(apex).dot(plane.normal);
732            if k.abs() <= tolerance {
733                // Plane through the apex: line pair; leave to the marcher.
734                return None;
735            }
736            let normal = plane.normal;
737            let conic = map_rational_curve(&circle, |p| {
738                let relative =
739                    Vec3::new(p.x - p.w * apex.x, p.y - p.w * apex.y, p.z - p.w * apex.z);
740                let w_new = relative.dot(normal);
741                let scaled = relative.scale(k);
742                crate::Vec4 {
743                    x: apex.x * w_new + scaled.x,
744                    y: apex.y * w_new + scaled.y,
745                    z: apex.z * w_new + scaled.z,
746                    w: w_new,
747                }
748            })?;
749            if !section_within_band(&conic, frame, *height, tolerance) {
750                return Some(Vec::new());
751            }
752            Some(vec![conic])
753        }
754        AnalyticSurface::Sphere { frame, radius } => {
755            plane_sphere_section(plane, frame.origin, *radius, tolerance)
756        }
757        AnalyticSurface::Torus {
758            frame,
759            major_radius,
760            minor_radius,
761        } => plane_torus_section(plane, frame, *major_radius, *minor_radius, tolerance),
762        AnalyticSurface::Plane { .. } => None,
763        AnalyticSurface::RuledRevolution { .. } => None,
764        // A REFLECTED sphere or torus (mirror feature, negative-determinant
765        // transform) recognizes as a general `Revolution` — same point set,
766        // opposite parameter handedness — and keeps its closed-form sections.
767        // Any other general revolution has none; the axial-plane and
768        // tangential-contact paths in imprint own those.
769        AnalyticSurface::Revolution { .. } => {
770            if let Some((center, radius)) = quadric.sphere_geometry() {
771                return plane_sphere_section(plane, center, radius, tolerance);
772            }
773            if let Some((frame, major_radius, minor_radius)) = quadric.torus_geometry() {
774                return plane_torus_section(plane, &frame, major_radius, minor_radius, tolerance);
775            }
776            None
777        }
778    }
779}
780
781/// Plane × sphere: one circle, or provably empty when the plane misses or
782/// merely TOUCHES the sphere (a point contact imprints nothing).
783fn plane_sphere_section(
784    plane: &PlaneData,
785    center: Vec3,
786    radius: f64,
787    tolerance: f64,
788) -> Option<Vec<NurbsCurve>> {
789    let distance = center.sub(plane.origin).dot(plane.normal);
790    if distance.abs() >= radius - tolerance {
791        return Some(Vec::new());
792    }
793    let circle_center = center.sub(plane.normal.scale(distance));
794    let circle_radius = (radius * radius - distance * distance).sqrt();
795    let x_axis = plane.normal.perpendicular().ok()?;
796    let y_axis = plane.normal.cross(x_axis);
797    Some(vec![make_arc(
798        circle_center,
799        x_axis,
800        y_axis,
801        circle_radius,
802        0.0,
803        std::f64::consts::TAU,
804    )
805    .ok()?])
806}
807
808/// Plane ⟂ torus axis: the two latitude circles at the plane's height (one
809/// when it grazes the tube's inner side), or provably empty above/below the
810/// tube.  `frame.origin` must sit at the tube centre's axial position.  An
811/// oblique plane has no closed form here (`None` → marcher).
812fn plane_torus_section(
813    plane: &PlaneData,
814    frame: &RevolutionFrame,
815    major_radius: f64,
816    minor_radius: f64,
817    tolerance: f64,
818) -> Option<Vec<NurbsCurve>> {
819    let alignment = plane.normal.dot(frame.axis);
820    if alignment.abs() < 1.0 - 1e-12 {
821        return None;
822    }
823    let z = plane.origin.sub(frame.origin).dot(frame.axis);
824    if z.abs() >= minor_radius - tolerance {
825        return Some(Vec::new());
826    }
827    let offset = (minor_radius * minor_radius - z * z).sqrt();
828    let mut circles = Vec::new();
829    for rho in [major_radius - offset, major_radius + offset] {
830        if rho > tolerance {
831            circles.push(frame_circle(frame, rho, z)?);
832        }
833    }
834    Some(circles)
835}
836
837/// Reject sections that entirely miss the ruled surface's axial band; the
838/// imprint trimmer would discard them anyway, but skipping early avoids
839/// building pcurves for out-of-band curves.
840///
841/// Uses the convex-hull property of a same-sign-weight rational curve: every
842/// curve point's axial coordinate lies within the [min, max] axial span of the
843/// Cartesian control points. So the section can only miss the band when the
844/// WHOLE control-point span sits above or below it. The earlier `any(CP in
845/// band)` test was wrong for near-axis-parallel oblique sections: the affine
846/// map divides the axial displacement by `alignment`, flinging the rational
847/// control points far to BOTH sides of the band (they straddle it with none
848/// landing inside), so a genuine crossing arc was falsely rejected — the
849/// missing cap∩cylinder imprint behind the curved-primitive open-edge /
850/// non-integral-genus booleans. An interval-overlap test can never falsely
851/// reject (hull property); a false accept is harmless (the imprint trimmer
852/// clips the returned curve to the real face domains regardless).
853fn section_within_band(
854    curve: &NurbsCurve,
855    frame: &RevolutionFrame,
856    height: f64,
857    tolerance: f64,
858) -> bool {
859    let (band_low, band_high) = if height >= 0.0 {
860        (0.0, height)
861    } else {
862        (height, 0.0)
863    };
864    let margin = tolerance.max(1e-9) + 1e-9;
865    let mut min_z = f64::INFINITY;
866    let mut max_z = f64::NEG_INFINITY;
867    for p in &curve.control_points {
868        let z = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w)
869            .sub(frame.origin)
870            .dot(frame.axis);
871        min_z = min_z.min(z);
872        max_z = max_z.max(z);
873    }
874    // The control-point axial span overlaps the band.
875    max_z >= band_low - margin && min_z <= band_high + margin
876}
877
878fn cylinder_parallel_plane_lines(
879    plane: &PlaneData,
880    frame: &RevolutionFrame,
881    radius: f64,
882    height: f64,
883    tolerance: f64,
884) -> Option<Vec<NurbsCurve>> {
885    // Distance from the axis to the plane, measured in the cross-section.
886    let axis_to_plane = plane.origin.sub(frame.origin).dot(plane.normal);
887    if axis_to_plane.abs() >= radius - tolerance {
888        return Some(Vec::new());
889    }
890    // In-plane offset of the chord from the axis foot.
891    let chord_half = (radius * radius - axis_to_plane * axis_to_plane).sqrt();
892    let chord_direction = frame.axis.cross(plane.normal).normalized().ok()?;
893    let foot = frame.origin.add(plane.normal.scale(axis_to_plane));
894    let mut lines = Vec::new();
895    for sign in [-1.0, 1.0] {
896        let base = foot.add(chord_direction.scale(sign * chord_half));
897        let top = base.add(frame.axis.scale(height));
898        lines.push(crate::make_line(base, top).ok()?);
899    }
900    Some(lines)
901}
902
903fn intersect_sphere_sphere(
904    center_a: Vec3,
905    radius_a: f64,
906    center_b: Vec3,
907    radius_b: f64,
908    tolerance: f64,
909) -> Option<Vec<NurbsCurve>> {
910    let offset = center_b.sub(center_a);
911    let distance = offset.length();
912    if distance <= tolerance {
913        // Concentric: empty or coincident; the cosurface path owns
914        // coincident carriers.
915        return if (radius_a - radius_b).abs() <= tolerance {
916            None
917        } else {
918            Some(Vec::new())
919        };
920    }
921    if distance >= radius_a + radius_b - tolerance
922        || distance <= (radius_a - radius_b).abs() + tolerance
923    {
924        return Some(Vec::new());
925    }
926    let normal = offset.scale(1.0 / distance);
927    let along =
928        (distance * distance + radius_a * radius_a - radius_b * radius_b) / (2.0 * distance);
929    let circle_radius_squared = radius_a * radius_a - along * along;
930    if circle_radius_squared <= tolerance * tolerance {
931        return Some(Vec::new());
932    }
933    let center = center_a.add(normal.scale(along));
934    let x_axis = normal.perpendicular().ok()?;
935    let y_axis = normal.cross(x_axis);
936    Some(vec![make_arc(
937        center,
938        x_axis,
939        y_axis,
940        circle_radius_squared.sqrt(),
941        0.0,
942        std::f64::consts::TAU,
943    )
944    .ok()?])
945}