1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
35use ogeom_geom::{Curve, SurfaceGeometry};
36use ogeom_math::{Circle, Direction, Ellipse, Frame, Point, Vector};
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum Meeting {
41 Apart,
43 Touching(Vec<Point>),
49 Along(Vec<Curve>),
51 Same,
58}
59
60pub fn surface_surface(
69 a: &SurfaceGeometry,
70 b: &SurfaceGeometry,
71 tol: Tolerances,
72) -> OgeomResult<Meeting> {
73 use SurfaceGeometry as S;
74 match (a, b) {
75 (S::Plane(p), S::Plane(q)) => Ok(plane_plane(
76 p.plane(),
77 q.plane(),
78 window_reach(p).min(window_reach(q)),
79 tol,
80 )),
81 (S::Plane(p), S::Sphere(s)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
82 (S::Sphere(s), S::Plane(p)) => Ok(plane_sphere(p.plane(), s.sphere(), tol)),
83 (S::Plane(p), S::Cylinder(c)) => plane_cylinder(p.plane(), c.cylinder(), tol),
84 (S::Cylinder(c), S::Plane(p)) => plane_cylinder(p.plane(), c.cylinder(), tol),
85 (S::Sphere(x), S::Sphere(y)) => Ok(sphere_sphere(x.sphere(), y.sphere(), tol)),
86 (S::Cylinder(x), S::Cylinder(y)) => coaxial_cylinders(x.cylinder(), y.cylinder(), tol),
87 (S::Cylinder(c), S::Sphere(s)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
88 (S::Sphere(s), S::Cylinder(c)) => coaxial_cylinder_sphere(c.cylinder(), s.sphere(), tol),
89 (S::Plane(p), S::Torus(t)) => axial_plane_torus(p.plane(), t.torus(), tol),
90 (S::Torus(t), S::Plane(p)) => axial_plane_torus(p.plane(), t.torus(), tol),
91 (S::Cylinder(c), S::Torus(t)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
92 (S::Torus(t), S::Cylinder(c)) => coaxial_cylinder_torus(c.cylinder(), t.torus(), tol),
93 (S::Torus(x), S::Torus(y)) => coaxial_tori(x.torus(), y.torus(), tol),
94 (S::Plane(p), S::Cone(c)) => plane_cone(p.plane(), c.cone(), tol),
95 (S::Cone(c), S::Plane(p)) => plane_cone(p.plane(), c.cone(), tol),
96 (S::Cylinder(x), S::Cone(c)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
97 (S::Cone(c), S::Cylinder(x)) => coaxial_cylinder_cone(x.cylinder(), c.cone(), tol),
98 (S::Cone(x), S::Cone(y)) => coaxial_cones(x.cone(), y.cone(), tol),
99 _ => ogeom_bail!(
100 NotDone,
101 "this pair of surfaces has no closed-form intersection; it needs \
102 the general marching intersector, which is gated on the benchmark \
103 these cases provide the ground truth for"
104 ),
105 }
106}
107
108fn window_reach(plane: &ogeom_geom::PlaneSurface) -> f64 {
112 let ((u0, u1), (v0, v1)) = ogeom_geom::Surface::domain(plane);
113 let reach = (u1 - u0).hypot(v1 - v0);
114 if reach.is_finite() && reach > 0.0 {
115 reach.min(1e9)
116 } else {
117 1e9
118 }
119}
120
121fn square_to_axis(along: f64, tol: Tolerances) -> bool {
132 (along.abs() - 1.0).abs() <= tol.angular()
133}
134
135fn plane_plane(a: ogeom_math::Plane, b: ogeom_math::Plane, reach: f64, tol: Tolerances) -> Meeting {
145 let turn = a.normal().angle(b.normal());
146 let turn = turn.min(core::f64::consts::PI - turn);
147 if turn <= tol.angular().max(tol.confusion() / reach) {
148 return if a.distance_to(b.origin()) <= tol.confusion() {
151 Meeting::Same
152 } else {
153 Meeting::Apart
154 };
155 }
156 let Ok(direction) = Direction::from_cross(a.normal().vector(), b.normal().vector(), tol) else {
159 return Meeting::Apart;
160 };
161 let (da, db) = (
162 a.normal().dot_vector(a.origin().to_vector()),
163 b.normal().dot_vector(b.origin().to_vector()),
164 );
165 let (na, nb) = (a.normal().vector(), b.normal().vector());
166 let dot = na.dot(nb);
167 let denominator = na.cross(nb).square_magnitude();
170 if denominator <= 0.0 {
171 return Meeting::Apart;
172 }
173 let ca = da.mul_add(1.0, -(db * dot)) / denominator;
174 let cb = db.mul_add(1.0, -(da * dot)) / denominator;
175 let through = Point::from_vector(na * ca + nb * cb);
176 Meeting::Along(vec![line_through(through, direction)])
177}
178
179fn plane_sphere(plane: ogeom_math::Plane, sphere: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
181 let gap = plane.signed_distance_to(sphere.centre());
182 let reach = gap.abs();
183 if reach > sphere.radius() + tol.confusion() {
184 return Meeting::Apart;
185 }
186 let foot = plane.project(sphere.centre());
187 if (reach - sphere.radius()).abs() <= tol.confusion() {
188 return Meeting::Touching(vec![foot]);
189 }
190 let radius = sphere
193 .radius()
194 .mul_add(sphere.radius(), -(gap * gap))
195 .max(0.0)
196 .sqrt();
197 match circle_on(foot, plane.normal(), radius, tol) {
198 Some(circle) => Meeting::Along(vec![circle]),
199 None => Meeting::Touching(vec![foot]),
200 }
201}
202
203fn plane_cylinder(
209 plane: ogeom_math::Plane,
210 cylinder: ogeom_math::Cylinder,
211 tol: Tolerances,
212) -> OgeomResult<Meeting> {
213 let axis = cylinder.axis();
214 let along = plane.normal().dot(axis.direction);
215
216 if along.abs() <= tol.angular() {
219 let gap = plane.signed_distance_to(axis.location);
220 let reach = gap.abs();
221 if reach > cylinder.radius() + tol.confusion() {
222 return Ok(Meeting::Apart);
223 }
224 let offset = cylinder
226 .radius()
227 .mul_add(cylinder.radius(), -(gap * gap))
228 .max(0.0)
229 .sqrt();
230 let foot = plane.project(axis.location);
231 let sideways =
232 Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
233 if offset <= tol.confusion() {
234 return Ok(Meeting::Along(vec![line_through(foot, axis.direction)]));
236 }
237 return Ok(Meeting::Along(vec![
238 line_through(foot + sideways.vector() * offset, axis.direction),
239 line_through(foot - sideways.vector() * offset, axis.direction),
240 ]));
241 }
242
243 let centre = intersect_axis_plane(axis, plane, tol)?;
245 if square_to_axis(along, tol) {
246 return Ok(
247 match circle_on(centre, plane.normal(), cylinder.radius(), tol) {
248 Some(circle) => Meeting::Along(vec![circle]),
249 None => Meeting::Apart,
250 },
251 );
252 }
253
254 let minor = cylinder.radius();
257 let major = minor / along.abs();
258 let minor_direction =
261 Direction::from_cross(plane.normal().vector(), axis.direction.vector(), tol)?;
262 let major_direction =
263 Direction::from_cross(minor_direction.vector(), plane.normal().vector(), tol)?;
264 let frame = Frame::from_axes(
265 centre,
266 major_direction,
267 minor_direction,
268 plane.normal(),
269 tol,
270 )?;
271 Ok(Meeting::Along(vec![
272 ogeom_geom::EllipseCurve::new(Ellipse::new(frame, major, minor, tol)?).into(),
273 ]))
274}
275
276fn sphere_sphere(a: ogeom_math::Sphere, b: ogeom_math::Sphere, tol: Tolerances) -> Meeting {
278 let between = b.centre() - a.centre();
279 let distance = between.magnitude();
280 if distance <= tol.confusion() {
281 return if (a.radius() - b.radius()).abs() <= tol.confusion() {
282 Meeting::Same
283 } else {
284 Meeting::Apart
286 };
287 }
288 let (ra, rb) = (a.radius(), b.radius());
289 if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
290 return Meeting::Apart;
291 }
292 let Ok(direction) = Direction::new(between, tol) else {
293 return Meeting::Apart;
294 };
295 let reach = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
297 let centre = a.centre() + direction.vector() * reach;
298 let squared = ra.mul_add(ra, -(reach * reach));
299 if squared <= tol.confusion() * tol.confusion() {
300 return Meeting::Touching(vec![centre]);
301 }
302 match circle_on(centre, direction, squared.max(0.0).sqrt(), tol) {
303 Some(circle) => Meeting::Along(vec![circle]),
304 None => Meeting::Touching(vec![centre]),
305 }
306}
307
308fn coaxial_cylinders(
314 a: ogeom_math::Cylinder,
315 b: ogeom_math::Cylinder,
316 tol: Tolerances,
317) -> OgeomResult<Meeting> {
318 if !a.axis().is_coaxial(b.axis(), tol) {
319 if (a.radius() - b.radius()).abs() <= tol.confusion() {
326 let (da, db) = (a.axis().direction.vector(), b.axis().direction.vector());
327 let normal = da.cross(db);
328 if normal.magnitude() > tol.angular() {
329 let (pa, pb) = (a.axis().location, b.axis().location);
330 let w = pb - pa;
333 let dd = da.dot(db);
334 let denom = dd.mul_add(-dd, 1.0);
335 let s = dd.mul_add(-db.dot(w), da.dot(w)) / denom;
336 let t = dd.mul_add(da.dot(w), -db.dot(w)) / denom;
337 let on_a = pa + da * s;
338 let on_b = pb + db * t;
339 if on_a.distance(on_b) <= tol.confusion() {
340 let centre = on_a;
341 let mut curves = Vec::new();
342 for m in [da - db, da + db] {
343 if m.magnitude() <= tol.angular() {
344 continue;
345 }
346 let plane =
347 ogeom_math::Plane::through(centre, ogeom_math::Direction::new(m, tol)?);
348 if let Meeting::Along(mut found) = plane_cylinder(plane, a, tol)? {
349 curves.append(&mut found);
350 }
351 }
352 if !curves.is_empty() {
353 return Ok(Meeting::Along(curves));
354 }
355 }
356 }
357 }
358 ogeom_bail!(
359 NotDone,
360 "two cylinders that do not share an axis meet in a quartic space \
361 curve, which needs the general marching intersector"
362 );
363 }
364 Ok(if (a.radius() - b.radius()).abs() <= tol.confusion() {
365 Meeting::Same
366 } else {
367 Meeting::Apart
369 })
370}
371
372fn coaxial_cylinder_sphere(
374 cylinder: ogeom_math::Cylinder,
375 sphere: ogeom_math::Sphere,
376 tol: Tolerances,
377) -> OgeomResult<Meeting> {
378 let axis = cylinder.axis();
379 if axis.distance_to(sphere.centre()) > tol.confusion() {
380 ogeom_bail!(
381 NotDone,
382 "a sphere off a cylinder's axis meets it in a quartic space curve, \
383 which needs the general marching intersector"
384 );
385 }
386 let (r, radius) = (cylinder.radius(), sphere.radius());
387 if r > radius + tol.confusion() {
388 return Ok(Meeting::Apart);
389 }
390 if (r - radius).abs() <= tol.confusion() {
391 let centre = sphere.centre();
394 return Ok(match circle_on(centre, axis.direction, r, tol) {
395 Some(circle) => Meeting::Along(vec![circle]),
396 None => Meeting::Apart,
397 });
398 }
399 let reach = radius.mul_add(radius, -(r * r)).max(0.0).sqrt();
401 let mut out = Vec::with_capacity(2);
402 for side in [reach, -reach] {
403 let centre = sphere.centre() + axis.direction.vector() * side;
404 if let Some(circle) = circle_on(centre, axis.direction, r, tol) {
405 out.push(circle);
406 }
407 }
408 Ok(if out.is_empty() {
409 Meeting::Apart
410 } else {
411 Meeting::Along(out)
412 })
413}
414
415fn axial_plane_torus(
429 plane: ogeom_math::Plane,
430 torus: ogeom_math::Torus,
431 tol: Tolerances,
432) -> OgeomResult<Meeting> {
433 let axis = torus.axis();
434 let along = plane.normal().dot(axis.direction);
435 if along.abs() <= tol.angular()
436 && plane.signed_distance_to(axis.location).abs() <= tol.confusion()
437 {
438 return Ok(meridians(plane, torus, tol));
439 }
440 if !square_to_axis(along, tol) {
441 ogeom_bail!(
442 NotDone,
443 "a plane oblique to a torus's axis, or parallel to it and off it, \
444 meets it in a quartic, which needs the general marching \
445 intersector"
446 );
447 }
448 let height = -plane.signed_distance_to(axis.location) * along.signum();
450 let minor = torus.minor_radius();
451 if height.abs() > minor + tol.confusion() {
452 return Ok(Meeting::Apart);
453 }
454 let centre = axis.location + axis.direction.vector() * height;
455 if (height.abs() - minor).abs() <= tol.confusion() {
456 return Ok(
458 match circle_on(centre, axis.direction, torus.major_radius(), tol) {
459 Some(circle) => Meeting::Along(vec![circle]),
460 None => Meeting::Apart,
461 },
462 );
463 }
464 let spread = minor.mul_add(minor, -(height * height)).max(0.0).sqrt();
467 let circles: Vec<Curve> = [torus.major_radius() + spread, torus.major_radius() - spread]
468 .into_iter()
469 .filter_map(|radius| circle_on(centre, axis.direction, radius, tol))
470 .collect();
471 Ok(if circles.is_empty() {
472 Meeting::Apart
473 } else {
474 Meeting::Along(circles)
475 })
476}
477
478fn meridians(plane: ogeom_math::Plane, torus: ogeom_math::Torus, tol: Tolerances) -> Meeting {
482 let axis = torus.axis();
483 let normal = plane.normal();
484 let Ok(out) = Direction::from_cross(axis.direction.vector(), normal.vector(), tol) else {
485 return Meeting::Apart;
486 };
487 let circles: Vec<Curve> = [out.vector(), -out.vector()]
488 .into_iter()
489 .filter_map(|radial| {
490 let centre = axis.location + radial * torus.major_radius();
491 let x = Direction::new(radial, tol).ok()?;
492 let frame = Frame::new(centre, normal, x, tol).ok()?;
493 let circle = Circle::new(frame, torus.minor_radius(), tol).ok()?;
494 Some(ogeom_geom::CircleCurve::new(circle).into())
495 })
496 .collect();
497 Meeting::Along(circles)
498}
499
500fn coaxial_cylinder_torus(
503 cylinder: ogeom_math::Cylinder,
504 torus: ogeom_math::Torus,
505 tol: Tolerances,
506) -> OgeomResult<Meeting> {
507 if !cylinder.axis().is_coaxial(torus.axis(), tol) {
508 ogeom_bail!(
509 NotDone,
510 "a cylinder off a torus's axis meets it in a quartic space curve, \
511 which needs the general marching intersector"
512 );
513 }
514 let axis = torus.axis();
515 let reach = (cylinder.radius() - torus.major_radius()).abs();
516 let minor = torus.minor_radius();
517 if reach > minor + tol.confusion() {
518 return Ok(Meeting::Apart);
519 }
520 if (reach - minor).abs() <= tol.confusion() {
521 return Ok(
523 match circle_on(axis.location, axis.direction, cylinder.radius(), tol) {
524 Some(circle) => Meeting::Along(vec![circle]),
525 None => Meeting::Apart,
526 },
527 );
528 }
529 let rise = minor.mul_add(minor, -(reach * reach)).max(0.0).sqrt();
530 let circles: Vec<Curve> = [rise, -rise]
531 .into_iter()
532 .filter_map(|height| {
533 circle_on(
534 axis.location + axis.direction.vector() * height,
535 axis.direction,
536 cylinder.radius(),
537 tol,
538 )
539 })
540 .collect();
541 Ok(if circles.is_empty() {
542 Meeting::Apart
543 } else {
544 Meeting::Along(circles)
545 })
546}
547
548fn plane_cone(
557 plane: ogeom_math::Plane,
558 cone: ogeom_math::Cone,
559 tol: Tolerances,
560) -> OgeomResult<Meeting> {
561 let axis = cone.axis();
562 let along = plane.normal().dot(axis.direction);
563 if !square_to_axis(along, tol) {
564 ogeom_bail!(
565 NotDone,
566 "a plane oblique to a cone's axis meets it in a conic, which \
567 needs the general marching intersector"
568 );
569 }
570 let height = -plane.signed_distance_to(axis.location) * along.signum();
572 let radius = cone.radius_at(height);
573 if radius.abs() <= tol.confusion() {
574 return Ok(Meeting::Touching(vec![cone.apex()]));
577 }
578 if radius < 0.0 {
579 ogeom_bail!(
583 NotDone,
584 "the plane crosses the cone past its apex, where the chart runs \
585 mirrored; that configuration needs the general machinery"
586 );
587 }
588 let centre = axis.location + axis.direction.vector() * height;
589 Ok(match cone_parallel(&cone, centre, radius, tol) {
590 Some(circle) => Meeting::Along(vec![circle]),
591 None => Meeting::Apart,
592 })
593}
594
595fn coaxial_cylinder_cone(
605 cylinder: ogeom_math::Cylinder,
606 cone: ogeom_math::Cone,
607 tol: Tolerances,
608) -> OgeomResult<Meeting> {
609 if !cylinder.axis().is_coaxial(cone.axis(), tol) {
610 ogeom_bail!(
611 NotDone,
612 "a cylinder off a cone's axis meets it in a curve only the \
613 general marching intersector can trace"
614 );
615 }
616 let axis = cone.axis();
617 let slope = cone.half_angle().tan();
618 let height = (cylinder.radius() - cone.reference_radius()) / slope;
619 Ok(
620 match cone_parallel(
621 &cone,
622 axis.location + axis.direction.vector() * height,
623 cylinder.radius(),
624 tol,
625 ) {
626 Some(circle) => Meeting::Along(vec![circle]),
627 None => Meeting::Apart,
628 },
629 )
630}
631
632fn coaxial_cones(
639 a: ogeom_math::Cone,
640 b: ogeom_math::Cone,
641 tol: Tolerances,
642) -> OgeomResult<Meeting> {
643 if !a.axis().is_coaxial(b.axis(), tol) {
644 ogeom_bail!(
645 NotDone,
646 "two cones that do not share an axis meet in a curve only the \
647 general marching intersector can trace"
648 );
649 }
650 let axis = a.axis();
651 let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
654 let (slope_a, slope_b) = (a.half_angle().tan(), b.half_angle().tan());
655 let (ref_a, ref_b) = (
656 a.reference_radius(),
657 slope_b.mul_add(-lift, b.reference_radius()),
658 );
659 if (slope_a - slope_b).abs() <= tol.angular() {
660 return Ok(if (ref_a - ref_b).abs() <= tol.confusion() {
662 Meeting::Same
663 } else {
664 Meeting::Apart
665 });
666 }
667 let height = (ref_b - ref_a) / (slope_a - slope_b);
671 let radius = a.radius_at(height);
672 if radius.abs() <= tol.confusion() {
673 return Ok(Meeting::Touching(vec![a.apex()]));
675 }
676 if radius < 0.0 {
677 ogeom_bail!(
678 NotDone,
679 "two coaxial cones that meet only past their apexes, where the \
680 charts run mirrored, need the general machinery"
681 );
682 }
683 Ok(
684 match cone_parallel(
685 &a,
686 axis.location + axis.direction.vector() * height,
687 radius,
688 tol,
689 ) {
690 Some(circle) => Meeting::Along(vec![circle]),
691 None => Meeting::Apart,
692 },
693 )
694}
695
696fn cone_parallel(
698 cone: &ogeom_math::Cone,
699 centre: Point,
700 radius: f64,
701 tol: Tolerances,
702) -> Option<Curve> {
703 if radius <= tol.confusion() {
704 return None;
705 }
706 let frame = cone.frame();
707 let placed = Frame::new(centre, frame.z(), frame.x(), tol).ok()?;
708 Some(ogeom_geom::CircleCurve::new(Circle::new(placed, radius, tol).ok()?).into())
709}
710
711fn coaxial_tori(
718 a: ogeom_math::Torus,
719 b: ogeom_math::Torus,
720 tol: Tolerances,
721) -> OgeomResult<Meeting> {
722 if !a.axis().is_coaxial(b.axis(), tol) {
723 ogeom_bail!(
724 NotDone,
725 "two tori that do not share an axis meet in a curve only the \
726 general marching intersector can trace"
727 );
728 }
729 let axis = a.axis();
730 let lift = (b.axis().location - a.axis().location).dot(axis.direction.vector());
731 if (a.major_radius() - b.major_radius()).abs() <= tol.confusion()
732 && lift.abs() <= tol.confusion()
733 && (a.minor_radius() - b.minor_radius()).abs() <= tol.confusion()
734 {
735 return Ok(Meeting::Same);
736 }
737 let (ca, cb) = (
740 ogeom_math::Point2::new(a.major_radius(), 0.0),
741 ogeom_math::Point2::new(b.major_radius(), lift),
742 );
743 let between = cb - ca;
744 let distance = between.magnitude();
745 let (ra, rb) = (a.minor_radius(), b.minor_radius());
746 if distance <= tol.confusion() {
747 return Ok(Meeting::Apart);
750 }
751 if distance > ra + rb + tol.confusion() || distance < (ra - rb).abs() - tol.confusion() {
752 return Ok(Meeting::Apart);
753 }
754 let along = distance.mul_add(distance, ra.mul_add(ra, -(rb * rb))) / (2.0 * distance);
755 let squared = ra.mul_add(ra, -(along * along));
756 let direction = between * (1.0 / distance);
757 let foot = ca + direction * along;
758 let mut profile_points = Vec::new();
759 if squared <= tol.confusion() * tol.confusion() {
760 profile_points.push(foot);
761 } else {
762 let offset = ogeom_math::Vector2::new(-direction.y, direction.x) * squared.max(0.0).sqrt();
763 profile_points.push(foot + offset);
764 profile_points.push(foot - offset);
765 }
766 let circles: Vec<Curve> = profile_points
767 .into_iter()
768 .filter_map(|p| {
769 circle_on(
770 axis.location + axis.direction.vector() * p.y,
771 axis.direction,
772 p.x,
773 tol,
774 )
775 })
776 .collect();
777 Ok(if circles.is_empty() {
778 Meeting::Apart
779 } else {
780 Meeting::Along(circles)
781 })
782}
783
784fn intersect_axis_plane(
786 axis: ogeom_math::Axis,
787 plane: ogeom_math::Plane,
788 tol: Tolerances,
789) -> OgeomResult<Point> {
790 let along = plane.normal().dot(axis.direction);
791 if along.abs() <= tol.angular() {
792 ogeom_bail!(Domain, "the axis runs along the plane and never crosses it");
793 }
794 let t = -plane.signed_distance_to(axis.location) / along;
795 Ok(axis.location + axis.direction.vector() * t)
796}
797
798fn circle_on(centre: Point, normal: Direction, radius: f64, tol: Tolerances) -> Option<Curve> {
800 if radius <= tol.confusion() {
801 return None;
802 }
803 let reference = if normal.vector().cross(Vector::X).magnitude() > 0.5 {
805 Vector::X
806 } else {
807 Vector::Y
808 };
809 let x = Direction::from_cross(normal.vector(), reference, tol).ok()?;
810 let frame = Frame::new(centre, normal, x, tol).ok()?;
811 Some(ogeom_geom::CircleCurve::new(Circle::new(frame, radius, tol).ok()?).into())
812}
813
814fn line_through(through: Point, direction: Direction) -> Curve {
816 ogeom_geom::LineCurve::new(ogeom_math::Axis::new(through, direction)).into()
817}