#![allow(clippy::similar_names, clippy::suspicious_operation_groupings)]
use crate::MathError;
use crate::nurbs::curve::NurbsCurve;
use crate::nurbs::decompose::curve_to_bezier_segments;
use crate::vec::{Point3, Vec3};
const MAX_DEPTH: usize = 50;
const MAX_NEWTON: usize = 10;
const CLIP_THRESHOLD: f64 = 0.6;
#[derive(Debug, Clone, Copy)]
pub struct CurveCurveHit {
pub u1: f64,
pub u2: f64,
pub point: Point3,
}
#[derive(Debug, Clone, Copy)]
pub struct CurveCurveOverlap {
pub u1_start: f64,
pub u1_end: f64,
pub u2_start: f64,
pub u2_end: f64,
}
#[derive(Debug, Clone)]
pub struct CurveCurveResult {
pub hits: Vec<CurveCurveHit>,
pub overlaps: Vec<CurveCurveOverlap>,
}
pub fn curve_curve_intersect(
curve1: &NurbsCurve,
curve2: &NurbsCurve,
tolerance: f64,
) -> Result<Vec<CurveCurveHit>, MathError> {
let result = curve_curve_intersect_full(curve1, curve2, tolerance)?;
Ok(result.hits)
}
pub fn curve_curve_intersect_full(
curve1: &NurbsCurve,
curve2: &NurbsCurve,
tolerance: f64,
) -> Result<CurveCurveResult, MathError> {
let segments1 = curve_to_bezier_segments(curve1)?;
let segments2 = curve_to_bezier_segments(curve2)?;
let mut hits = Vec::new();
let mut overlaps = Vec::new();
for seg1 in &segments1 {
let aabb1 = seg1.aabb();
for seg2 in &segments2 {
let aabb2 = seg2.aabb();
if !aabb1.intersects(aabb2) {
continue;
}
let (u1_lo, u1_hi) = seg1.domain();
let (u2_lo, u2_hi) = seg2.domain();
bezier_clip_recurse(
seg1,
seg2,
u1_lo,
u1_hi,
u2_lo,
u2_hi,
tolerance,
0,
&mut hits,
&mut overlaps,
);
}
}
merge_duplicate_hits(&mut hits, tolerance);
merge_overlaps(&mut overlaps, tolerance);
if !overlaps.is_empty() {
hits.retain(|h| {
!overlaps
.iter()
.any(|o| h.u1 >= o.u1_start - tolerance && h.u1 <= o.u1_end + tolerance)
});
}
Ok(CurveCurveResult { hits, overlaps })
}
#[allow(clippy::type_complexity)]
fn fat_line(cps: &[Point3]) -> Option<(Vec3, Vec<f64>, f64, f64, Option<Vec3>)> {
let n = cps.len();
if n < 2 {
return None;
}
let p0 = cps[0];
let pn = cps[n - 1];
let baseline = pn - p0;
let baseline_len = baseline.length();
if baseline_len < 1e-30 {
return None;
}
let dir = Vec3::new(
baseline.x() / baseline_len,
baseline.y() / baseline_len,
baseline.z() / baseline_len,
);
let ref_normal = cps
.iter()
.map(|&pi| (pi - p0).cross(dir))
.find(|c| c.length() > 1e-20);
let dists: Vec<f64> = cps
.iter()
.map(|&pi| {
let v = pi - p0;
let cross = v.cross(dir);
let len = cross.length();
let sign = match ref_normal {
Some(ref_n) => {
if cross.dot(ref_n) >= 0.0 {
1.0
} else {
-1.0
}
}
None => dominant_sign(cross),
};
len * sign
})
.collect();
let d_min = dists.iter().copied().fold(f64::INFINITY, f64::min).min(0.0);
let d_max = dists
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
Some((dir, dists, d_min, d_max, ref_normal))
}
fn dominant_sign(cross: Vec3) -> f64 {
let ax = cross.x().abs();
let ay = cross.y().abs();
let az = cross.z().abs();
let val = if ax >= ay && ax >= az {
cross.x()
} else if ay >= az {
cross.y()
} else {
cross.z()
};
if val >= 0.0 { 1.0 } else { -1.0 }
}
#[allow(clippy::too_many_lines)]
fn clip_to_fat_line(
cps_a: &[Point3],
cps_b: &[Point3],
t_b_lo: f64,
t_b_hi: f64,
) -> Option<(f64, f64)> {
let (dir, _dists_a, d_min, d_max, ref_normal_a) = fat_line(cps_a)?;
let p0_a = cps_a[0];
let n_b = cps_b.len();
if n_b < 2 {
return None;
}
let ref_normal_b = ref_normal_a;
#[allow(clippy::cast_precision_loss)]
let dist_pts: Vec<(f64, f64)> = cps_b
.iter()
.enumerate()
.map(|(i, &pi)| {
let v = pi - p0_a;
let cross = v.cross(dir);
let len = cross.length();
let sign = match ref_normal_b {
Some(ref_n) => {
if cross.dot(ref_n) >= 0.0 {
1.0
} else {
-1.0
}
}
None => dominant_sign(cross),
};
let t = t_b_lo + (t_b_hi - t_b_lo) * (i as f64) / ((n_b - 1) as f64);
(t, len * sign)
})
.collect();
convex_hull_clip(&dist_pts, d_min, d_max)
}
fn convex_hull_clip(pts: &[(f64, f64)], d_min: f64, d_max: f64) -> Option<(f64, f64)> {
let upper = upper_hull(pts);
let lower = lower_hull(pts);
let t_min_from_upper = intersect_hull_with_line(&upper, d_min, true);
let t_min_from_lower = intersect_hull_with_line(&lower, d_min, true);
let t_max_from_upper = intersect_hull_with_line(&upper, d_max, false);
let t_max_from_lower = intersect_hull_with_line(&lower, d_max, false);
let t_lo = match (t_min_from_upper, t_min_from_lower) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) | (None, Some(a)) => a,
(None, None) => return None,
};
let t_hi = match (t_max_from_upper, t_max_from_lower) {
(Some(a), Some(b)) => a.max(b),
(Some(a), None) | (None, Some(a)) => a,
(None, None) => return None,
};
if t_lo > t_hi {
return None;
}
Some((t_lo, t_hi))
}
fn upper_hull(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut hull = Vec::with_capacity(pts.len());
for &p in pts {
while hull.len() >= 2 && cross_2d(hull[hull.len() - 2], hull[hull.len() - 1], p) >= 0.0 {
hull.pop();
}
hull.push(p);
}
hull
}
fn lower_hull(pts: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut hull = Vec::with_capacity(pts.len());
for &p in pts {
while hull.len() >= 2 && cross_2d(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0.0 {
hull.pop();
}
hull.push(p);
}
hull
}
fn cross_2d(o: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
(a.0 - o.0).mul_add(b.1 - o.1, -((a.1 - o.1) * (b.0 - o.0)))
}
fn intersect_hull_with_line(hull: &[(f64, f64)], d: f64, find_min: bool) -> Option<f64> {
if hull.is_empty() {
return None;
}
let mut result: Option<f64> = None;
for window in hull.windows(2) {
let (t0, d0) = window[0];
let (t1, d1) = window[1];
if (d0 - d) * (d1 - d) <= 0.0 {
let dd = d1 - d0;
let t = if dd.abs() < 1e-30 {
if find_min { t0.min(t1) } else { t0.max(t1) }
} else {
t0 + (d - d0) * (t1 - t0) / dd
};
result =
Some(result.map_or(t, |prev| if find_min { prev.min(t) } else { prev.max(t) }));
}
}
for &(t, di) in hull {
if find_min && di >= d {
result = Some(result.map_or(t, |prev| prev.min(t)));
}
if !find_min && di <= d {
result = Some(result.map_or(t, |prev| prev.max(t)));
}
}
result
}
const OVERLAP_CHECK_DEPTH: usize = 8;
const DEGENERATE_FAT_LINE: f64 = 1e-12;
const HAUSDORFF_SAMPLES: usize = 5;
#[allow(clippy::too_many_arguments)]
fn bezier_clip_recurse(
seg_a: &NurbsCurve,
seg_b: &NurbsCurve,
u_a_lo: f64,
u_a_hi: f64,
u_b_lo: f64,
u_b_hi: f64,
tolerance: f64,
depth: usize,
hits: &mut Vec<CurveCurveHit>,
overlaps: &mut Vec<CurveCurveOverlap>,
) {
let span_a = u_a_hi - u_a_lo;
let span_b = u_b_hi - u_b_lo;
if span_a < tolerance && span_b < tolerance {
let u1_mid = 0.5 * (u_a_lo + u_a_hi);
let u2_mid = 0.5 * (u_b_lo + u_b_hi);
if let Some(hit) = newton_refine(seg_a, seg_b, u1_mid, u2_mid, tolerance) {
hits.push(hit);
} else {
let pt = seg_a.evaluate(u1_mid);
hits.push(CurveCurveHit {
u1: u1_mid,
u2: u2_mid,
point: pt,
});
}
return;
}
if depth >= MAX_DEPTH {
if check_overlap(
seg_a, seg_b, u_a_lo, u_a_hi, u_b_lo, u_b_hi, tolerance, overlaps,
) {
return;
}
let u1_mid = 0.5 * (u_a_lo + u_a_hi);
let u2_mid = 0.5 * (u_b_lo + u_b_hi);
let pt = seg_a.evaluate(u1_mid);
hits.push(CurveCurveHit {
u1: u1_mid,
u2: u2_mid,
point: pt,
});
return;
}
let aabb_a = sub_aabb(seg_a, u_a_lo, u_a_hi);
let aabb_b = sub_aabb(seg_b, u_b_lo, u_b_hi);
if !aabb_a.intersects(aabb_b) {
return;
}
let cps_a = seg_a.control_points();
let cps_b = seg_b.control_points();
if depth <= 2
&& let Some((_, _, d_min_a, d_max_a, _)) = fat_line(cps_a)
&& (d_max_a - d_min_a) < DEGENERATE_FAT_LINE
&& let Some((_, _, d_min_b, d_max_b, _)) = fat_line(cps_b)
&& (d_max_b - d_min_b) < DEGENERATE_FAT_LINE
{
if check_overlap(
seg_a, seg_b, u_a_lo, u_a_hi, u_b_lo, u_b_hi, tolerance, overlaps,
) {
return;
}
}
if let Some((new_b_lo, new_b_hi)) = clip_to_fat_line(cps_a, cps_b, u_b_lo, u_b_hi) {
let new_span_b = new_b_hi - new_b_lo;
let ratio = if span_b > 1e-30 {
new_span_b / span_b
} else {
1.0
};
if ratio < CLIP_THRESHOLD {
bezier_clip_recurse(
seg_b,
seg_a,
new_b_lo,
new_b_hi,
u_a_lo,
u_a_hi,
tolerance,
depth + 1,
hits,
overlaps,
);
return;
}
if let Some((new_a_lo, new_a_hi)) = clip_to_fat_line(cps_b, cps_a, u_a_lo, u_a_hi) {
let new_span_a = new_a_hi - new_a_lo;
let ratio_a = if span_a > 1e-30 {
new_span_a / span_a
} else {
1.0
};
if ratio_a < CLIP_THRESHOLD {
bezier_clip_recurse(
seg_a,
seg_b,
new_a_lo,
new_a_hi,
new_b_lo,
new_b_hi,
tolerance,
depth + 1,
hits,
overlaps,
);
return;
}
}
if depth >= OVERLAP_CHECK_DEPTH
&& check_overlap(
seg_a, seg_b, u_a_lo, u_a_hi, new_b_lo, new_b_hi, tolerance, overlaps,
)
{
return;
}
subdivide_and_recurse(
seg_a, seg_b, u_a_lo, u_a_hi, new_b_lo, new_b_hi, tolerance, depth, hits, overlaps,
);
}
}
#[allow(clippy::too_many_arguments)]
fn check_overlap(
seg_a: &NurbsCurve,
seg_b: &NurbsCurve,
u_a_lo: f64,
u_a_hi: f64,
u_b_lo: f64,
u_b_hi: f64,
tolerance: f64,
overlaps: &mut Vec<CurveCurveOverlap>,
) -> bool {
let span_a = u_a_hi - u_a_lo;
let span_b = u_b_hi - u_b_lo;
let pa_lo = seg_a.evaluate(u_a_lo);
let pa_hi = seg_a.evaluate(u_a_hi);
let arc_a = (pa_hi - pa_lo).length();
if arc_a < tolerance * 50.0 && span_a < tolerance * 100.0 && span_b < tolerance * 100.0 {
return false;
}
let mut max_dist = 0.0_f64;
#[allow(clippy::cast_precision_loss)]
for i in 0..=HAUSDORFF_SAMPLES {
let t_a = u_a_lo + (u_a_hi - u_a_lo) * (i as f64) / (HAUSDORFF_SAMPLES as f64);
let pa = seg_a.evaluate(t_a);
let mut best_dist = f64::MAX;
#[allow(clippy::cast_precision_loss)]
for j in 0..=HAUSDORFF_SAMPLES {
let t_b = u_b_lo + (u_b_hi - u_b_lo) * (j as f64) / (HAUSDORFF_SAMPLES as f64);
let pb = seg_b.evaluate(t_b);
best_dist = best_dist.min((pa - pb).length());
}
max_dist = max_dist.max(best_dist);
}
#[allow(clippy::cast_precision_loss)]
for i in 0..=HAUSDORFF_SAMPLES {
let t_b = u_b_lo + (u_b_hi - u_b_lo) * (i as f64) / (HAUSDORFF_SAMPLES as f64);
let pb = seg_b.evaluate(t_b);
let mut best_dist = f64::MAX;
#[allow(clippy::cast_precision_loss)]
for j in 0..=HAUSDORFF_SAMPLES {
let t_a = u_a_lo + (u_a_hi - u_a_lo) * (j as f64) / (HAUSDORFF_SAMPLES as f64);
let pa = seg_a.evaluate(t_a);
best_dist = best_dist.min((pb - pa).length());
}
max_dist = max_dist.max(best_dist);
}
if max_dist < tolerance * 10.0 {
overlaps.push(CurveCurveOverlap {
u1_start: u_a_lo,
u1_end: u_a_hi,
u2_start: u_b_lo,
u2_end: u_b_hi,
});
true
} else {
false
}
}
#[allow(clippy::too_many_arguments)]
fn subdivide_and_recurse(
seg_a: &NurbsCurve,
seg_b: &NurbsCurve,
u_a_lo: f64,
u_a_hi: f64,
u_b_lo: f64,
u_b_hi: f64,
tolerance: f64,
depth: usize,
hits: &mut Vec<CurveCurveHit>,
overlaps: &mut Vec<CurveCurveOverlap>,
) {
let span_a = u_a_hi - u_a_lo;
let span_b = u_b_hi - u_b_lo;
if span_a > span_b {
let mid = 0.5 * (u_a_lo + u_a_hi);
bezier_clip_recurse(
seg_a,
seg_b,
u_a_lo,
mid,
u_b_lo,
u_b_hi,
tolerance,
depth + 1,
hits,
overlaps,
);
bezier_clip_recurse(
seg_a,
seg_b,
mid,
u_a_hi,
u_b_lo,
u_b_hi,
tolerance,
depth + 1,
hits,
overlaps,
);
} else {
let mid = 0.5 * (u_b_lo + u_b_hi);
bezier_clip_recurse(
seg_a,
seg_b,
u_a_lo,
u_a_hi,
u_b_lo,
mid,
tolerance,
depth + 1,
hits,
overlaps,
);
bezier_clip_recurse(
seg_a,
seg_b,
u_a_lo,
u_a_hi,
mid,
u_b_hi,
tolerance,
depth + 1,
hits,
overlaps,
);
}
}
fn sub_aabb(curve: &NurbsCurve, u_lo: f64, u_hi: f64) -> crate::aabb::Aabb3 {
const N_SAMPLES: usize = 8;
let mut pts = Vec::with_capacity(N_SAMPLES + 1);
#[allow(clippy::cast_precision_loss)]
for i in 0..=N_SAMPLES {
let t = u_lo + (u_hi - u_lo) * (i as f64) / (N_SAMPLES as f64);
pts.push(curve.evaluate(t));
}
crate::aabb::Aabb3::from_points(pts)
}
fn newton_refine(
curve_a: &NurbsCurve,
curve_b: &NurbsCurve,
mut u1: f64,
mut u2: f64,
tolerance: f64,
) -> Option<CurveCurveHit> {
let (a_lo, a_hi) = curve_a.domain();
let (b_lo, b_hi) = curve_b.domain();
for _ in 0..MAX_NEWTON {
let pa = curve_a.evaluate(u1);
let pb = curve_b.evaluate(u2);
let f = pa - pb;
if f.length() < tolerance {
return Some(CurveCurveHit { u1, u2, point: pa });
}
let da = curve_a.derivatives(u1, 1);
let db = curve_b.derivatives(u2, 1);
let t1 = da[1]; let t2 = db[1];
let j11 = t1.dot(t1);
let j12 = -t1.dot(t2);
let j22 = t2.dot(t2);
let r1 = -t1.dot(f);
let r2 = t2.dot(f);
let det = j11 * j22 - j12 * j12;
if det.abs() < 1e-30 {
return None; }
let du1 = (j22 * r1 - j12 * r2) / det;
let du2 = (-j12).mul_add(r1, j11 * r2) / det;
u1 = (u1 + du1).clamp(a_lo, a_hi);
u2 = (u2 + du2).clamp(b_lo, b_hi);
}
let pa = curve_a.evaluate(u1);
let pb = curve_b.evaluate(u2);
if (pa - pb).length() < tolerance * 10.0 {
Some(CurveCurveHit { u1, u2, point: pa })
} else {
None
}
}
fn merge_duplicate_hits(hits: &mut Vec<CurveCurveHit>, tolerance: f64) {
if hits.len() <= 1 {
return;
}
hits.sort_by(|a, b| a.u1.partial_cmp(&b.u1).unwrap_or(std::cmp::Ordering::Equal));
let mut merged = Vec::with_capacity(hits.len());
merged.push(hits[0]);
for hit in hits.iter().skip(1) {
if let Some(last) = merged.last()
&& (hit.u1 - last.u1).abs() < tolerance
&& (hit.u2 - last.u2).abs() < tolerance
{
continue;
}
merged.push(*hit);
}
*hits = merged;
}
fn merge_overlaps(overlaps: &mut Vec<CurveCurveOverlap>, tolerance: f64) {
if overlaps.len() <= 1 {
return;
}
overlaps.sort_by(|a, b| {
a.u1_start
.partial_cmp(&b.u1_start)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut merged = Vec::with_capacity(overlaps.len());
merged.push(overlaps[0]);
for ov in overlaps.iter().skip(1) {
if let Some(last) = merged.last_mut() {
if ov.u1_start <= last.u1_end + tolerance {
last.u1_end = last.u1_end.max(ov.u1_end);
last.u2_start = last.u2_start.min(ov.u2_start);
last.u2_end = last.u2_end.max(ov.u2_end);
} else {
merged.push(*ov);
}
}
}
*overlaps = merged;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn make_line(p0: Point3, p1: Point3) -> NurbsCurve {
NurbsCurve::new(1, vec![0.0, 0.0, 1.0, 1.0], vec![p0, p1], vec![1.0, 1.0])
.expect("valid line")
}
#[test]
fn two_lines_one_intersection() {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 2.0, 0.0));
let c2 = make_line(Point3::new(0.0, 2.0, 0.0), Point3::new(2.0, 0.0, 0.0));
let hits = curve_curve_intersect(&c1, &c2, 1e-10).expect("no error");
assert_eq!(hits.len(), 1, "expected 1 hit, got {}", hits.len());
let hit = &hits[0];
assert!((hit.point.x() - 1.0).abs() < 1e-6, "x: {}", hit.point.x());
assert!((hit.point.y() - 1.0).abs() < 1e-6, "y: {}", hit.point.y());
assert!((hit.u1 - 0.5).abs() < 1e-6, "u1: {}", hit.u1);
assert!((hit.u2 - 0.5).abs() < 1e-6, "u2: {}", hit.u2);
}
#[test]
fn two_quarter_circles_intersection() {
let w = std::f64::consts::FRAC_1_SQRT_2;
let c1 = NurbsCurve::new(
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
],
vec![1.0, w, 1.0],
)
.expect("valid arc1");
let c2 = NurbsCurve::new(
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
vec![
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
],
vec![1.0, w, 1.0],
)
.expect("valid arc2");
let hits = curve_curve_intersect(&c1, &c2, 1e-8).expect("no error");
assert!(
!hits.is_empty(),
"expected at least one intersection between overlapping arcs"
);
for hit in &hits {
let p1 = c1.evaluate(hit.u1);
let p2 = c2.evaluate(hit.u2);
let dist = (p1 - p2).length();
assert!(
dist < 1e-4,
"hit not on both curves: dist={dist}, u1={}, u2={}",
hit.u1,
hit.u2
);
}
}
#[test]
fn disjoint_curves_no_hits() {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0));
let c2 = make_line(Point3::new(0.0, 10.0, 0.0), Point3::new(1.0, 10.0, 0.0));
let hits = curve_curve_intersect(&c1, &c2, 1e-10).expect("no error");
assert!(hits.is_empty(), "expected no hits for disjoint curves");
}
#[test]
fn parallel_lines_no_hits() {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0));
let c2 = make_line(Point3::new(0.0, 0.1, 0.0), Point3::new(1.0, 0.1, 0.0));
let hits = curve_curve_intersect(&c1, &c2, 1e-10).expect("no error");
assert!(hits.is_empty(), "expected no hits for parallel lines");
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_known_intersection(u_param in 0.1f64..0.9) {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 0.0, 0.0));
let target = c1.evaluate(u_param);
let c2 = make_line(
Point3::new(target.x(), -1.0, 0.0),
Point3::new(target.x(), 1.0, 0.0),
);
let hits = curve_curve_intersect(&c1, &c2, 1e-8).expect("no error");
prop_assert!(!hits.is_empty(), "expected hit near u={u_param}");
let near = hits.iter().any(|h| (h.point - target).length() < 1e-4);
prop_assert!(near, "no hit near target {:?}, hits: {:?}", target, hits);
}
}
#[test]
fn overlapping_lines_detected() {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 0.0, 0.0));
let c2 = make_line(Point3::new(1.0, 0.0, 0.0), Point3::new(3.0, 0.0, 0.0));
let result = curve_curve_intersect_full(&c1, &c2, 1e-8).expect("no error");
assert!(
!result.overlaps.is_empty(),
"expected overlap, got {} hits and {} overlaps",
result.hits.len(),
result.overlaps.len()
);
let ov = &result.overlaps[0];
assert!(ov.u1_start < 0.55, "u1_start too high: {}", ov.u1_start);
assert!(ov.u1_end > 0.95, "u1_end too low: {}", ov.u1_end);
}
#[test]
fn identical_curves_full_overlap() {
let c1 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 0.0));
let c2 = make_line(Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 1.0, 0.0));
let result = curve_curve_intersect_full(&c1, &c2, 1e-8).expect("no error");
assert!(
!result.overlaps.is_empty(),
"identical curves should produce overlap, got {} hits",
result.hits.len()
);
}
}