1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
37use ogeom_geom::{Curve, Curve2d, Curve3d, PlanarCurve};
38use ogeom_math::{Point, Point2, solve};
39
40#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct Crossing<P> {
43 pub on_a: f64,
45 pub on_b: f64,
47 pub point: P,
49 pub gap: f64,
55 pub reach: f64,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Overlap {
66 pub on_a: (f64, f64),
68 pub on_b: (f64, f64),
70}
71
72#[derive(Debug, Clone, PartialEq)]
74pub struct CurveIntersection<P> {
75 pub crossings: Vec<Crossing<P>>,
77 pub overlaps: Vec<Overlap>,
89}
90
91impl<P> CurveIntersection<P> {
92 #[must_use]
94 pub fn is_empty(&self) -> bool {
95 self.crossings.is_empty() && self.overlaps.is_empty()
96 }
97
98 const fn empty() -> Self {
99 Self {
100 crossings: Vec::new(),
101 overlaps: Vec::new(),
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq)]
108pub struct CurveCurveOptions {
109 pub samples: usize,
113 pub gap: f64,
118}
119
120impl Default for CurveCurveOptions {
121 fn default() -> Self {
122 Self {
123 samples: 128,
124 gap: 1e-7,
125 }
126 }
127}
128
129pub fn intersect_curves_2d(
139 a: &PlanarCurve,
140 b: &PlanarCurve,
141 options: CurveCurveOptions,
142 tol: Tolerances,
143) -> OgeomResult<CurveIntersection<Point2>> {
144 check(options)?;
145 match (a, b) {
146 (PlanarCurve::Line(x), PlanarCurve::Line(y)) => Ok(line_line_2d(x, y, tol)),
147 (PlanarCurve::Line(x), PlanarCurve::Circle(y)) => Ok(line_circle_2d(x, y, false, tol)),
148 (PlanarCurve::Circle(x), PlanarCurve::Line(y)) => Ok(line_circle_2d(y, x, true, tol)),
149 (PlanarCurve::Circle(x), PlanarCurve::Circle(y)) => Ok(circle_circle_2d(x, y, tol)),
150 _ => general_2d(a, b, options, tol),
151 }
152}
153
154pub fn intersect_curves(
160 a: &Curve,
161 b: &Curve,
162 options: CurveCurveOptions,
163 tol: Tolerances,
164) -> OgeomResult<CurveIntersection<Point>> {
165 check(options)?;
166 let (basis_a, window_a) = through_trim(a);
167 let (basis_b, window_b) = through_trim(b);
168 if let Some(found) = same_curve_3d(basis_a, basis_b) {
169 return Ok(clipped_to_windows(
170 found,
171 (basis_a, window_a),
172 (basis_b, window_b),
173 tol,
174 ));
175 }
176 if let Some(found) = analytic_3d(basis_a, basis_b, options, tol) {
177 return Ok(clipped_to_windows(
178 found,
179 (basis_a, window_a),
180 (basis_b, window_b),
181 tol,
182 ));
183 }
184 general_3d(a, b, options, tol)
185}
186
187fn through_trim(curve: &Curve) -> (&Curve, Option<(f64, f64)>) {
195 match curve {
196 Curve::Trimmed(t) if !t.is_reversed() => (t.basis(), Some(Curve3d::domain(&**t))),
197 other => (other, None),
198 }
199}
200
201fn analytic_3d(
204 a: &Curve,
205 b: &Curve,
206 options: CurveCurveOptions,
207 tol: Tolerances,
208) -> Option<CurveIntersection<Point>> {
209 match (a, b) {
210 (Curve::Line(x), Curve::Line(y)) => Some(line_line_3d(x, y, options, tol)),
211 (Curve::Circle(x), Curve::Circle(y)) => same_circle_3d(x, y, tol),
212 (Curve::Ellipse(x), Curve::Ellipse(y)) => same_ellipse_3d(x, y, tol),
213 _ => None,
214 }
215}
216
217fn clipped_to_windows(
226 found: CurveIntersection<Point>,
227 a: (&Curve, Option<(f64, f64)>),
228 b: (&Curve, Option<(f64, f64)>),
229 tol: Tolerances,
230) -> CurveIntersection<Point> {
231 let (basis_a, window_a) = a;
232 let (basis_b, window_b) = b;
233 if window_a.is_none() && window_b.is_none() {
234 return found;
235 }
236 let period = |curve: &Curve| -> Option<f64> {
237 if curve.is_periodic() {
238 let (lo, hi) = curve.domain();
239 (hi > lo).then_some(hi - lo)
240 } else {
241 None
242 }
243 };
244 let (pa, pb) = (period(basis_a), period(basis_b));
245 let slack = tol.parametric();
246 let placed = |t: f64, window: Option<(f64, f64)>, period: Option<f64>| -> Option<f64> {
247 let Some((lo, hi)) = window else {
248 return Some(t);
249 };
250 for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
251 let shifted = period.map_or(t, |p| p.mul_add(k, t));
252 if shifted >= lo - slack && shifted <= hi + slack {
253 return Some(shifted);
254 }
255 if period.is_none() {
256 break;
257 }
258 }
259 None
260 };
261
262 let mut crossings = Vec::with_capacity(found.crossings.len());
263 for crossing in found.crossings {
264 let (Some(on_a), Some(on_b)) = (
265 placed(crossing.on_a, window_a, pa),
266 placed(crossing.on_b, window_b, pb),
267 ) else {
268 continue;
269 };
270 crossings.push(Crossing {
271 on_a,
272 on_b,
273 ..crossing
274 });
275 }
276
277 let mut overlaps = Vec::with_capacity(found.overlaps.len());
278 for overlap in found.overlaps {
279 let span_a = overlap.on_a.1 - overlap.on_a.0;
280 let span_b = overlap.on_b.1 - overlap.on_b.0;
281 if span_a.abs() <= f64::MIN_POSITIVE || span_b.abs() <= f64::MIN_POSITIVE {
282 continue;
283 }
284 let to_b = |t: f64| overlap.on_b.0 + span_b * (t - overlap.on_a.0) / span_a;
285 let to_a = |t: f64| overlap.on_a.0 + span_a * (t - overlap.on_b.0) / span_b;
286 let ordered = |r: (f64, f64)| if r.0 <= r.1 { r } else { (r.1, r.0) };
287 let mut kept = ordered(overlap.on_a);
288 if let Some(w) = window_b {
291 let (wlo, whi) = ordered((to_a(w.0), to_a(w.1)));
292 let shift = pa.unwrap_or(0.0);
293 let mut best: Option<(f64, f64)> = None;
294 for k in [0.0, 1.0, -1.0, 2.0, -2.0] {
295 let candidate = (
296 kept.0.max(shift.mul_add(k, wlo)),
297 kept.1.min(shift.mul_add(k, whi)),
298 );
299 if candidate.1 - candidate.0 > best.map_or(0.0, |(lo, hi)| hi - lo) {
300 best = Some(candidate);
301 }
302 if shift == 0.0 {
303 break;
304 }
305 }
306 let Some(candidate) = best else { continue };
307 kept = candidate;
308 }
309 if let Some(w) = window_a {
310 let (wlo, whi) = ordered(w);
311 kept = (kept.0.max(wlo), kept.1.min(whi));
312 }
313 if kept.1 - kept.0 <= slack {
314 continue;
315 }
316 let (on_a, on_b) = if span_a >= 0.0 {
317 (kept, (to_b(kept.0), to_b(kept.1)))
318 } else {
319 ((kept.1, kept.0), (to_b(kept.1), to_b(kept.0)))
320 };
321 overlaps.push(Overlap { on_a, on_b });
322 }
323 CurveIntersection {
324 crossings,
325 overlaps,
326 }
327}
328
329fn same_circle_3d(
335 a: &ogeom_geom::CircleCurve,
336 b: &ogeom_geom::CircleCurve,
337 tol: Tolerances,
338) -> Option<CurveIntersection<Point>> {
339 let (ca, cb) = (a.circle(), b.circle());
340 if ca.centre().distance(cb.centre()) > tol.confusion() {
341 return None;
342 }
343 if (ca.radius() - cb.radius()).abs() > tol.confusion() {
344 return None;
345 }
346 let (za, zb) = (ca.frame().z().vector(), cb.frame().z().vector());
349 if za.cross(zb).magnitude() > tol.angular() {
350 return None;
351 }
352 let (lo, hi) = Curve3d::domain(a);
359 let start = a.point_at(lo, tol).ok()?;
360 let local = cb.frame().to_local(start);
361 let angle = local.y.atan2(local.x);
362 let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
363 let along_a = a.d1_at(lo, tol).ok()?;
364 let along_b = b.d1_at(phase, tol).ok()?;
365 let winding: f64 = if along_a.dot(along_b) >= 0.0 {
366 1.0
367 } else {
368 -1.0
369 };
370 Some(CurveIntersection {
371 crossings: Vec::new(),
372 overlaps: vec![Overlap {
373 on_a: (lo, hi),
374 on_b: (phase, winding.mul_add(hi - lo, phase)),
375 }],
376 })
377}
378
379fn same_curve_3d(a: &Curve, b: &Curve) -> Option<CurveIntersection<Point>> {
386 let (lo, hi) = Curve3d::domain(a);
387 if a == b {
388 return Some(CurveIntersection {
389 crossings: Vec::new(),
390 overlaps: vec![Overlap {
391 on_a: (lo, hi),
392 on_b: (lo, hi),
393 }],
394 });
395 }
396 use ogeom_geom::Reversible as _;
397 if *a == b.clone().reversed() {
398 let (blo, bhi) = Curve3d::domain(b);
399 return Some(CurveIntersection {
400 crossings: Vec::new(),
401 overlaps: vec![Overlap {
402 on_a: (lo, hi),
403 on_b: (bhi, blo),
404 }],
405 });
406 }
407 None
408}
409
410fn same_ellipse_3d(
417 a: &ogeom_geom::EllipseCurve,
418 b: &ogeom_geom::EllipseCurve,
419 tol: Tolerances,
420) -> Option<CurveIntersection<Point>> {
421 let (ea, eb) = (a.ellipse(), b.ellipse());
422 if ea.frame().origin().distance(eb.frame().origin()) > tol.confusion() {
423 return None;
424 }
425 if (ea.major_radius() - eb.major_radius()).abs() > tol.confusion()
426 || (ea.minor_radius() - eb.minor_radius()).abs() > tol.confusion()
427 {
428 return None;
429 }
430 let (za, zb) = (ea.frame().z().vector(), eb.frame().z().vector());
431 if za.cross(zb).magnitude() > tol.angular() {
432 return None;
433 }
434 let (xa, xb) = (ea.frame().x().vector(), eb.frame().x().vector());
435 if xa.cross(xb).magnitude() > tol.angular() {
436 return None;
437 }
438 let (lo, hi) = Curve3d::domain(a);
442 let start = a.point_at(lo, tol).ok()?;
443 let angle = ogeom_math::elementary::ellipse_parameter(&eb, start, tol).ok()?;
444 let phase = if b.is_reversed() { -angle } else { angle }.rem_euclid(core::f64::consts::TAU);
445 let along_a = a.d1_at(lo, tol).ok()?;
446 let along_b = b.d1_at(phase, tol).ok()?;
447 let winding: f64 = if along_a.dot(along_b) >= 0.0 {
448 1.0
449 } else {
450 -1.0
451 };
452 Some(CurveIntersection {
453 crossings: Vec::new(),
454 overlaps: vec![Overlap {
455 on_a: (lo, hi),
456 on_b: (phase, winding.mul_add(hi - lo, phase)),
457 }],
458 })
459}
460
461fn check(options: CurveCurveOptions) -> OgeomResult<()> {
462 if options.samples < 2 {
463 ogeom_bail!(Construction, "seeding needs at least two segments");
464 }
465 if !options.gap.is_finite() || options.gap <= 0.0 {
466 ogeom_bail!(Construction, "a gap of {} is not a distance", options.gap);
467 }
468 Ok(())
469}
470
471fn line_line_2d(
474 a: &ogeom_geom::Line2d,
475 b: &ogeom_geom::Line2d,
476 tol: Tolerances,
477) -> CurveIntersection<Point2> {
478 let (oa, da) = (a.axis().location, a.axis().direction.vector());
479 let (ob, db) = (b.axis().location, b.axis().direction.vector());
480 let cross = da.cross(db);
481
482 if cross.abs() <= tol.angular() {
483 let between = ob - oa;
485 if between.cross(da).abs() > tol.confusion() {
486 return CurveIntersection::empty();
487 }
488 let (a_lo, a_hi) = a.domain();
490 let (b_lo, b_hi) = b.domain();
491 let project = |p: Point2| (p - oa).dot(da);
493 let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
494 let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
495 if lo >= hi {
496 return CurveIntersection::empty();
497 }
498 let back = |t: f64| (oa + da * t - ob).dot(db);
500 return CurveIntersection {
501 crossings: Vec::new(),
502 overlaps: vec![Overlap {
503 on_a: (lo, hi),
504 on_b: (back(lo), back(hi)),
510 }],
511 };
512 }
513
514 let between = ob - oa;
515 let t = between.cross(db) / cross;
516 let s = between.cross(da) / cross;
517 let (a_lo, a_hi) = a.domain();
518 let (b_lo, b_hi) = b.domain();
519 if t < a_lo - tol.parametric()
520 || t > a_hi + tol.parametric()
521 || s < b_lo - tol.parametric()
522 || s > b_hi + tol.parametric()
523 {
524 return CurveIntersection::empty();
525 }
526 CurveIntersection {
527 crossings: vec![Crossing {
528 on_a: t,
529 on_b: s,
530 point: oa + da * t,
531 gap: 0.0,
532 reach: 0.0,
533 }],
534 overlaps: Vec::new(),
535 }
536}
537
538fn line_circle_2d(
539 line: &ogeom_geom::Line2d,
540 circle: &ogeom_geom::Circle2d,
541 swapped: bool,
542 tol: Tolerances,
543) -> CurveIntersection<Point2> {
544 let (o, d) = (line.axis().location, line.axis().direction.vector());
545 let c = circle.circle();
546 let centre = c.centre();
547 let radius = c.radius();
548
549 let along = (centre - o).dot(d);
551 let foot = o + d * along;
552 let gap = foot.distance(centre);
553 if gap > radius + tol.confusion() {
554 return CurveIntersection::empty();
555 }
556 let half = radius.mul_add(radius, -(gap * gap)).max(0.0).sqrt();
557 let candidates = if half <= tol.confusion() {
558 vec![along]
559 } else {
560 vec![along - half, along + half]
561 };
562
563 let (l_lo, l_hi) = line.domain();
564 let mut crossings = Vec::new();
565 for t in candidates {
566 if t < l_lo - tol.parametric() || t > l_hi + tol.parametric() {
567 continue;
568 }
569 let p = o + d * t;
570 let Some(s) = circle_parameter(circle, p, tol) else {
571 continue;
572 };
573 let (on_a, on_b) = if swapped { (s, t) } else { (t, s) };
574 crossings.push(Crossing {
575 on_a,
576 on_b,
577 point: p,
578 gap: 0.0,
579 reach: 0.0,
580 });
581 }
582 sort_crossings(&mut crossings);
583 CurveIntersection {
584 crossings,
585 overlaps: Vec::new(),
586 }
587}
588
589fn circle_circle_2d(
590 a: &ogeom_geom::Circle2d,
591 b: &ogeom_geom::Circle2d,
592 tol: Tolerances,
593) -> CurveIntersection<Point2> {
594 let (ca, cb) = (a.circle(), b.circle());
595 let between = cb.centre() - ca.centre();
596 let distance = between.magnitude();
597 let (ra, rb) = (ca.radius(), cb.radius());
598
599 if distance <= tol.confusion() {
600 if (ra - rb).abs() <= tol.confusion() {
601 return CurveIntersection {
603 crossings: Vec::new(),
604 overlaps: vec![Overlap {
605 on_a: a.domain(),
606 on_b: b.domain(),
607 }],
608 };
609 }
610 return CurveIntersection::empty();
611 }
612 if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
613 return CurveIntersection::empty();
614 }
615
616 let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
618 let squared = ra.mul_add(ra, -(along * along));
619 let direction = between * (1.0 / distance);
620 let foot = ca.centre() + direction * along;
621 let mut crossings = Vec::new();
622 let mut push = |p: Point2| {
623 if let (Some(s), Some(t)) = (circle_parameter(a, p, tol), circle_parameter(b, p, tol)) {
624 crossings.push(Crossing {
625 on_a: s,
626 on_b: t,
627 point: p,
628 gap: 0.0,
629 reach: 0.0,
630 });
631 }
632 };
633 if squared <= tol.confusion() * tol.confusion() {
634 push(foot);
635 } else {
636 let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
637 push(foot + offset);
638 push(foot - offset);
639 }
640 sort_crossings(&mut crossings);
641 CurveIntersection {
642 crossings,
643 overlaps: Vec::new(),
644 }
645}
646
647fn circle_parameter(curve: &ogeom_geom::Circle2d, p: Point2, tol: Tolerances) -> Option<f64> {
649 let c = curve.circle();
650 let local = p - c.centre();
651 let x = local.dot(c.frame().x().vector());
652 let y = local.dot(c.frame().y().vector());
653 let mut angle = y.atan2(x);
654 if curve.is_reversed() {
655 angle = -angle;
656 }
657 let angle = angle.rem_euclid(core::f64::consts::TAU);
658 let (lo, hi) = curve.domain();
659 if angle >= lo - tol.parametric() && angle <= hi + tol.parametric() {
661 return Some(angle.clamp(lo, hi));
662 }
663 let shifted = angle - core::f64::consts::TAU;
664 if shifted >= lo - tol.parametric() && shifted <= hi + tol.parametric() {
665 return Some(shifted.clamp(lo, hi));
666 }
667 None
668}
669
670fn line_line_3d(
673 a: &ogeom_geom::LineCurve,
674 b: &ogeom_geom::LineCurve,
675 options: CurveCurveOptions,
676 tol: Tolerances,
677) -> CurveIntersection<Point> {
678 let (oa, da) = (a.axis().location, a.axis().direction.vector());
679 let (ob, db) = (b.axis().location, b.axis().direction.vector());
680 let cross = da.cross(db);
681 let denominator = cross.square_magnitude();
682
683 if denominator <= tol.angular() * tol.angular() {
684 let between = ob - oa;
686 if between.cross(da).magnitude() > tol.confusion() {
687 return CurveIntersection::empty();
688 }
689 let (a_lo, a_hi) = a.domain();
690 let (b_lo, b_hi) = b.domain();
691 let project = |p: Point| (p - oa).dot(da);
692 let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
693 let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
694 if lo >= hi {
695 return CurveIntersection::empty();
696 }
697 let back = |t: f64| (oa + da * t - ob).dot(db);
698 return CurveIntersection {
699 crossings: Vec::new(),
700 overlaps: vec![Overlap {
701 on_a: (lo, hi),
702 on_b: (back(lo), back(hi)),
708 }],
709 };
710 }
711
712 let between = ob - oa;
714 let t = between.cross(db).dot(cross) / denominator;
715 let s = between.cross(da).dot(cross) / denominator;
716 let pa = oa + da * t;
717 let pb = ob + db * s;
718 let gap = pa.distance(pb);
719 let (a_lo, a_hi) = a.domain();
720 let (b_lo, b_hi) = b.domain();
721 if gap > options.gap
722 || t < a_lo - tol.parametric()
723 || t > a_hi + tol.parametric()
724 || s < b_lo - tol.parametric()
725 || s > b_hi + tol.parametric()
726 {
727 return CurveIntersection::empty();
728 }
729 CurveIntersection {
730 crossings: vec![Crossing {
731 on_a: t,
732 on_b: s,
733 point: pa,
734 gap,
735 reach: 0.0,
736 }],
737 overlaps: Vec::new(),
738 }
739}
740
741struct Sampled<P> {
745 points: Vec<P>,
746 parameters: Vec<f64>,
747}
748
749fn sample_2d(curve: &PlanarCurve, n: usize, tol: Tolerances) -> Sampled<Point2> {
750 let (lo, hi) = curve.domain();
751 let mut points = Vec::with_capacity(n + 1);
752 let mut parameters = Vec::with_capacity(n + 1);
753 for i in 0..=n {
754 #[allow(clippy::cast_precision_loss)]
755 let t = lo + (hi - lo) * i as f64 / n as f64;
756 if let Ok(p) = curve.point_at(t, tol) {
757 points.push(p);
758 parameters.push(t);
759 }
760 }
761 Sampled { points, parameters }
762}
763
764fn sample_3d(curve: &Curve, n: usize, tol: Tolerances) -> Sampled<Point> {
765 let (lo, hi) = curve.domain();
766 let mut points = Vec::with_capacity(n + 1);
767 let mut parameters = Vec::with_capacity(n + 1);
768 for i in 0..=n {
769 #[allow(clippy::cast_precision_loss)]
770 let t = lo + (hi - lo) * i as f64 / n as f64;
771 if let Ok(p) = curve.point_at(t, tol) {
772 points.push(p);
773 parameters.push(t);
774 }
775 }
776 Sampled { points, parameters }
777}
778
779fn general_2d(
780 a: &PlanarCurve,
781 b: &PlanarCurve,
782 options: CurveCurveOptions,
783 tol: Tolerances,
784) -> OgeomResult<CurveIntersection<Point2>> {
785 let sa = sample_2d(a, options.samples, tol);
786 let sb = sample_2d(b, options.samples, tol);
787
788 let mut crossings: Vec<Crossing<Point2>> = Vec::new();
789 for i in 1..sa.points.len() {
790 for j in 1..sb.points.len() {
791 let Some((ta, tb)) = segments_cross_2d(
792 (sa.points[i - 1], sa.points[i]),
793 (sb.points[j - 1], sb.points[j]),
794 ) else {
795 continue;
796 };
797 let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
798 let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
799 if let Some(found) = polish_2d(a, b, seed_a, seed_b, options, tol) {
800 push_unique_2d(&mut crossings, found, tol);
801 }
802 }
803 }
804 sort_crossings(&mut crossings);
805 Ok(CurveIntersection {
806 crossings,
807 overlaps: Vec::new(),
808 })
809}
810
811fn general_3d(
812 a: &Curve,
813 b: &Curve,
814 options: CurveCurveOptions,
815 tol: Tolerances,
816) -> OgeomResult<CurveIntersection<Point>> {
817 let sa = sample_3d(a, options.samples, tol);
818 let sb = sample_3d(b, options.samples, tol);
819
820 let mut reach = options.gap;
824 for s in [&sa, &sb] {
825 let longest = s
826 .points
827 .windows(2)
828 .map(|w| w[0].distance(w[1]))
829 .fold(0.0_f64, f64::max);
830 reach += longest;
831 }
832
833 let mut crossings: Vec<Crossing<Point>> = Vec::new();
834 for i in 1..sa.points.len() {
835 for j in 1..sb.points.len() {
836 let (ta, tb, gap) = segments_approach_3d(
837 (sa.points[i - 1], sa.points[i]),
838 (sb.points[j - 1], sb.points[j]),
839 );
840 if gap > reach {
841 continue;
842 }
843 let seed_a = sa.parameters[i - 1] + (sa.parameters[i] - sa.parameters[i - 1]) * ta;
844 let seed_b = sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb;
845 if let Some(found) = polish_3d(a, b, seed_a, seed_b, options, tol) {
846 push_unique_3d(&mut crossings, found, tol);
847 }
848 }
849 }
850 sort_crossings(&mut crossings);
851
852 if crossings.len() > 1 {
861 let mut merged: Vec<Crossing<Point>> = Vec::with_capacity(crossings.len());
862 let mut run_start: Option<Point> = None;
863 for c in crossings {
864 if let Some(last) = merged.last_mut()
865 && contact_between_3d(a, b, last, &c, options, tol)
866 {
867 let start = run_start.get_or_insert(last.point);
868 let reach = start.distance(c.point).max(last.reach);
869 if c.gap < last.gap {
870 *last = c;
871 }
872 last.reach = reach;
873 continue;
874 }
875 run_start = None;
876 merged.push(c);
877 }
878 if merged.len() > 1 && a.is_periodic() {
882 let (lo, hi) = a.domain();
883 let (first, last) = (merged[0], merged[merged.len() - 1]);
884 let wrapped = Crossing {
885 on_a: first.on_a + (hi - lo),
886 ..first
887 };
888 if contact_between_3d(a, b, &last, &wrapped, options, tol) {
889 let reach = last
890 .reach
891 .max(first.reach)
892 .max(last.point.distance(first.point));
893 let keep = if first.gap <= last.gap {
894 0
895 } else {
896 merged.len() - 1
897 };
898 merged[keep].reach = reach;
899 if keep == 0 {
900 merged.pop();
901 } else {
902 merged.remove(0);
903 }
904 }
905 }
906 crossings = merged;
907 }
908
909 let overlaps = shared_support_3d(a, b, &sa, &sb, options, tol);
919 if !overlaps.is_empty() {
920 crossings.retain(|c| {
921 !overlaps.iter().any(|o| {
922 let (lo, hi) = order(o.on_a.0, o.on_a.1);
923 c.on_a >= lo - tol.parametric() && c.on_a <= hi + tol.parametric()
924 })
925 });
926 }
927 let gap = options.gap.max(tol.confusion());
939 let extent = {
940 let along = |s: &Sampled<Point>| {
941 s.points
942 .windows(2)
943 .map(|w| w[0].distance(w[1]))
944 .sum::<f64>()
945 };
946 along(&sa).min(along(&sb))
947 };
948 let cap = 4.0 * (gap * extent).sqrt();
949 for c in &mut crossings {
950 let valley = valley_extent_3d(a, b, &sb, c, gap, tol);
951 if valley > gap * 8.0 && valley <= cap {
952 c.reach = c.reach.max(valley);
953 }
954 }
955 Ok(CurveIntersection {
956 crossings,
957 overlaps,
958 })
959}
960
961fn contact_between_3d(
965 a: &Curve,
966 b: &Curve,
967 from: &Crossing<Point>,
968 to: &Crossing<Point>,
969 options: CurveCurveOptions,
970 tol: Tolerances,
971) -> bool {
972 if (to.on_a - from.on_a).abs() <= tol.parametric() {
973 return true;
974 }
975 (1..=3).all(|k| {
976 let f = f64::from(k) / 4.0;
977 let t = from.on_a + (to.on_a - from.on_a) * f;
978 let seed = from.on_b + (to.on_b - from.on_b) * f;
979 a.point_at(t, tol)
980 .ok()
981 .and_then(|p| foot_on_3d(b, p, seed, tol))
982 .is_some_and(|(_, gap)| gap <= options.gap)
983 })
984}
985
986fn shared_support_3d(
989 a: &Curve,
990 b: &Curve,
991 sa: &Sampled<Point>,
992 sb: &Sampled<Point>,
993 options: CurveCurveOptions,
994 tol: Tolerances,
995) -> Vec<Overlap> {
996 let foot = |p: Point| -> Option<(f64, f64)> {
999 let mut seed = (f64::INFINITY, 0.0);
1000 for j in 1..sb.points.len() {
1001 let (_, tb, gap) = segments_approach_3d((p, p), (sb.points[j - 1], sb.points[j]));
1002 if gap < seed.0 {
1003 seed = (
1004 gap,
1005 sb.parameters[j - 1] + (sb.parameters[j] - sb.parameters[j - 1]) * tb,
1006 );
1007 }
1008 }
1009 if !seed.0.is_finite() {
1010 return None;
1011 }
1012 foot_on_3d(b, p, seed.1, tol)
1013 };
1014 let hugs = |t: f64| -> Option<(f64, f64)> {
1015 let p = a.point_at(t, tol).ok()?;
1016 let (s, gap) = foot(p)?;
1017 (gap <= options.gap).then_some((s, gap))
1018 };
1019 let feet: Vec<Option<(f64, f64)>> = sa.points.iter().map(|p| foot(*p)).collect();
1020 let within = |i: usize| feet[i].is_some_and(|(_, gap)| gap <= options.gap);
1021
1022 let mut overlaps = Vec::new();
1023 let mut i = 0;
1024 while i < sa.points.len() {
1025 if !within(i) {
1026 i += 1;
1027 continue;
1028 }
1029 let start = i;
1030 while i + 1 < sa.points.len() && within(i + 1) {
1031 i += 1;
1032 }
1033 let end = i;
1034 i += 1;
1035 if end == start {
1036 continue;
1037 }
1038 let refine = |inside: usize, outside: Option<usize>| -> (f64, f64) {
1041 let (mut t_in, s_in) = (sa.parameters[inside], feet[inside].map_or(0.0, |f| f.0));
1042 let Some(out) = outside else {
1043 return (t_in, s_in);
1044 };
1045 let mut s_at = s_in;
1046 let mut t_out = sa.parameters[out];
1047 for _ in 0..48 {
1048 if (t_out - t_in).abs() <= tol.parametric() {
1049 break;
1050 }
1051 let mid = f64::midpoint(t_in, t_out);
1052 match hugs(mid) {
1053 Some((s, _)) => {
1054 t_in = mid;
1055 s_at = s;
1056 }
1057 None => t_out = mid,
1058 }
1059 }
1060 (t_in, s_at)
1061 };
1062 let (lo_a, lo_b) = refine(start, start.checked_sub(1));
1063 let (hi_a, hi_b) = refine(end, (end + 1 < sa.points.len()).then_some(end + 1));
1064 if hi_a - lo_a <= tol.parametric() || (hi_b - lo_b).abs() <= tol.parametric() {
1065 continue;
1066 }
1067 overlaps.push(Overlap {
1068 on_a: (lo_a, hi_a),
1069 on_b: (lo_b, hi_b),
1070 });
1071 }
1072 overlaps
1073}
1074
1075fn foot_on_3d(curve: &Curve, p: Point, seed: f64, tol: Tolerances) -> Option<(f64, f64)> {
1078 let mut s = clamp_3d(curve, seed);
1079 let mut best = (s, curve.point_at(s, tol).ok()?.distance(p));
1080 for _ in 0..30 {
1081 let d = curve.derivatives_at(s, 2, tol).ok()?;
1082 let zero = ogeom_math::Vector::ZERO;
1083 let (c, d1, d2) = (
1084 d.first().copied().unwrap_or(zero),
1085 d.get(1).copied().unwrap_or(zero),
1086 d.get(2).copied().unwrap_or(zero),
1087 );
1088 let gap = c - (p - Point::ORIGIN);
1089 let g = gap.dot(d1);
1090 let dg = d1.dot(d1) + gap.dot(d2);
1091 if dg.abs() <= f64::MIN_POSITIVE {
1092 break;
1093 }
1094 let next = clamp_3d(curve, s - g / dg);
1095 let dist = curve.point_at(next, tol).ok()?.distance(p);
1096 let moved = (next - s).abs();
1097 s = next;
1098 if dist < best.1 {
1099 best = (s, dist);
1100 }
1101 if moved <= tol.parametric() {
1102 break;
1103 }
1104 }
1105 Some(best)
1106}
1107
1108fn polish_2d(
1110 a: &PlanarCurve,
1111 b: &PlanarCurve,
1112 seed_a: f64,
1113 seed_b: f64,
1114 options: CurveCurveOptions,
1115 tol: Tolerances,
1116) -> Option<Crossing<Point2>> {
1117 let system = |x: &[f64]| {
1118 let (t, s) = (clamp_2d(a, x[0]), clamp_2d(b, x[1]));
1119 let pa = a.point_at(t, tol).unwrap_or(Point2::ORIGIN);
1120 let pb = b.point_at(s, tol).unwrap_or(Point2::ORIGIN);
1121 let da = a
1122 .d1_at(t, tol)
1123 .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1124 let db = b
1125 .d1_at(s, tol)
1126 .unwrap_or(ogeom_math::Vector2::new(0.0, 0.0));
1127 (
1128 vec![pa.x - pb.x, pa.y - pb.y],
1129 vec![vec![da.x, -db.x], vec![da.y, -db.y]],
1130 )
1131 };
1132 let criteria = solve::Criteria {
1133 residual: tol.confusion() * 0.01,
1134 step: tol.parametric(),
1135 max_iterations: 40,
1136 };
1137 let found = solve::newton_system(system, &[seed_a, seed_b], criteria).ok()?;
1138 let (t, s) = (clamp_2d(a, found.value[0]), clamp_2d(b, found.value[1]));
1139 let pa = a.point_at(t, tol).ok()?;
1140 let pb = b.point_at(s, tol).ok()?;
1141 let gap = pa.distance(pb);
1142 if gap > options.gap {
1143 return None;
1144 }
1145 Some(Crossing {
1146 on_a: t,
1147 on_b: s,
1148 point: pa,
1149 gap,
1150 reach: 0.0,
1151 })
1152}
1153
1154fn polish_3d(
1161 a: &Curve,
1162 b: &Curve,
1163 seed_a: f64,
1164 seed_b: f64,
1165 options: CurveCurveOptions,
1166 tol: Tolerances,
1167) -> Option<Crossing<Point>> {
1168 let system = |x: &[f64]| {
1169 let (t, s) = (clamp_3d(a, x[0]), clamp_3d(b, x[1]));
1170 let pa = a.point_at(t, tol).unwrap_or(Point::ORIGIN);
1171 let pb = b.point_at(s, tol).unwrap_or(Point::ORIGIN);
1172 let da = a.derivatives_at(t, 2, tol).unwrap_or_default();
1173 let db = b.derivatives_at(s, 2, tol).unwrap_or_default();
1174 let zero = ogeom_math::Vector::ZERO;
1175 let (d1a, d2a) = (
1176 da.get(1).copied().unwrap_or(zero),
1177 da.get(2).copied().unwrap_or(zero),
1178 );
1179 let (d1b, d2b) = (
1180 db.get(1).copied().unwrap_or(zero),
1181 db.get(2).copied().unwrap_or(zero),
1182 );
1183 let gap = pa - pb;
1184 (
1185 vec![gap.dot(d1a), -gap.dot(d1b)],
1186 vec![
1187 vec![d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
1188 vec![-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
1189 ],
1190 )
1191 };
1192 let criteria = solve::Criteria {
1193 residual: tol.confusion() * 0.01,
1194 step: tol.parametric(),
1195 max_iterations: 40,
1196 };
1197 let found = solve::newton_system(system, &[seed_a, seed_b], criteria).ok()?;
1198 let (t, s) = (clamp_3d(a, found.value[0]), clamp_3d(b, found.value[1]));
1199 let pa = a.point_at(t, tol).ok()?;
1200 let pb = b.point_at(s, tol).ok()?;
1201 let gap = pa.distance(pb);
1202 if gap > options.gap {
1203 return None;
1204 }
1205 Some(Crossing {
1206 on_a: t,
1207 on_b: s,
1208 point: pa,
1209 gap,
1210 reach: 0.0,
1211 })
1212}
1213
1214fn clamp_2d(curve: &PlanarCurve, t: f64) -> f64 {
1217 let (lo, hi) = curve.domain();
1218 if curve.is_periodic() {
1219 let span = hi - lo;
1220 if span > 0.0 {
1221 return lo + (t - lo).rem_euclid(span);
1222 }
1223 }
1224 t.clamp(lo, hi)
1225}
1226
1227fn clamp_3d(curve: &Curve, t: f64) -> f64 {
1228 let (lo, hi) = curve.domain();
1229 if curve.is_periodic() {
1230 let span = hi - lo;
1231 if span > 0.0 {
1232 return lo + (t - lo).rem_euclid(span);
1233 }
1234 }
1235 t.clamp(lo, hi)
1236}
1237
1238fn segments_cross_2d(a: (Point2, Point2), b: (Point2, Point2)) -> Option<(f64, f64)> {
1240 let da = a.1 - a.0;
1241 let db = b.1 - b.0;
1242 let cross = da.cross(db);
1243 if cross.abs() <= f64::MIN_POSITIVE {
1244 return None;
1245 }
1246 let between = b.0 - a.0;
1247 let t = between.cross(db) / cross;
1248 let s = between.cross(da) / cross;
1249 if !(0.0..=1.0).contains(&t) || !(0.0..=1.0).contains(&s) {
1250 return None;
1251 }
1252 Some((t, s))
1253}
1254
1255fn distance_to_curve_3d(b: &Curve, sb: &Sampled<Point>, p: Point, tol: Tolerances) -> f64 {
1258 let mut best = (0_usize, f64::INFINITY);
1259 for i in 1..sb.points.len() {
1260 let (q0, q1) = (sb.points[i - 1], sb.points[i]);
1261 let d = q1 - q0;
1262 let len2 = d.dot(d);
1263 let f = if len2 <= f64::MIN_POSITIVE {
1264 0.0
1265 } else {
1266 ((p - q0).dot(d) / len2).clamp(0.0, 1.0)
1267 };
1268 let dist = p.distance(q0 + d * f);
1269 if dist < best.1 {
1270 best = (i, dist);
1271 }
1272 }
1273 if best.0 == 0 {
1274 return best.1;
1275 }
1276 let (mut lo, mut hi) = (sb.parameters[best.0 - 1], sb.parameters[best.0]);
1277 let at = |t: f64| -> f64 { b.point_at(t, tol).map_or(f64::INFINITY, |q| q.distance(p)) };
1278 let phi = 0.5 * (3.0 - 5.0_f64.sqrt());
1281 let (mut x1, mut x2) = (lo + phi * (hi - lo), hi - phi * (hi - lo));
1282 let (mut f1, mut f2) = (at(x1), at(x2));
1283 for _ in 0..48 {
1284 if f1 < f2 {
1285 hi = x2;
1286 x2 = x1;
1287 f2 = f1;
1288 x1 = lo + phi * (hi - lo);
1289 f1 = at(x1);
1290 } else {
1291 lo = x1;
1292 x1 = x2;
1293 f1 = f2;
1294 x2 = hi - phi * (hi - lo);
1295 f2 = at(x2);
1296 }
1297 }
1298 f1.min(f2).min(best.1)
1299}
1300
1301fn valley_extent_3d(
1304 a: &Curve,
1305 b: &Curve,
1306 sb: &Sampled<Point>,
1307 crossing: &Crossing<Point>,
1308 gap: f64,
1309 tol: Tolerances,
1310) -> f64 {
1311 let (lo, hi) = a.domain();
1312 let span = hi - lo;
1313 if span <= 0.0 {
1314 return 0.0;
1315 }
1316 let mut extent = 0.0_f64;
1317 for direction in [-1.0, 1.0] {
1318 let inside = |t: f64| -> bool {
1319 if t < lo || t > hi {
1320 return false;
1321 }
1322 a.point_at(t, tol)
1323 .is_ok_and(|q| distance_to_curve_3d(b, sb, q, tol) <= gap)
1324 };
1325 let mut step = span * 1e-6;
1326 let mut last_in = crossing.on_a;
1327 let mut first_out: Option<f64> = None;
1328 while step <= span {
1329 let t = crossing.on_a + direction * step;
1330 if inside(t) {
1331 last_in = t;
1332 step *= 2.0;
1333 } else {
1334 first_out = Some(t);
1335 break;
1336 }
1337 }
1338 let edge = match first_out {
1339 Some(mut out) => {
1340 let mut r#in = last_in;
1341 for _ in 0..30 {
1342 let mid = f64::midpoint(r#in, out);
1343 if inside(mid) {
1344 r#in = mid;
1345 } else {
1346 out = mid;
1347 }
1348 }
1349 r#in
1350 }
1351 None => last_in,
1352 };
1353 if let Ok(q) = a.point_at(edge, tol) {
1354 extent = extent.max(q.distance(crossing.point));
1355 }
1356 }
1357 extent
1358}
1359
1360fn segments_approach_3d(a: (Point, Point), b: (Point, Point)) -> (f64, f64, f64) {
1362 let da = a.1 - a.0;
1363 let db = b.1 - b.0;
1364 let between = a.0 - b.0;
1365 let (aa, bb, ab) = (da.dot(da), db.dot(db), da.dot(db));
1366 let (ad, bd) = (da.dot(between), db.dot(between));
1367 let denominator = ab.mul_add(-ab, aa * bb);
1368
1369 let (mut t, mut s) = if denominator.abs() <= f64::MIN_POSITIVE {
1370 (
1371 0.0,
1372 if bb > 0.0 {
1373 (bd / bb).clamp(0.0, 1.0)
1374 } else {
1375 0.0
1376 },
1377 )
1378 } else {
1379 (
1380 (ab.mul_add(bd, -(bb * ad)) / denominator).clamp(0.0, 1.0),
1381 (aa.mul_add(bd, -(ab * ad)) / denominator).clamp(0.0, 1.0),
1382 )
1383 };
1384 if bb > 0.0 {
1386 s = ((da.dot(between) + t * aa - 0.0).mul_add(0.0, db.dot(between + da * t)) / bb)
1387 .clamp(0.0, 1.0);
1388 }
1389 if aa > 0.0 {
1390 t = (da.dot(db * s - between) / aa).clamp(0.0, 1.0);
1391 }
1392 let pa = a.0 + da * t;
1393 let pb = b.0 + db * s;
1394 (t, s, pa.distance(pb))
1395}
1396
1397fn order(a: f64, b: f64) -> (f64, f64) {
1398 if a <= b { (a, b) } else { (b, a) }
1399}
1400
1401fn sort_crossings<P>(crossings: &mut [Crossing<P>]) {
1402 crossings.sort_by(|x, y| {
1403 x.on_a
1404 .partial_cmp(&y.on_a)
1405 .unwrap_or(core::cmp::Ordering::Equal)
1406 });
1407}
1408
1409fn push_unique_2d(crossings: &mut Vec<Crossing<Point2>>, found: Crossing<Point2>, tol: Tolerances) {
1410 let reach = tol.confusion() * 100.0;
1411 if crossings
1412 .iter()
1413 .any(|c| c.point.distance(found.point) <= reach)
1414 {
1415 return;
1416 }
1417 crossings.push(found);
1418}
1419
1420fn push_unique_3d(crossings: &mut Vec<Crossing<Point>>, found: Crossing<Point>, tol: Tolerances) {
1421 let reach = tol.confusion() * 100.0;
1422 if crossings
1423 .iter()
1424 .any(|c| c.point.distance(found.point) <= reach)
1425 {
1426 return;
1427 }
1428 crossings.push(found);
1429}
1430
1431#[cfg(test)]
1432#[allow(clippy::unwrap_used)]
1433mod tests {
1434 use super::*;
1435 use ogeom_geom::{BSpline2d, Circle2d, CircleCurve, Line2d, LineCurve};
1436 use ogeom_math::{Circle, Circle2, Direction2, Frame, Frame2, KnotVector, Vector2};
1437
1438 const T: Tolerances = Tolerances::millimetres();
1439
1440 fn line2(from: Point2, to: Point2) -> PlanarCurve {
1441 Line2d::segment(from, to, T).unwrap().into()
1442 }
1443
1444 fn circle2(centre: Point2, radius: f64) -> PlanarCurve {
1445 Circle2d::new(
1446 Circle2::new(
1447 Frame2::new(centre, Direction2::new(Vector2::new(1.0, 0.0), T).unwrap()),
1448 radius,
1449 T,
1450 )
1451 .unwrap(),
1452 )
1453 .into()
1454 }
1455
1456 #[test]
1457 fn two_lines_cross_where_algebra_says() {
1458 let a = line2(Point2::new(0.0, 0.0), Point2::new(4.0, 4.0));
1459 let b = line2(Point2::new(0.0, 4.0), Point2::new(4.0, 0.0));
1460 let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
1461 assert_eq!(found.crossings.len(), 1);
1462 let hit = &found.crossings[0];
1463 assert!(hit.point.is_equal(Point2::new(2.0, 2.0), T));
1464 approx::assert_relative_eq!(hit.on_a, 8.0_f64.sqrt(), epsilon = 1e-9);
1466
1467 let short = line2(Point2::new(0.0, 4.0), Point2::new(1.0, 3.0));
1469 assert!(
1470 intersect_curves_2d(&a, &short, CurveCurveOptions::default(), T)
1471 .unwrap()
1472 .is_empty()
1473 );
1474 }
1475
1476 #[test]
1477 fn collinear_lines_overlap_rather_than_crossing_everywhere() {
1478 let a = line2(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0));
1479 let b = line2(Point2::new(4.0, 0.0), Point2::new(20.0, 0.0));
1480 let found = intersect_curves_2d(&a, &b, CurveCurveOptions::default(), T).unwrap();
1481 assert!(found.crossings.is_empty());
1482 assert_eq!(found.overlaps.len(), 1);
1483 let overlap = &found.overlaps[0];
1484 approx::assert_relative_eq!(overlap.on_a.0, 4.0, epsilon = 1e-9);
1485 approx::assert_relative_eq!(overlap.on_a.1, 10.0, epsilon = 1e-9);
1486 approx::assert_relative_eq!(overlap.on_b.0, 0.0, epsilon = 1e-9);
1487 approx::assert_relative_eq!(overlap.on_b.1, 6.0, epsilon = 1e-9);
1488
1489 let above = line2(Point2::new(0.0, 1.0), Point2::new(10.0, 1.0));
1491 assert!(
1492 intersect_curves_2d(&a, &above, CurveCurveOptions::default(), T)
1493 .unwrap()
1494 .is_empty()
1495 );
1496 }
1497
1498 #[test]
1499 fn a_line_meets_a_circle_in_two_points_one_or_none() {
1500 let circle = circle2(Point2::new(0.0, 0.0), 2.0);
1501 let through = line2(Point2::new(-5.0, 0.0), Point2::new(5.0, 0.0));
1502 let found =
1503 intersect_curves_2d(&through, &circle, CurveCurveOptions::default(), T).unwrap();
1504 assert_eq!(found.crossings.len(), 2);
1505 for hit in &found.crossings {
1506 approx::assert_relative_eq!(
1507 hit.point.distance(Point2::new(0.0, 0.0)),
1508 2.0,
1509 epsilon = 1e-9
1510 );
1511 let PlanarCurve::Circle(_) = &circle else {
1513 unreachable!()
1514 };
1515 let on_circle = circle.point_at(hit.on_b, T).unwrap();
1516 assert!(on_circle.is_equal(hit.point, T));
1517 }
1518
1519 let tangent = line2(Point2::new(-5.0, 2.0), Point2::new(5.0, 2.0));
1520 assert_eq!(
1521 intersect_curves_2d(&tangent, &circle, CurveCurveOptions::default(), T)
1522 .unwrap()
1523 .crossings
1524 .len(),
1525 1
1526 );
1527 let missing = line2(Point2::new(-5.0, 3.0), Point2::new(5.0, 3.0));
1528 assert!(
1529 intersect_curves_2d(&missing, &circle, CurveCurveOptions::default(), T)
1530 .unwrap()
1531 .is_empty()
1532 );
1533 }
1534
1535 #[test]
1536 fn two_circles_cross_touch_coincide_or_miss() {
1537 let a = circle2(Point2::new(0.0, 0.0), 2.0);
1538
1539 let crossing = circle2(Point2::new(3.0, 0.0), 2.0);
1540 let found = intersect_curves_2d(&a, &crossing, CurveCurveOptions::default(), T).unwrap();
1541 assert_eq!(found.crossings.len(), 2);
1542 for hit in &found.crossings {
1543 let on_a = a.point_at(hit.on_a, T).unwrap();
1544 let on_b = crossing.point_at(hit.on_b, T).unwrap();
1545 assert!(on_a.is_equal(hit.point, T));
1546 assert!(on_b.is_equal(hit.point, T));
1547 }
1548
1549 let touching = circle2(Point2::new(4.0, 0.0), 2.0);
1550 assert_eq!(
1551 intersect_curves_2d(&a, &touching, CurveCurveOptions::default(), T)
1552 .unwrap()
1553 .crossings
1554 .len(),
1555 1
1556 );
1557
1558 let same = circle2(Point2::new(0.0, 0.0), 2.0);
1559 let coincident = intersect_curves_2d(&a, &same, CurveCurveOptions::default(), T).unwrap();
1560 assert!(coincident.crossings.is_empty());
1561 assert_eq!(coincident.overlaps.len(), 1);
1562
1563 let apart = circle2(Point2::new(10.0, 0.0), 2.0);
1564 assert!(
1565 intersect_curves_2d(&a, &apart, CurveCurveOptions::default(), T)
1566 .unwrap()
1567 .is_empty()
1568 );
1569 }
1570
1571 #[test]
1572 fn the_general_path_handles_what_has_no_closed_form() {
1573 let wave: PlanarCurve = BSpline2d::new(
1576 KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
1577 vec![
1578 Point2::new(0.0, -1.0),
1579 Point2::new(1.0, 3.0),
1580 Point2::new(2.0, -3.0),
1581 Point2::new(3.0, 3.0),
1582 Point2::new(4.0, -1.0),
1583 ],
1584 T,
1585 )
1586 .unwrap()
1587 .into();
1588 let axis = line2(Point2::new(-1.0, 0.0), Point2::new(5.0, 0.0));
1589 let found = intersect_curves_2d(&wave, &axis, CurveCurveOptions::default(), T).unwrap();
1590 assert_eq!(found.crossings.len(), 3, "a wave crosses its axis thrice");
1591 for hit in &found.crossings {
1592 assert!(hit.gap < 1e-9);
1593 assert!(hit.point.y.abs() < 1e-9);
1594 let on_wave = wave.point_at(hit.on_a, T).unwrap();
1595 assert!(on_wave.is_equal(hit.point, T));
1596 }
1597 }
1598
1599 #[test]
1606 fn one_circle_written_twice_states_the_correspondence_between_them() {
1607 use ogeom_math::{Direction, Vector};
1608 let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 3.0, T).unwrap()).into();
1609 let third = 2.0 * core::f64::consts::PI / 3.0;
1612 let flipped = Frame::new(
1613 Point::ORIGIN,
1614 -Direction::Z,
1615 Direction::new(Vector::new(third.cos(), third.sin(), 0.0), T).unwrap(),
1616 T,
1617 )
1618 .unwrap();
1619 let b: Curve = CircleCurve::new(Circle::new(flipped, 3.0, T).unwrap()).into();
1620 for pair in [(&a, &b), (&b, &a)] {
1621 let found = intersect_curves(pair.0, pair.1, CurveCurveOptions::default(), T).unwrap();
1622 assert!(
1623 found.crossings.is_empty(),
1624 "every point is a hit, so none is"
1625 );
1626 assert_eq!(found.overlaps.len(), 1);
1627 let overlap = &found.overlaps[0];
1628 let span = overlap.on_a.1 - overlap.on_a.0;
1629 for i in 0..=8 {
1630 let t = f64::from(i) / 8.0;
1631 let ta = span.mul_add(t, overlap.on_a.0);
1632 let tb = (overlap.on_b.1 - overlap.on_b.0).mul_add(t, overlap.on_b.0);
1633 let pa = pair.0.point_at(ta, T).unwrap();
1634 let pb = pair
1635 .1
1636 .point_at(tb.rem_euclid(core::f64::consts::TAU), T)
1637 .unwrap();
1638 assert!(
1639 pa.distance(pb) < 1e-9,
1640 "at {t}: {pa:?} against {pb:?} (on_a {:?} on_b {:?})",
1641 overlap.on_a,
1642 overlap.on_b
1643 );
1644 }
1645 }
1646 }
1647
1648 #[test]
1649 fn space_curves_cross_within_a_gap_and_report_it() {
1650 let a: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
1657 let lifted = Frame::new(
1658 Point::new(3.0, 0.0, 0.001),
1659 ogeom_math::Direction::Z,
1660 ogeom_math::Direction::X,
1661 T,
1662 )
1663 .unwrap();
1664 let b: Curve = CircleCurve::new(Circle::new(lifted, 2.0, T).unwrap()).into();
1665
1666 let options = CurveCurveOptions {
1667 gap: 1e-2,
1668 ..CurveCurveOptions::default()
1669 };
1670 let found = intersect_curves(&a, &b, options, T).unwrap();
1671 assert_eq!(found.crossings.len(), 2, "two near-crossings");
1672 for hit in &found.crossings {
1673 assert!(hit.gap > 1e-4, "the gap is real and must not be zeroed");
1674 assert!(hit.gap < 2e-3, "but small: {}", hit.gap);
1675 }
1676
1677 let strict = CurveCurveOptions {
1679 gap: 1e-5,
1680 ..CurveCurveOptions::default()
1681 };
1682 assert!(intersect_curves(&a, &b, strict, T).unwrap().is_empty());
1683 }
1684
1685 #[test]
1686 fn skew_lines_in_space_miss_and_close_ones_meet() {
1687 let a: Curve = LineCurve::segment(Point::ORIGIN, Point::new(10.0, 0.0, 0.0), T)
1688 .unwrap()
1689 .into();
1690 let skew: Curve =
1691 LineCurve::segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0), T)
1692 .unwrap()
1693 .into();
1694 assert!(
1695 intersect_curves(&a, &skew, CurveCurveOptions::default(), T)
1696 .unwrap()
1697 .is_empty(),
1698 "a unit apart is not a crossing"
1699 );
1700
1701 let meeting: Curve =
1702 LineCurve::segment(Point::new(5.0, -5.0, 0.0), Point::new(5.0, 5.0, 0.0), T)
1703 .unwrap()
1704 .into();
1705 let found = intersect_curves(&a, &meeting, CurveCurveOptions::default(), T).unwrap();
1706 assert_eq!(found.crossings.len(), 1);
1707 assert!(
1708 found.crossings[0]
1709 .point
1710 .is_equal(Point::new(5.0, 0.0, 0.0), T)
1711 );
1712 assert!(found.crossings[0].gap < 1e-12);
1713
1714 let collinear: Curve =
1716 LineCurve::segment(Point::new(4.0, 0.0, 0.0), Point::new(20.0, 0.0, 0.0), T)
1717 .unwrap()
1718 .into();
1719 let shared = intersect_curves(&a, &collinear, CurveCurveOptions::default(), T).unwrap();
1720 assert_eq!(shared.overlaps.len(), 1);
1721 }
1722
1723 #[test]
1724 fn a_fitted_curve_tracing_an_arc_is_one_overlap_not_a_row_of_crossings() {
1725 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1731 let points: Vec<Point> = (0..=40)
1732 .map(|i| {
1733 let a = 0.2 + 1.0 * f64::from(i) / 40.0;
1734 Point::new(4.0 * a.cos(), 4.0 * a.sin(), 0.0)
1735 })
1736 .collect();
1737 let fitted: Curve = ogeom_geom::fit::fit_points(&points, 3, 1e-7, T)
1738 .unwrap()
1739 .curve
1740 .into();
1741 let options = CurveCurveOptions {
1742 gap: 1e-5,
1743 ..CurveCurveOptions::default()
1744 };
1745 let found = intersect_curves(&fitted, &circle, options, T).unwrap();
1746 assert_eq!(found.overlaps.len(), 1, "one shared stretch: {found:?}");
1747 let (lo, hi) = found.overlaps[0].on_a;
1748 let (fa, fb) = fitted.domain();
1749 assert!(
1750 lo - fa < 1e-3 && fb - hi < 1e-3,
1751 "the whole fit runs along the circle"
1752 );
1753 assert!(
1754 found.crossings.is_empty(),
1755 "no crossing survives inside the overlap: {:?}",
1756 found.crossings
1757 );
1758 }
1759
1760 #[test]
1761 fn an_arc_ending_tangent_to_a_line_is_one_crossing_with_its_reach() {
1762 let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 4.0, T).unwrap()).into();
1767 let line: Curve =
1768 LineCurve::segment(Point::new(4.0, -3.0, 0.0), Point::new(4.0, 3.0, 0.0), T)
1769 .unwrap()
1770 .into();
1771 let options = CurveCurveOptions {
1772 gap: 1e-5,
1773 ..CurveCurveOptions::default()
1774 };
1775 let found = intersect_curves(&circle, &line, options, T).unwrap();
1776 assert_eq!(found.crossings.len(), 1, "one touch: {:?}", found.crossings);
1777 let touch = found.crossings[0];
1778 assert!(
1779 touch.point.distance(Point::new(4.0, 0.0, 0.0)) < 2e-2,
1780 "{touch:?}"
1781 );
1782 assert!(touch.reach < 5e-2, "the valley is short: {touch:?}");
1783 assert!(found.overlaps.is_empty());
1784 }
1785
1786 #[test]
1787 fn unusable_options_are_refused() {
1788 let a = line2(Point2::new(0.0, 0.0), Point2::new(1.0, 0.0));
1789 for options in [
1790 CurveCurveOptions {
1791 samples: 1,
1792 ..CurveCurveOptions::default()
1793 },
1794 CurveCurveOptions {
1795 gap: 0.0,
1796 ..CurveCurveOptions::default()
1797 },
1798 CurveCurveOptions {
1799 gap: f64::NAN,
1800 ..CurveCurveOptions::default()
1801 },
1802 ] {
1803 assert!(intersect_curves_2d(&a, &a.clone(), options, T).is_err());
1804 }
1805 }
1806}