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    if let Some(curves) = intersect_recognized_pair(first, second, tolerance) {
82        return Some(curves);
83    }
84    if let Some(curves) = intersect_coaxial_revolutions(first, second, tolerance) {
85        return Some(curves);
86    }
87    if let Some(curves) = intersect_axial_plane_revolution(first, second, tolerance) {
88        return Some(curves);
89    }
90    intersect_axial_plane_revolution(second, first, tolerance)
91}
92
93/// Exact intersection of a plane CONTAINING the revolution axis with a
94/// surface of revolution: the meridian profile rotated to the (at most
95/// two) angles where the plane crosses the sweep. The marcher fits these
96/// as long noisy polylines otherwise (a revolve's start/end caps against
97/// the other operand's revolved walls in revolve-with-holes booleans).
98fn intersect_axial_plane_revolution(
99    plane_surface: &NurbsSurface,
100    revolved: &NurbsSurface,
101    _tolerance: f64,
102) -> Option<Vec<NurbsCurve>> {
103    let plane = match plane_surface.analytic()? {
104        AnalyticSurface::Plane {
105            origin,
106            u_dir,
107            v_dir,
108            ..
109        } => (*origin, u_dir.cross(*v_dir).normalized().ok()?),
110        _ => return None,
111    };
112    let structure = revolution_structure(revolved)?;
113    if !generatrix_is_meridional_half_ray(&structure.generatrix, &structure.frame) {
114        return None;
115    }
116    let scale = surface_scale(plane_surface)
117        .max(surface_scale(revolved))
118        .max(1.0);
119    let (plane_origin, plane_normal) = plane;
120    if structure.frame.axis.dot(plane_normal).abs() > 1e-9 {
121        return None;
122    }
123    if structure
124        .frame
125        .origin
126        .sub(plane_origin)
127        .dot(plane_normal)
128        .abs()
129        > 1e-9 * scale
130    {
131        return None;
132    }
133    let radial = structure.frame.axis.cross(plane_normal).normalized().ok()?;
134    let tau = std::f64::consts::TAU;
135    let mut curves = Vec::new();
136    for direction in [radial, radial.scale(-1.0)] {
137        let mut angle = direction
138            .dot(structure.frame.y_axis)
139            .atan2(direction.dot(structure.frame.x_axis));
140        if angle < -1e-9 {
141            angle += tau;
142        }
143        if angle <= structure.sweep + 1e-9 {
144            curves.push(rotate_curve_about_axis(
145                &structure.generatrix,
146                structure.frame.origin,
147                structure.frame.axis,
148                angle.clamp(0.0, structure.sweep),
149            )?);
150        }
151    }
152    Some(curves)
153}
154
155fn intersect_recognized_pair(
156    first: &NurbsSurface,
157    second: &NurbsSurface,
158    tolerance: f64,
159) -> Option<Vec<NurbsCurve>> {
160    let a = first.analytic()?;
161    let b = second.analytic()?;
162    if !matches!(b, AnalyticSurface::Plane { .. }) {
163        if let Some(plane) = plane_data(a) {
164            return intersect_plane_quadric(&plane, b, tolerance);
165        }
166    }
167    if !matches!(a, AnalyticSurface::Plane { .. }) {
168        if let Some(plane) = plane_data(b) {
169            return intersect_plane_quadric(&plane, a, tolerance);
170        }
171    }
172    // Sphere×sphere, whichever way each sphere is parameterized (a reflected
173    // sphere recognizes as a general `Revolution`; see `sphere_geometry`).
174    if let (Some((center_a, radius_a)), Some((center_b, radius_b))) =
175        (a.sphere_geometry(), b.sphere_geometry())
176    {
177        return intersect_sphere_sphere(center_a, radius_a, center_b, radius_b, tolerance);
178    }
179    // Plane×plane is deliberately NOT special-cased: the iso path
180    // already returns axis-aligned box intersections exactly, and a
181    // synthesized over-long line segment interacts poorly with the
182    // shared-parameter pcurve contract in process_curve (the pcurve is
183    // fitted over the face crossing only, so the curve's [t0,t1]
184    // subrange and the pcurve domain drift apart on long overhangs).
185    None
186}
187
188/// Frame/meridian data for any revolution with a straight-line generatrix:
189/// the `RuledRevolution` quadrics themselves, plus partial-sweep
190/// `Revolution`s whose generatrix is a degree-1 unit-weight segment
191/// (fillet cutter walls are quarter cylinders of this shape). The plane
192/// intersectors only need the carrier geometry — the boolean trims the
193/// returned curves to the actual face domains afterwards.
194fn ruled_revolution_data(quadric: &AnalyticSurface) -> Option<(RevolutionFrame, f64, f64, f64)> {
195    match quadric {
196        AnalyticSurface::RuledRevolution {
197            frame,
198            rho0,
199            rho1,
200            height,
201        } => Some((frame.clone(), *rho0, *rho1, *height)),
202        AnalyticSurface::Revolution {
203            frame, generatrix, ..
204        } => {
205            let controls = &generatrix.control_points;
206            if generatrix.degree != 1
207                || controls.len() != 2
208                || (controls[0].w - 1.0).abs() > RECOGNITION_TOLERANCE
209                || (controls[1].w - 1.0).abs() > RECOGNITION_TOLERANCE
210            {
211                return None;
212            }
213            let (_, rho0, z0) = frame.cylindrical(controls[0].point().ok()?);
214            let (_, rho1, z1) = frame.cylindrical(controls[1].point().ok()?);
215            let height = z1 - z0;
216            if height.abs() <= 1e-12 * (1.0 + rho0.abs().max(rho1.abs())) {
217                return None;
218            }
219            let origin = frame.origin.add(frame.axis.scale(z0));
220            Some((
221                RevolutionFrame {
222                    origin,
223                    ..frame.clone()
224                },
225                rho0,
226                rho1,
227                height,
228            ))
229        }
230        _ => None,
231    }
232}
233
234/// TRUE when a recognized frustum's radius drift is below the precision floor
235/// of the cone construction itself, so the plane section must take the
236/// CYLINDER branch. The cone branch builds its conic as a projective image of
237/// a base circle toward the apex at distance ~|height|·max|ρ|/|Δρ|; the map's
238/// floating-point error grows like that distance × machine epsilon, so for a
239/// near-cylinder frustum (t363: Δρ = 2.23e-12 over height 1500, apex ~1.5e16)
240/// the "exact analytic" section comes back up to ~1 mm off BOTH surfaces.
241/// Downstream, `build_pcurve_on_surface` honestly measures that deviation on
242/// the curved operand, drops every interior sample, and collapses the ring's
243/// pcurve to a single point — the face under-splits and the boolean fails
244/// with one-use edges / non-integral genus.
245///
246/// Branch selection by error balance: the cylinder approximation errs by at
247/// most |Δρ|, the cone construction by ~ε·|height|·max|ρ|/|Δρ|; prefer the
248/// cylinder when its error is smaller, i.e. Δρ² ≤ ε·|height|·max|ρ| (with a
249/// modest constant for the map's factors). This is a deterministic choice
250/// between two exact-in-infinite-precision constructions — not a tolerance —
251/// and can only ever pick the MORE accurate branch. The legacy absolute
252/// 1e-12 acceptance remains as a floor at the call site. Escape hatch:
253/// BREP_CONE_APEX_GUARD=0 restores the bare 1e-12 discrimination.
254fn degenerate_apex_frustum(rho0: f64, rho1: f64, height: f64) -> bool {
255    if std::env::var("BREP_CONE_APEX_GUARD").as_deref() == Ok("0") {
256        return false;
257    }
258    let delta = rho1 - rho0;
259    delta * delta <= 64.0 * f64::EPSILON * height.abs() * rho0.abs().max(rho1.abs())
260}
261
262fn intersect_plane_quadric(
263    plane: &PlaneData,
264    quadric: &AnalyticSurface,
265    tolerance: f64,
266) -> Option<Vec<NurbsCurve>> {
267    match quadric {
268        AnalyticSurface::RuledRevolution { .. } | AnalyticSurface::Revolution { .. }
269            if ruled_revolution_data(quadric).is_some() =>
270        {
271            let (frame, rho0, rho1, height) = ruled_revolution_data(quadric)?;
272            let (frame, rho0, rho1, height) = (&frame, &rho0, &rho1, &height);
273            let alignment = plane.normal.dot(frame.axis);
274            if alignment.abs() >= 1.0 - 1e-12 {
275                // Perpendicular plane: one circle at the plane's height.
276                let z = plane.origin.sub(frame.origin).dot(frame.axis);
277                let t = z / height;
278                if !(-1e-9..=1.0 + 1e-9).contains(&t) {
279                    return Some(Vec::new());
280                }
281                let rho = rho0 + (rho1 - rho0) * t.clamp(0.0, 1.0);
282                if rho <= tolerance {
283                    return Some(Vec::new());
284                }
285                return Some(vec![frame_circle(frame, rho, z)?]);
286            }
287            if (rho1 - rho0).abs() <= 1e-12 || degenerate_apex_frustum(*rho0, *rho1, *height) {
288                // Cylinder.
289                if alignment.abs() <= 1e-12 {
290                    // Plane parallel to the axis: zero, one, or two
291                    // generatrix lines at the circle/line crossing angles.
292                    return cylinder_parallel_plane_lines(plane, frame, *rho0, *height, tolerance);
293                }
294                // Oblique section: exact ellipse as the affine image of a
295                // base circle along the axis direction.
296                let circle = frame_circle(frame, *rho0, 0.0)?;
297                let denominator = alignment;
298                let origin_dot = plane.origin.dot(plane.normal);
299                let normal = plane.normal;
300                let axis = frame.axis;
301                let ellipse = map_rational_curve(&circle, |p| {
302                    let t = (origin_dot * p.w - Vec3::new(p.x, p.y, p.z).dot(normal)) / denominator;
303                    crate::Vec4 {
304                        x: p.x + axis.x * t,
305                        y: p.y + axis.y * t,
306                        z: p.z + axis.z * t,
307                        w: p.w,
308                    }
309                })?;
310                if !section_within_band(&ellipse, frame, *height, tolerance) {
311                    return Some(Vec::new());
312                }
313                return Some(vec![ellipse]);
314            }
315            // Cone/frustum: projective image of a base circle toward the
316            // apex. Weight signs flip for parabolic/hyperbolic sections and
317            // map_rational_curve refuses those (marcher fallback).
318            let apex_t = rho0 / (rho0 - rho1);
319            let apex = frame.origin.add(frame.axis.scale(height * apex_t));
320            let reference_rho = if rho0.abs() > rho1.abs() {
321                *rho0
322            } else {
323                *rho1
324            };
325            let reference_z = if rho0.abs() > rho1.abs() {
326                0.0
327            } else {
328                *height
329            };
330            let circle = frame_circle(frame, reference_rho, reference_z)?;
331            let k = plane.origin.sub(apex).dot(plane.normal);
332            if k.abs() <= tolerance {
333                // Plane through the apex: line pair; leave to the marcher.
334                return None;
335            }
336            let normal = plane.normal;
337            let conic = map_rational_curve(&circle, |p| {
338                let relative =
339                    Vec3::new(p.x - p.w * apex.x, p.y - p.w * apex.y, p.z - p.w * apex.z);
340                let w_new = relative.dot(normal);
341                let scaled = relative.scale(k);
342                crate::Vec4 {
343                    x: apex.x * w_new + scaled.x,
344                    y: apex.y * w_new + scaled.y,
345                    z: apex.z * w_new + scaled.z,
346                    w: w_new,
347                }
348            })?;
349            if !section_within_band(&conic, frame, *height, tolerance) {
350                return Some(Vec::new());
351            }
352            Some(vec![conic])
353        }
354        AnalyticSurface::Sphere { frame, radius } => {
355            plane_sphere_section(plane, frame.origin, *radius, tolerance)
356        }
357        AnalyticSurface::Torus {
358            frame,
359            major_radius,
360            minor_radius,
361        } => plane_torus_section(plane, frame, *major_radius, *minor_radius, tolerance),
362        AnalyticSurface::Plane { .. } => None,
363        AnalyticSurface::RuledRevolution { .. } => None,
364        // A REFLECTED sphere or torus (mirror feature, negative-determinant
365        // transform) recognizes as a general `Revolution` — same point set,
366        // opposite parameter handedness — and keeps its closed-form sections.
367        // Any other general revolution has none; the axial-plane and
368        // tangential-contact paths in imprint own those.
369        AnalyticSurface::Revolution { .. } => {
370            if let Some((center, radius)) = quadric.sphere_geometry() {
371                return plane_sphere_section(plane, center, radius, tolerance);
372            }
373            if let Some((frame, major_radius, minor_radius)) = quadric.torus_geometry() {
374                return plane_torus_section(plane, &frame, major_radius, minor_radius, tolerance);
375            }
376            None
377        }
378    }
379}
380
381/// Plane × sphere: one circle, or provably empty when the plane misses or
382/// merely TOUCHES the sphere (a point contact imprints nothing).
383fn plane_sphere_section(
384    plane: &PlaneData,
385    center: Vec3,
386    radius: f64,
387    tolerance: f64,
388) -> Option<Vec<NurbsCurve>> {
389    let distance = center.sub(plane.origin).dot(plane.normal);
390    if distance.abs() >= radius - tolerance {
391        return Some(Vec::new());
392    }
393    let circle_center = center.sub(plane.normal.scale(distance));
394    let circle_radius = (radius * radius - distance * distance).sqrt();
395    let x_axis = plane.normal.perpendicular().ok()?;
396    let y_axis = plane.normal.cross(x_axis);
397    Some(vec![make_arc(
398        circle_center,
399        x_axis,
400        y_axis,
401        circle_radius,
402        0.0,
403        std::f64::consts::TAU,
404    )
405    .ok()?])
406}
407
408/// Plane ⟂ torus axis: the two latitude circles at the plane's height (one
409/// when it grazes the tube's inner side), or provably empty above/below the
410/// tube.  `frame.origin` must sit at the tube centre's axial position.  An
411/// oblique plane has no closed form here (`None` → marcher).
412fn plane_torus_section(
413    plane: &PlaneData,
414    frame: &RevolutionFrame,
415    major_radius: f64,
416    minor_radius: f64,
417    tolerance: f64,
418) -> Option<Vec<NurbsCurve>> {
419    let alignment = plane.normal.dot(frame.axis);
420    if alignment.abs() < 1.0 - 1e-12 {
421        return None;
422    }
423    let z = plane.origin.sub(frame.origin).dot(frame.axis);
424    if z.abs() >= minor_radius - tolerance {
425        return Some(Vec::new());
426    }
427    let offset = (minor_radius * minor_radius - z * z).sqrt();
428    let mut circles = Vec::new();
429    for rho in [major_radius - offset, major_radius + offset] {
430        if rho > tolerance {
431            circles.push(frame_circle(frame, rho, z)?);
432        }
433    }
434    Some(circles)
435}
436
437/// Reject sections that entirely miss the ruled surface's axial band; the
438/// imprint trimmer would discard them anyway, but skipping early avoids
439/// building pcurves for out-of-band curves.
440///
441/// Uses the convex-hull property of a same-sign-weight rational curve: every
442/// curve point's axial coordinate lies within the [min, max] axial span of the
443/// Cartesian control points. So the section can only miss the band when the
444/// WHOLE control-point span sits above or below it. The earlier `any(CP in
445/// band)` test was wrong for near-axis-parallel oblique sections: the affine
446/// map divides the axial displacement by `alignment`, flinging the rational
447/// control points far to BOTH sides of the band (they straddle it with none
448/// landing inside), so a genuine crossing arc was falsely rejected — the
449/// missing cap∩cylinder imprint behind the curved-primitive open-edge /
450/// non-integral-genus booleans. An interval-overlap test can never falsely
451/// reject (hull property); a false accept is harmless (the imprint trimmer
452/// clips the returned curve to the real face domains regardless).
453fn section_within_band(
454    curve: &NurbsCurve,
455    frame: &RevolutionFrame,
456    height: f64,
457    tolerance: f64,
458) -> bool {
459    let (band_low, band_high) = if height >= 0.0 {
460        (0.0, height)
461    } else {
462        (height, 0.0)
463    };
464    let margin = tolerance.max(1e-9) + 1e-9;
465    let mut min_z = f64::INFINITY;
466    let mut max_z = f64::NEG_INFINITY;
467    for p in &curve.control_points {
468        let z = Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w)
469            .sub(frame.origin)
470            .dot(frame.axis);
471        min_z = min_z.min(z);
472        max_z = max_z.max(z);
473    }
474    // The control-point axial span overlaps the band.
475    max_z >= band_low - margin && min_z <= band_high + margin
476}
477
478fn cylinder_parallel_plane_lines(
479    plane: &PlaneData,
480    frame: &RevolutionFrame,
481    radius: f64,
482    height: f64,
483    tolerance: f64,
484) -> Option<Vec<NurbsCurve>> {
485    // Distance from the axis to the plane, measured in the cross-section.
486    let axis_to_plane = plane.origin.sub(frame.origin).dot(plane.normal);
487    if axis_to_plane.abs() >= radius - tolerance {
488        return Some(Vec::new());
489    }
490    // In-plane offset of the chord from the axis foot.
491    let chord_half = (radius * radius - axis_to_plane * axis_to_plane).sqrt();
492    let chord_direction = frame.axis.cross(plane.normal).normalized().ok()?;
493    let foot = frame.origin.add(plane.normal.scale(axis_to_plane));
494    let mut lines = Vec::new();
495    for sign in [-1.0, 1.0] {
496        let base = foot.add(chord_direction.scale(sign * chord_half));
497        let top = base.add(frame.axis.scale(height));
498        lines.push(crate::make_line(base, top).ok()?);
499    }
500    Some(lines)
501}
502
503fn intersect_sphere_sphere(
504    center_a: Vec3,
505    radius_a: f64,
506    center_b: Vec3,
507    radius_b: f64,
508    tolerance: f64,
509) -> Option<Vec<NurbsCurve>> {
510    let offset = center_b.sub(center_a);
511    let distance = offset.length();
512    if distance <= tolerance {
513        // Concentric: empty or coincident; the cosurface path owns
514        // coincident carriers.
515        return if (radius_a - radius_b).abs() <= tolerance {
516            None
517        } else {
518            Some(Vec::new())
519        };
520    }
521    if distance >= radius_a + radius_b - tolerance
522        || distance <= (radius_a - radius_b).abs() + tolerance
523    {
524        return Some(Vec::new());
525    }
526    let normal = offset.scale(1.0 / distance);
527    let along =
528        (distance * distance + radius_a * radius_a - radius_b * radius_b) / (2.0 * distance);
529    let circle_radius_squared = radius_a * radius_a - along * along;
530    if circle_radius_squared <= tolerance * tolerance {
531        return Some(Vec::new());
532    }
533    let center = center_a.add(normal.scale(along));
534    let x_axis = normal.perpendicular().ok()?;
535    let y_axis = normal.cross(x_axis);
536    Some(vec![make_arc(
537        center,
538        x_axis,
539        y_axis,
540        circle_radius_squared.sqrt(),
541        0.0,
542        std::f64::consts::TAU,
543    )
544    .ok()?])
545}