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    if let Some(curves) = intersect_axial_plane_revolution(first, second, tolerance) {
127        return Some(curves);
128    }
129    intersect_axial_plane_revolution(second, first, tolerance)
130}
131
132/// Exact intersection of a plane CONTAINING the revolution axis with a
133/// surface of revolution: the meridian profile rotated to the (at most
134/// two) angles where the plane crosses the sweep. The marcher fits these
135/// as long noisy polylines otherwise (a revolve's start/end caps against
136/// the other operand's revolved walls in revolve-with-holes booleans).
137fn intersect_axial_plane_revolution(
138    plane_surface: &NurbsSurface,
139    revolved: &NurbsSurface,
140    _tolerance: f64,
141) -> Option<Vec<NurbsCurve>> {
142    let plane = match plane_surface.analytic()? {
143        AnalyticSurface::Plane {
144            origin,
145            u_dir,
146            v_dir,
147            ..
148        } => (*origin, u_dir.cross(*v_dir).normalized().ok()?),
149        _ => return None,
150    };
151    let structure = revolution_structure(revolved)?;
152    if !generatrix_is_meridional_half_ray(&structure.generatrix, &structure.frame) {
153        return None;
154    }
155    let scale = surface_scale(plane_surface)
156        .max(surface_scale(revolved))
157        .max(1.0);
158    let (plane_origin, plane_normal) = plane;
159    if structure.frame.axis.dot(plane_normal).abs() > 1e-9 {
160        return None;
161    }
162    if structure
163        .frame
164        .origin
165        .sub(plane_origin)
166        .dot(plane_normal)
167        .abs()
168        > 1e-9 * scale
169    {
170        return None;
171    }
172    let radial = structure.frame.axis.cross(plane_normal).normalized().ok()?;
173    let tau = std::f64::consts::TAU;
174    let mut curves = Vec::new();
175    for direction in [radial, radial.scale(-1.0)] {
176        let mut angle = direction
177            .dot(structure.frame.y_axis)
178            .atan2(direction.dot(structure.frame.x_axis));
179        if angle < -1e-9 {
180            angle += tau;
181        }
182        if angle <= structure.sweep + 1e-9 {
183            curves.push(rotate_curve_about_axis(
184                &structure.generatrix,
185                structure.frame.origin,
186                structure.frame.axis,
187                angle.clamp(0.0, structure.sweep),
188            )?);
189        }
190    }
191    Some(curves)
192}
193
194/// Plane x affine sphere (`geometry/affine_sphere.rs`, under
195/// `BREP_AFFINE_SPHERE`): the exact ellipse, either operand order. Nothing
196/// else pairs with an ellipsoid in closed form (sphere, cylinder and cone
197/// sections of it are quartics; the marcher keeps them).
198fn intersect_plane_affine_sphere(
199    first: &NurbsSurface,
200    second: &NurbsSurface,
201    tolerance: f64,
202) -> Option<Vec<NurbsCurve>> {
203    for (plane_surface, other) in [(first, second), (second, first)] {
204        let Some(ellipsoid) = other.affine_sphere() else { continue };
205        let Some(plane) = plane_surface.analytic().and_then(plane_data) else { continue };
206        return ellipsoid.plane_section(plane.origin, plane.normal, tolerance);
207    }
208    None
209}
210
211fn intersect_recognized_pair(
212    first: &NurbsSurface,
213    second: &NurbsSurface,
214    tolerance: f64,
215    gate: RuledGate,
216) -> Option<Vec<NurbsCurve>> {
217    let a = first.analytic()?;
218    let b = second.analytic()?;
219    if !matches!(b, AnalyticSurface::Plane { .. }) {
220        if let Some(plane) = plane_data(a) {
221            return intersect_plane_quadric(&plane, b, tolerance, gate);
222        }
223    }
224    if !matches!(a, AnalyticSurface::Plane { .. }) {
225        if let Some(plane) = plane_data(b) {
226            return intersect_plane_quadric(&plane, a, tolerance, gate);
227        }
228    }
229    // Sphere×sphere, whichever way each sphere is parameterized (a reflected
230    // sphere recognizes as a general `Revolution`; see `sphere_geometry`).
231    if let (Some((center_a, radius_a)), Some((center_b, radius_b))) =
232        (a.sphere_geometry(), b.sphere_geometry())
233    {
234        return intersect_sphere_sphere(center_a, radius_a, center_b, radius_b, tolerance);
235    }
236    // Plane×plane is deliberately NOT special-cased: the iso path
237    // already returns axis-aligned box intersections exactly, and a
238    // synthesized over-long line segment interacts poorly with the
239    // shared-parameter pcurve contract in process_curve (the pcurve is
240    // fitted over the face crossing only, so the curve's [t0,t1]
241    // subrange and the pcurve domain drift apart on long overhangs).
242    None
243}
244
245/// Frame/meridian data for any revolution with a straight-line generatrix:
246/// the `RuledRevolution` quadrics themselves, plus partial-sweep
247/// `Revolution`s whose generatrix the named gate admits as straight (fillet
248/// cutter walls are quarter cylinders of this shape). The plane
249/// intersectors only need the carrier geometry — the boolean trims the
250/// returned curves to the actual face domains afterwards.
251fn ruled_revolution_data(
252    quadric: &AnalyticSurface,
253    tolerance: f64,
254    gate: RuledGate,
255) -> Option<(RevolutionFrame, f64, f64, f64)> {
256    match quadric {
257        AnalyticSurface::RuledRevolution {
258            frame,
259            rho0,
260            rho1,
261            height,
262        } => Some((frame.clone(), *rho0, *rho1, *height)),
263        AnalyticSurface::Revolution {
264            frame, generatrix, ..
265        } => {
266            let (start, end) = match gate {
267                RuledGate::Count => {
268                    let controls = &generatrix.control_points;
269                    if generatrix.degree != 1
270                        || controls.len() != 2
271                        || (controls[0].w - 1.0).abs() > RECOGNITION_TOLERANCE
272                        || (controls[1].w - 1.0).abs() > RECOGNITION_TOLERANCE
273                    {
274                        return None;
275                    }
276                    (controls[0].point().ok()?, controls[1].point().ok()?)
277                }
278                RuledGate::Geometry => generatrix.straight_segment(tolerance)?,
279            };
280            let (_, rho0, z0) = frame.cylindrical(start);
281            let (_, rho1, z1) = frame.cylindrical(end);
282            let height = z1 - z0;
283            if height.abs() <= 1e-12 * (1.0 + rho0.abs().max(rho1.abs())) {
284                return None;
285            }
286            let origin = frame.origin.add(frame.axis.scale(z0));
287            Some((
288                RevolutionFrame {
289                    origin,
290                    ..frame.clone()
291                },
292                rho0,
293                rho1,
294                height,
295            ))
296        }
297        _ => None,
298    }
299}
300
301/// CENSUS ONLY (`BREP_FIT_CENSUS=1`): a description of `surface`'s generatrix
302/// when it is a general revolution the two generatrix gates answer
303/// differently, `None` otherwise.
304pub(crate) fn ruled_gate_census(surface: &NurbsSurface, tolerance: f64) -> Option<String> {
305    let quadric = surface.analytic()?;
306    let AnalyticSurface::Revolution { generatrix, .. } = quadric else {
307        return None;
308    };
309    let count = ruled_revolution_data(quadric, tolerance, RuledGate::Count).is_some();
310    let geometry = ruled_revolution_data(quadric, tolerance, RuledGate::Geometry).is_some();
311    if count == geometry {
312        return None;
313    }
314    let controls = &generatrix.control_points;
315    let points: Vec<Vec3> = controls
316        .iter()
317        .map(|control| control.point().unwrap_or_default())
318        .collect();
319    let (first, last) = (points[0], points[points.len() - 1]);
320    let direction = last.sub(first);
321    let length_squared = direction.length_squared();
322    let mut collinearity = 0.0_f64;
323    for point in &points {
324        collinearity = collinearity.max(if length_squared <= 0.0 {
325            point.sub(first).length()
326        } else {
327            let fraction = (point.sub(first).dot(direction) / length_squared).clamp(0.0, 1.0);
328            point.sub(first.add(direction.scale(fraction))).length()
329        });
330    }
331    let unit_weights = controls
332        .iter()
333        .all(|control| (control.w - 1.0).abs() <= RECOGNITION_TOLERANCE);
334    Some(format!(
335        "degree={} controls={} collinearity={collinearity:.3e} unit_weights={unit_weights} \
336         count_gate={count} geometry_gate={geometry}",
337        generatrix.degree,
338        controls.len()
339    ))
340}
341
342/// TRUE when a recognized frustum's radius drift is below the precision floor
343/// of the cone construction itself, so the plane section must take the
344/// CYLINDER branch. The cone branch builds its conic as a projective image of
345/// a base circle toward the apex at distance ~|height|·max|ρ|/|Δρ|; the map's
346/// floating-point error grows like that distance × machine epsilon, so for a
347/// near-cylinder frustum (t363: Δρ = 2.23e-12 over height 1500, apex ~1.5e16)
348/// the "exact analytic" section comes back up to ~1 mm off BOTH surfaces.
349/// Downstream, `build_pcurve_on_surface` honestly measures that deviation on
350/// the curved operand, drops every interior sample, and collapses the ring's
351/// pcurve to a single point — the face under-splits and the boolean fails
352/// with one-use edges / non-integral genus.
353///
354/// Branch selection by error balance: the cylinder approximation errs by at
355/// most |Δρ|, the cone construction by ~ε·|height|·max|ρ|/|Δρ|; prefer the
356/// cylinder when its error is smaller, i.e. Δρ² ≤ ε·|height|·max|ρ| (with a
357/// modest constant for the map's factors). This is a deterministic choice
358/// between two exact-in-infinite-precision constructions — not a tolerance —
359/// and can only ever pick the MORE accurate branch. The legacy absolute
360/// 1e-12 acceptance remains as a floor at the call site. Escape hatch:
361/// BREP_CONE_APEX_GUARD=0 restores the bare 1e-12 discrimination.
362fn degenerate_apex_frustum(rho0: f64, rho1: f64, height: f64) -> bool {
363    if std::env::var("BREP_CONE_APEX_GUARD").as_deref() == Ok("0") {
364        return false;
365    }
366    let delta = rho1 - rho0;
367    delta * delta <= 64.0 * f64::EPSILON * height.abs() * rho0.abs().max(rho1.abs())
368}
369
370/// The EXACT arc of `plane ∩ ruled revolution` spanning a given AZIMUTH range
371/// about the carrier's own frame — the arc-restricted, seam-anchorable form of
372/// the full sections `intersect_plane_quadric` returns.
373///
374/// ADDITION, not a change: `intersect_plane_quadric` is untouched and still
375/// answers every pair it answered before, bit for bit. This function exists
376/// because a direct edit needs the section as an EDGE, not as a carrier
377/// section, and an edge has endpoints: a closed rim must START at the face's
378/// own seam vertex (which need not sit at the carrier's `u = 0` — see the
379/// push-face seam fix), and an open rim is a sub-arc between two re-solved
380/// corners. `intersect_plane_quadric` always returns the whole section anchored
381/// at the frame's `x_axis`, so neither is expressible through it.
382///
383/// Both constructions below are the ones that function already uses, and both
384/// preserve AZIMUTH — the cylinder's map adds only an axial component, the
385/// cone's is a central projection from a point ON the axis — so "the arc from
386/// azimuth `a` through `sweep`" is well defined on the section itself and the
387/// anchoring is exact rather than fitted:
388///
389/// - **Cylinder** (`rho1 ≈ rho0`): the section is the AFFINE image of the base
390///   circle under the axial shear `X ↦ X + axis·(c − n·X)/(n·axis)`, which is
391///   linear in the homogeneous control point, so a rational-quadratic arc maps
392///   to a rational-quadratic arc on the SAME knot vector.
393/// - **Cone/frustum**: the section is the PROJECTIVE image from the apex,
394///   `X ↦ apex + (k/((X−apex)·n))·(X−apex)`, again linear in the homogeneous
395///   control point. Weight signs flip for a parabolic/hyperbolic section (the
396///   arc crosses the apex plane), and those are refused by name rather than
397///   silently mis-built.
398///
399/// `sweep` must be positive and at most a full turn; a reversed rim is built
400/// forward and reversed by the caller, which is what keeps the knot vector the
401/// arc construction's own.
402pub fn plane_ruled_section_arc(
403    plane_origin: Vec3,
404    plane_normal: Vec3,
405    frame: &RevolutionFrame,
406    rho0: f64,
407    rho1: f64,
408    height: f64,
409    start_azimuth: f64,
410    sweep: f64,
411    tolerance: f64,
412) -> Result<NurbsCurve, String> {
413    let tau = std::f64::consts::TAU;
414    if !(start_azimuth.is_finite() && sweep.is_finite()) {
415        return Err("plane_ruled_section_arc: non-finite azimuth range".into());
416    }
417    if sweep <= 1e-12 || sweep > tau + 1e-9 {
418        return Err(format!(
419            "plane_ruled_section_arc: the section arc sweeps {sweep:.6e} rad, which is not a \
420             positive arc of at most one turn — refusing"
421        ));
422    }
423    let normal = plane_normal.normalized()?;
424    let radius_scale = rho0.abs().max(rho1.abs()).max(1.0);
425    let axis = frame.axis;
426    let is_cylinder = (rho1 - rho0).abs() <= 1e-9 * radius_scale
427        || degenerate_apex_frustum(rho0, rho1, height);
428    let curve = if is_cylinder {
429        let alignment = normal.dot(axis);
430        if alignment.abs() <= 1e-12 {
431            return Err(
432                "plane_ruled_section_arc: the plane is parallel to the cylinder axis, so the \
433                 section is a generatrix pair rather than a conic — refusing"
434                    .into(),
435            );
436        }
437        if rho0.abs() <= tolerance {
438            return Err("plane_ruled_section_arc: the cylinder carrier has no radius".into());
439        }
440        let base = make_arc(
441            frame.origin,
442            frame.x_axis,
443            frame.y_axis,
444            rho0.abs(),
445            start_azimuth,
446            start_azimuth + sweep,
447        )?;
448        let origin_dot = plane_origin.dot(normal);
449        map_rational_curve(&base, |p| {
450            let t = (origin_dot * p.w - Vec3::new(p.x, p.y, p.z).dot(normal)) / alignment;
451            crate::Vec4 {
452                x: p.x + axis.x * t,
453                y: p.y + axis.y * t,
454                z: p.z + axis.z * t,
455                w: p.w,
456            }
457        })
458        .ok_or_else(|| {
459            "plane_ruled_section_arc: the cylinder section's weights are not one-signed \
460             — refusing"
461                .to_string()
462        })?
463    } else {
464        let apex = frame.origin.add(axis.scale(height * rho0 / (rho0 - rho1)));
465        let k = plane_origin.sub(apex).dot(normal);
466        if k.abs() <= tolerance {
467            return Err(
468                "plane_ruled_section_arc: the plane passes through the cone apex, so the \
469                 section is a line pair rather than a conic — refusing"
470                    .into(),
471            );
472        }
473        let (reference_rho, reference_z) = if rho0.abs() > rho1.abs() {
474            (rho0.abs(), 0.0)
475        } else {
476            (rho1.abs(), height)
477        };
478        if reference_rho <= tolerance {
479            return Err("plane_ruled_section_arc: the cone carrier degenerates to its apex".into());
480        }
481        let base = make_arc(
482            frame.origin.add(axis.scale(reference_z)),
483            frame.x_axis,
484            frame.y_axis,
485            reference_rho,
486            start_azimuth,
487            start_azimuth + sweep,
488        )?;
489        map_rational_curve(&base, |p| {
490            let relative = Vec3::new(p.x - p.w * apex.x, p.y - p.w * apex.y, p.z - p.w * apex.z);
491            let w_new = relative.dot(normal);
492            let scaled = relative.scale(k);
493            crate::Vec4 {
494                x: apex.x * w_new + scaled.x,
495                y: apex.y * w_new + scaled.y,
496                z: apex.z * w_new + scaled.z,
497                w: w_new,
498            }
499        })
500        .ok_or_else(|| {
501            "plane_ruled_section_arc: the section crosses the cone's apex plane (a parabolic \
502             or hyperbolic branch, whose weights change sign) — refusing"
503                .to_string()
504        })?
505    };
506    // The construction is exact, so this measures rather than fits: every
507    // sample must sit on BOTH carriers, at the carrier's own radius for its own
508    // axial station and on the plane.
509    let [d0, d1] = curve.domain()?;
510    let slope = if height == 0.0 {
511        0.0
512    } else {
513        (rho1 - rho0) / height
514    };
515    for step in 0..=8 {
516        let point = curve.evaluate(d0 + (d1 - d0) * (step as f64 / 8.0))?;
517        let delta = point.sub(frame.origin);
518        let axial = delta.dot(axis);
519        let radial = delta.sub(axis.scale(axial)).length();
520        let off_ruled = (radial - (rho0 + slope * axial)).abs();
521        let off_plane = point.sub(plane_origin).dot(normal).abs();
522        if off_ruled > 10.0 * tolerance || off_plane > 10.0 * tolerance {
523            return Err(format!(
524                "plane_ruled_section_arc: the section does not lie on both carriers (off ruled \
525                 {off_ruled:.3e}, off plane {off_plane:.3e}) — refusing"
526            ));
527        }
528    }
529    Ok(curve)
530}
531
532fn intersect_plane_quadric(
533    plane: &PlaneData,
534    quadric: &AnalyticSurface,
535    tolerance: f64,
536    gate: RuledGate,
537) -> Option<Vec<NurbsCurve>> {
538    match quadric {
539        AnalyticSurface::RuledRevolution { .. } | AnalyticSurface::Revolution { .. }
540            if ruled_revolution_data(quadric, tolerance, gate).is_some() =>
541        {
542            let (frame, rho0, rho1, height) = ruled_revolution_data(quadric, tolerance, gate)?;
543            let (frame, rho0, rho1, height) = (&frame, &rho0, &rho1, &height);
544            let alignment = plane.normal.dot(frame.axis);
545            if alignment.abs() >= 1.0 - 1e-12 {
546                // Perpendicular plane: one circle at the plane's height.
547                let z = plane.origin.sub(frame.origin).dot(frame.axis);
548                let t = z / height;
549                if !(-1e-9..=1.0 + 1e-9).contains(&t) {
550                    return Some(Vec::new());
551                }
552                let rho = rho0 + (rho1 - rho0) * t.clamp(0.0, 1.0);
553                if rho <= tolerance {
554                    return Some(Vec::new());
555                }
556                return Some(vec![frame_circle(frame, rho, z)?]);
557            }
558            if (rho1 - rho0).abs() <= 1e-12 || degenerate_apex_frustum(*rho0, *rho1, *height) {
559                // Cylinder.
560                if alignment.abs() <= 1e-12 {
561                    // Plane parallel to the axis: zero, one, or two
562                    // generatrix lines at the circle/line crossing angles.
563                    return cylinder_parallel_plane_lines(plane, frame, *rho0, *height, tolerance);
564                }
565                // Oblique section: exact ellipse as the affine image of a
566                // base circle along the axis direction.
567                let circle = frame_circle(frame, *rho0, 0.0)?;
568                let denominator = alignment;
569                let origin_dot = plane.origin.dot(plane.normal);
570                let normal = plane.normal;
571                let axis = frame.axis;
572                let ellipse = map_rational_curve(&circle, |p| {
573                    let t = (origin_dot * p.w - Vec3::new(p.x, p.y, p.z).dot(normal)) / denominator;
574                    crate::Vec4 {
575                        x: p.x + axis.x * t,
576                        y: p.y + axis.y * t,
577                        z: p.z + axis.z * t,
578                        w: p.w,
579                    }
580                })?;
581                if !section_within_band(&ellipse, frame, *height, tolerance) {
582                    return Some(Vec::new());
583                }
584                return Some(vec![ellipse]);
585            }
586            // Cone/frustum: projective image of a base circle toward the
587            // apex. Weight signs flip for parabolic/hyperbolic sections and
588            // map_rational_curve refuses those (marcher fallback).
589            let apex_t = rho0 / (rho0 - rho1);
590            let apex = frame.origin.add(frame.axis.scale(height * apex_t));
591            let reference_rho = if rho0.abs() > rho1.abs() {
592                *rho0
593            } else {
594                *rho1
595            };
596            let reference_z = if rho0.abs() > rho1.abs() {
597                0.0
598            } else {
599                *height
600            };
601            let circle = frame_circle(frame, reference_rho, reference_z)?;
602            let k = plane.origin.sub(apex).dot(plane.normal);
603            if k.abs() <= tolerance {
604                // Plane through the apex: line pair; leave to the marcher.
605                return None;
606            }
607            let normal = plane.normal;
608            let conic = map_rational_curve(&circle, |p| {
609                let relative =
610                    Vec3::new(p.x - p.w * apex.x, p.y - p.w * apex.y, p.z - p.w * apex.z);
611                let w_new = relative.dot(normal);
612                let scaled = relative.scale(k);
613                crate::Vec4 {
614                    x: apex.x * w_new + scaled.x,
615                    y: apex.y * w_new + scaled.y,
616                    z: apex.z * w_new + scaled.z,
617                    w: w_new,
618                }
619            })?;
620            if !section_within_band(&conic, frame, *height, tolerance) {
621                return Some(Vec::new());
622            }
623            Some(vec![conic])
624        }
625        AnalyticSurface::Sphere { frame, radius } => {
626            plane_sphere_section(plane, frame.origin, *radius, tolerance)
627        }
628        AnalyticSurface::Torus {
629            frame,
630            major_radius,
631            minor_radius,
632        } => plane_torus_section(plane, frame, *major_radius, *minor_radius, tolerance),
633        AnalyticSurface::Plane { .. } => None,
634        AnalyticSurface::RuledRevolution { .. } => None,
635        // A REFLECTED sphere or torus (mirror feature, negative-determinant
636        // transform) recognizes as a general `Revolution` — same point set,
637        // opposite parameter handedness — and keeps its closed-form sections.
638        // Any other general revolution has none; the axial-plane and
639        // tangential-contact paths in imprint own those.
640        AnalyticSurface::Revolution { .. } => {
641            if let Some((center, radius)) = quadric.sphere_geometry() {
642                return plane_sphere_section(plane, center, radius, tolerance);
643            }
644            if let Some((frame, major_radius, minor_radius)) = quadric.torus_geometry() {
645                return plane_torus_section(plane, &frame, major_radius, minor_radius, tolerance);
646            }
647            None
648        }
649    }
650}
651
652/// Plane × sphere: one circle, or provably empty when the plane misses or
653/// merely TOUCHES the sphere (a point contact imprints nothing).
654fn plane_sphere_section(
655    plane: &PlaneData,
656    center: Vec3,
657    radius: f64,
658    tolerance: f64,
659) -> Option<Vec<NurbsCurve>> {
660    let distance = center.sub(plane.origin).dot(plane.normal);
661    if distance.abs() >= radius - tolerance {
662        return Some(Vec::new());
663    }
664    let circle_center = center.sub(plane.normal.scale(distance));
665    let circle_radius = (radius * radius - distance * distance).sqrt();
666    let x_axis = plane.normal.perpendicular().ok()?;
667    let y_axis = plane.normal.cross(x_axis);
668    Some(vec![make_arc(
669        circle_center,
670        x_axis,
671        y_axis,
672        circle_radius,
673        0.0,
674        std::f64::consts::TAU,
675    )
676    .ok()?])
677}
678
679/// Plane ⟂ torus axis: the two latitude circles at the plane's height (one
680/// when it grazes the tube's inner side), or provably empty above/below the
681/// tube.  `frame.origin` must sit at the tube centre's axial position.  An
682/// oblique plane has no closed form here (`None` → marcher).
683fn plane_torus_section(
684    plane: &PlaneData,
685    frame: &RevolutionFrame,
686    major_radius: f64,
687    minor_radius: f64,
688    tolerance: f64,
689) -> Option<Vec<NurbsCurve>> {
690    let alignment = plane.normal.dot(frame.axis);
691    if alignment.abs() < 1.0 - 1e-12 {
692        return None;
693    }
694    let z = plane.origin.sub(frame.origin).dot(frame.axis);
695    if z.abs() >= minor_radius - tolerance {
696        return Some(Vec::new());
697    }
698    let offset = (minor_radius * minor_radius - z * z).sqrt();
699    let mut circles = Vec::new();
700    for rho in [major_radius - offset, major_radius + offset] {
701        if rho > tolerance {
702            circles.push(frame_circle(frame, rho, z)?);
703        }
704    }
705    Some(circles)
706}
707
708/// Reject sections that entirely miss the ruled surface's axial band; the
709/// imprint trimmer would discard them anyway, but skipping early avoids
710/// building pcurves for out-of-band curves.
711///
712/// Uses the convex-hull property of a same-sign-weight rational curve: every
713/// curve point's axial coordinate lies within the [min, max] axial span of the
714/// Cartesian control points. So the section can only miss the band when the
715/// WHOLE control-point span sits above or below it. The earlier `any(CP in
716/// band)` test was wrong for near-axis-parallel oblique sections: the affine
717/// map divides the axial displacement by `alignment`, flinging the rational
718/// control points far to BOTH sides of the band (they straddle it with none
719/// landing inside), so a genuine crossing arc was falsely rejected — the
720/// missing cap∩cylinder imprint behind the curved-primitive open-edge /
721/// non-integral-genus booleans. An interval-overlap test can never falsely
722/// reject (hull property); a false accept is harmless (the imprint trimmer
723/// clips the returned curve to the real face domains regardless).
724fn section_within_band(
725    curve: &NurbsCurve,
726    frame: &RevolutionFrame,
727    height: f64,
728    tolerance: f64,
729) -> bool {
730    let (band_low, band_high) = if height >= 0.0 {
731        (0.0, height)
732    } else {
733        (height, 0.0)
734    };
735    let margin = tolerance.max(1e-9) + 1e-9;
736    let mut min_z = f64::INFINITY;
737    let mut max_z = f64::NEG_INFINITY;
738    for p in &curve.control_points {
739        let z = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w)
740            .sub(frame.origin)
741            .dot(frame.axis);
742        min_z = min_z.min(z);
743        max_z = max_z.max(z);
744    }
745    // The control-point axial span overlaps the band.
746    max_z >= band_low - margin && min_z <= band_high + margin
747}
748
749fn cylinder_parallel_plane_lines(
750    plane: &PlaneData,
751    frame: &RevolutionFrame,
752    radius: f64,
753    height: f64,
754    tolerance: f64,
755) -> Option<Vec<NurbsCurve>> {
756    // Distance from the axis to the plane, measured in the cross-section.
757    let axis_to_plane = plane.origin.sub(frame.origin).dot(plane.normal);
758    if axis_to_plane.abs() >= radius - tolerance {
759        return Some(Vec::new());
760    }
761    // In-plane offset of the chord from the axis foot.
762    let chord_half = (radius * radius - axis_to_plane * axis_to_plane).sqrt();
763    let chord_direction = frame.axis.cross(plane.normal).normalized().ok()?;
764    let foot = frame.origin.add(plane.normal.scale(axis_to_plane));
765    let mut lines = Vec::new();
766    for sign in [-1.0, 1.0] {
767        let base = foot.add(chord_direction.scale(sign * chord_half));
768        let top = base.add(frame.axis.scale(height));
769        lines.push(crate::make_line(base, top).ok()?);
770    }
771    Some(lines)
772}
773
774fn intersect_sphere_sphere(
775    center_a: Vec3,
776    radius_a: f64,
777    center_b: Vec3,
778    radius_b: f64,
779    tolerance: f64,
780) -> Option<Vec<NurbsCurve>> {
781    let offset = center_b.sub(center_a);
782    let distance = offset.length();
783    if distance <= tolerance {
784        // Concentric: empty or coincident; the cosurface path owns
785        // coincident carriers.
786        return if (radius_a - radius_b).abs() <= tolerance {
787            None
788        } else {
789            Some(Vec::new())
790        };
791    }
792    if distance >= radius_a + radius_b - tolerance
793        || distance <= (radius_a - radius_b).abs() + tolerance
794    {
795        return Some(Vec::new());
796    }
797    let normal = offset.scale(1.0 / distance);
798    let along =
799        (distance * distance + radius_a * radius_a - radius_b * radius_b) / (2.0 * distance);
800    let circle_radius_squared = radius_a * radius_a - along * along;
801    if circle_radius_squared <= tolerance * tolerance {
802        return Some(Vec::new());
803    }
804    let center = center_a.add(normal.scale(along));
805    let x_axis = normal.perpendicular().ok()?;
806    let y_axis = normal.cross(x_axis);
807    Some(vec![make_arc(
808        center,
809        x_axis,
810        y_axis,
811        circle_radius_squared.sqrt(),
812        0.0,
813        std::f64::consts::TAU,
814    )
815    .ok()?])
816}