1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::{Curve, Curve3d, Surface, SurfaceGeometry};
27use ogeom_math::{Point, solve};
28
29use crate::march::{Cell, sample_by, segment_meets_triangle};
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Piercing {
34 pub on_curve: f64,
36 pub on_surface: (f64, f64),
38 pub point: Point,
40 pub gap: f64,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct CurveSurfaceIntersection {
47 pub crossings: Vec<Piercing>,
49 pub lying: Vec<(f64, f64)>,
55}
56
57impl CurveSurfaceIntersection {
58 #[must_use]
60 pub fn is_empty(&self) -> bool {
61 self.crossings.is_empty() && self.lying.is_empty()
62 }
63
64 const fn empty() -> Self {
65 Self {
66 crossings: Vec::new(),
67 lying: Vec::new(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct CurveSurfaceOptions {
75 pub samples: usize,
77 pub grid: usize,
79 pub gap: f64,
81}
82
83impl Default for CurveSurfaceOptions {
84 fn default() -> Self {
85 Self {
86 samples: 128,
87 grid: 24,
88 gap: 1e-7,
89 }
90 }
91}
92
93pub fn intersect_curve_surface(
104 curve: &Curve,
105 surface: &SurfaceGeometry,
106 options: CurveSurfaceOptions,
107 tol: Tolerances,
108) -> OgeomResult<CurveSurfaceIntersection> {
109 if options.samples < 2 || options.grid < 2 {
110 ogeom_bail!(Construction, "seeding needs at least two steps each way");
111 }
112 if !options.gap.is_finite() || options.gap <= 0.0 {
113 ogeom_bail!(Construction, "a gap of {} is not a distance", options.gap);
114 }
115
116 match (curve, surface) {
117 (Curve::Line(line), SurfaceGeometry::Plane(p)) => {
118 Ok(line_plane(line, p.plane(), curve, surface, tol))
119 }
120 (Curve::Line(line), SurfaceGeometry::Sphere(s)) => Ok(line_quadric(
121 line,
122 curve,
123 surface,
124 sphere_roots(line, s.sphere()),
125 options,
126 tol,
127 )),
128 (Curve::Line(line), SurfaceGeometry::Cylinder(c)) => Ok(line_quadric(
129 line,
130 curve,
131 surface,
132 cylinder_roots(line, c.cylinder()),
133 options,
134 tol,
135 )),
136 _ => general(curve, surface, options, tol),
137 }
138}
139
140fn line_plane(
143 line: &ogeom_geom::LineCurve,
144 plane: ogeom_math::Plane,
145 curve: &Curve,
146 surface: &SurfaceGeometry,
147 tol: Tolerances,
148) -> CurveSurfaceIntersection {
149 let axis = line.axis();
150 let along = plane.normal().dot(axis.direction);
151 let height = plane.signed_distance_to(axis.location);
152
153 if along.abs() <= tol.angular() {
154 if height.abs() <= tol.confusion() {
156 return CurveSurfaceIntersection {
157 crossings: Vec::new(),
158 lying: vec![line.domain()],
159 };
160 }
161 return CurveSurfaceIntersection::empty();
162 }
163
164 let t = -height / along;
165 let (lo, hi) = line.domain();
166 if t < lo - tol.parametric() || t > hi + tol.parametric() {
167 return CurveSurfaceIntersection::empty();
168 }
169 let point = axis.location + axis.direction.vector() * t;
170 let Some(found) = invert(surface, point, curve, t, tol) else {
171 return CurveSurfaceIntersection::empty();
172 };
173 if found.gap > tol.confusion() {
174 return CurveSurfaceIntersection::empty();
177 }
178 CurveSurfaceIntersection {
179 crossings: vec![found],
180 lying: Vec::new(),
181 }
182}
183
184fn sphere_roots(line: &ogeom_geom::LineCurve, sphere: ogeom_math::Sphere) -> Vec<f64> {
186 let axis = line.axis();
187 let d = axis.direction.vector();
188 let m = axis.location - sphere.centre();
189 let b = m.dot(d);
191 let c = sphere.radius().mul_add(-sphere.radius(), m.dot(m));
192 let discriminant = b.mul_add(b, -c);
193 if discriminant < 0.0 {
194 return Vec::new();
195 }
196 let root = discriminant.sqrt();
197 if root == 0.0 {
198 vec![-b]
199 } else {
200 vec![-b - root, -b + root]
201 }
202}
203
204fn cylinder_roots(line: &ogeom_geom::LineCurve, cylinder: ogeom_math::Cylinder) -> Vec<f64> {
206 let axis = line.axis();
207 let w = cylinder.axis().direction.vector();
208 let d = axis.direction.vector();
211 let m = axis.location - cylinder.axis().location;
212 let d_perp = d - w * d.dot(w);
213 let m_perp = m - w * m.dot(w);
214 let a = d_perp.dot(d_perp);
215 if a <= f64::MIN_POSITIVE {
216 return Vec::new();
219 }
220 let b = d_perp.dot(m_perp);
221 let c = cylinder
222 .radius()
223 .mul_add(-cylinder.radius(), m_perp.dot(m_perp));
224 let discriminant = b.mul_add(b, -(a * c));
225 if discriminant < 0.0 {
226 return Vec::new();
227 }
228 let root = discriminant.sqrt();
229 if root == 0.0 {
230 vec![-b / a]
231 } else {
232 vec![(-b - root) / a, (-b + root) / a]
233 }
234}
235
236fn line_quadric(
239 line: &ogeom_geom::LineCurve,
240 curve: &Curve,
241 surface: &SurfaceGeometry,
242 roots: Vec<f64>,
243 options: CurveSurfaceOptions,
244 tol: Tolerances,
245) -> CurveSurfaceIntersection {
246 let axis = line.axis();
247 let (lo, hi) = line.domain();
248 let mut crossings = Vec::new();
249 for t in roots {
250 if t < lo - tol.parametric() || t > hi + tol.parametric() {
251 continue;
252 }
253 let point = axis.location + axis.direction.vector() * t;
254 let Some(found) = invert(surface, point, curve, t, tol) else {
255 continue;
256 };
257 if found.gap > tol.confusion() {
264 continue;
265 }
266 let _ = options;
267 crossings.push(Piercing {
268 on_curve: t,
269 on_surface: found.on_surface,
270 point,
271 gap: found.gap,
272 });
273 }
274 crossings.sort_by(|a, b| {
275 a.on_curve
276 .partial_cmp(&b.on_curve)
277 .unwrap_or(core::cmp::Ordering::Equal)
278 });
279 CurveSurfaceIntersection {
280 crossings,
281 lying: Vec::new(),
282 }
283}
284
285fn invert(
290 surface: &SurfaceGeometry,
291 point: Point,
292 curve: &Curve,
293 on_curve: f64,
294 tol: Tolerances,
295) -> Option<Piercing> {
296 let guess = match surface {
297 SurfaceGeometry::Plane(p) => {
298 let local = p.plane().frame().to_local(point);
299 (local.x, local.y)
300 }
301 SurfaceGeometry::Sphere(s) => {
302 let local = s.sphere().frame().to_local(point);
303 let latitude = (local.z / s.sphere().radius()).clamp(-1.0, 1.0).asin();
304 (
305 local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU),
306 latitude,
307 )
308 }
309 SurfaceGeometry::Cylinder(c) => {
310 let local = c.cylinder().frame().to_local(point);
311 (
312 local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU),
313 local.z,
314 )
315 }
316 _ => return None,
317 };
318 polish(curve, surface, on_curve, guess, tol)
323}
324
325fn general(
328 curve: &Curve,
329 surface: &SurfaceGeometry,
330 options: CurveSurfaceOptions,
331 tol: Tolerances,
332) -> OgeomResult<CurveSurfaceIntersection> {
333 let cells = sample_by(surface, seeding(surface, options.grid), tol);
334 let (lo, hi) = curve.domain();
335
336 let mut points = Vec::with_capacity(options.samples + 1);
337 for i in 0..=options.samples {
338 #[allow(clippy::cast_precision_loss)]
339 let t = lo + (hi - lo) * i as f64 / options.samples as f64;
340 if let Ok(p) = curve.point_at(t, tol) {
341 points.push((t, p));
342 }
343 }
344
345 let mut crossings: Vec<Piercing> = Vec::new();
346 for pair in points.windows(2) {
347 let (t0, p0) = pair[0];
348 let (t1, p1) = pair[1];
349 for cell in &cells {
350 if !segment_near_cell(p0, p1, cell, options.gap.max(cell.sag)) {
356 continue;
357 }
358 if segment_meets_triangle(p0, p1, cell.corners).is_none()
359 && !(cell.sag > options.gap && segment_near_cell(p0, p1, cell, cell.sag))
360 {
361 continue;
362 }
363 let (near_t, near_uv) = seed_in(cell, p0, p1, t0, t1);
369 let Some(found) = [(near_t, near_uv), (f64::midpoint(t0, t1), cell.at)]
370 .into_iter()
371 .filter_map(|(t, uv)| polish(curve, surface, t, uv, tol))
372 .find(|found| found.gap <= options.gap)
373 else {
374 continue;
375 };
376 let reach = tol.confusion() * 100.0;
377 if !crossings
378 .iter()
379 .any(|c| c.point.distance(found.point) <= reach)
380 {
381 crossings.push(found);
382 }
383 }
384 }
385 crossings.sort_by(|a, b| {
386 a.on_curve
387 .partial_cmp(&b.on_curve)
388 .unwrap_or(core::cmp::Ordering::Equal)
389 });
390 Ok(CurveSurfaceIntersection {
391 crossings,
392 lying: Vec::new(),
393 })
394}
395
396fn seeding(surface: &SurfaceGeometry, grid: usize) -> (usize, usize) {
403 const CAP: usize = 1024;
404 let SurfaceGeometry::BSpline(spline) = surface else {
405 return (grid, grid);
406 };
407 let spans = |knots: &ogeom_math::KnotVector| knots.distinct().len().saturating_sub(1);
408 (
409 grid.max(2 * spans(spline.u_knots())).min(CAP.max(grid)),
410 grid.max(2 * spans(spline.v_knots())).min(CAP.max(grid)),
411 )
412}
413
414fn seed_in(cell: &Cell, p0: Point, p1: Point, t0: f64, t1: f64) -> (f64, (f64, f64)) {
419 let [a, b, c] = cell.corners;
420 let (t, at) = segment_meets_triangle(p0, p1, cell.corners).map_or_else(
421 || (f64::midpoint(t0, t1), p0.midpoint(p1)),
422 |x| {
423 let length = p0.distance(p1);
424 let f = if length > 0.0 {
425 p0.distance(x) / length
426 } else {
427 0.5
428 };
429 (t0 + (t1 - t0) * f, x)
430 },
431 );
432 let (e1, e2, d) = (b - a, c - a, at - a);
435 let (d11, d12, d22) = (e1.dot(e1), e1.dot(e2), e2.dot(e2));
436 let (d1, d2) = (d.dot(e1), d.dot(e2));
437 let det = d11 * d22 - d12 * d12;
438 let (mut wb, mut wc) = if det > 0.0 {
439 ((d22 * d1 - d12 * d2) / det, (d11 * d2 - d12 * d1) / det)
440 } else {
441 (1.0 / 3.0, 1.0 / 3.0)
442 };
443 wb = wb.clamp(0.0, 1.0);
444 wc = wc.clamp(0.0, 1.0);
445 if wb + wc > 1.0 {
446 let sum = wb + wc;
447 wb /= sum;
448 wc /= sum;
449 }
450 let wa = 1.0 - wb - wc;
451 let [pa, pb, pc] = cell.params;
452 (
453 t,
454 (
455 wa * pa.0 + wb * pb.0 + wc * pc.0,
456 wa * pa.1 + wb * pb.1 + wc * pc.1,
457 ),
458 )
459}
460
461fn segment_near_cell(a: Point, b: Point, cell: &Cell, margin: f64) -> bool {
463 let low = Point::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z));
464 let high = Point::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z));
465 low.x <= cell.high.x + margin
466 && cell.low.x <= high.x + margin
467 && low.y <= cell.high.y + margin
468 && cell.low.y <= high.y + margin
469 && low.z <= cell.high.z + margin
470 && cell.low.z <= high.z + margin
471}
472
473fn polish(
475 curve: &Curve,
476 surface: &SurfaceGeometry,
477 seed_t: f64,
478 seed_uv: (f64, f64),
479 tol: Tolerances,
480) -> Option<Piercing> {
481 let clamp_t = |t: f64| {
482 let (lo, hi) = curve.domain();
483 if curve.is_periodic() {
484 let span = hi - lo;
485 if span > 0.0 {
486 return lo + (t - lo).rem_euclid(span);
487 }
488 }
489 t.clamp(lo, hi)
490 };
491 let clamp_uv = |u: f64, v: f64| {
492 let ((ua, ub), (va, vb)) = surface.domain();
493 let fold = |x: f64, lo: f64, hi: f64, periodic: bool| {
494 if periodic {
495 let span = hi - lo;
496 if span > 0.0 {
497 return lo + (x - lo).rem_euclid(span);
498 }
499 }
500 x.clamp(lo, hi)
501 };
502 (
503 fold(u, ua, ub, surface.is_periodic_u()),
504 fold(v, va, vb, surface.is_periodic_v()),
505 )
506 };
507
508 let system = |x: &[f64]| {
509 let t = clamp_t(x[0]);
510 let (u, v) = clamp_uv(x[1], x[2]);
511 let pc = curve.point_at(t, tol).unwrap_or(Point::ORIGIN);
512 let ps = surface.point_at(u, v, tol).unwrap_or(Point::ORIGIN);
513 let dc = curve.d1_at(t, tol).unwrap_or(ogeom_math::Vector::ZERO);
514 let (du, dv) = surface
515 .d1_at(u, v, tol)
516 .unwrap_or((ogeom_math::Vector::ZERO, ogeom_math::Vector::ZERO));
517 let gap = pc - ps;
518 (
519 vec![gap.x, gap.y, gap.z],
520 vec![
521 vec![dc.x, -du.x, -dv.x],
522 vec![dc.y, -du.y, -dv.y],
523 vec![dc.z, -du.z, -dv.z],
524 ],
525 )
526 };
527 let criteria = solve::Criteria {
528 residual: tol.confusion() * 0.01,
529 step: tol.parametric(),
530 max_iterations: 40,
531 };
532 let found = solve::newton_system(system, &[seed_t, seed_uv.0, seed_uv.1], criteria).ok()?;
533 let t = clamp_t(found.value[0]);
534 let (u, v) = clamp_uv(found.value[1], found.value[2]);
535 let pc = curve.point_at(t, tol).ok()?;
536 let ps = surface.point_at(u, v, tol).ok()?;
537 Some(Piercing {
538 on_curve: t,
539 on_surface: (u, v),
540 point: pc,
541 gap: pc.distance(ps),
542 })
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used)]
547mod tests {
548 use super::*;
549 use ogeom_geom::{
550 BSplineCurve, CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface,
551 };
552 use ogeom_math::{Circle, Cylinder, Direction, Frame, KnotVector, Plane, Sphere, Vector};
553
554 const T: Tolerances = Tolerances::millimetres();
555
556 fn sphere(radius: f64) -> SurfaceGeometry {
557 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
558 }
559
560 fn cylinder(radius: f64, height: (f64, f64)) -> SurfaceGeometry {
561 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), height)
562 .unwrap()
563 .into()
564 }
565
566 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
567 PlaneSurface::over(
568 Plane::through(origin, Direction::new(normal, T).unwrap()),
569 (-6.0, 6.0),
570 (-6.0, 6.0),
571 )
572 .unwrap()
573 .into()
574 }
575
576 fn segment(from: Point, to: Point) -> Curve {
577 LineCurve::segment(from, to, T).unwrap().into()
578 }
579
580 #[test]
581 fn a_line_through_a_sphere_pierces_it_where_the_quadratic_says() {
582 let ball = sphere(2.0);
583 let ray = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
584 let found =
585 intersect_curve_surface(&ray, &ball, CurveSurfaceOptions::default(), T).unwrap();
586 assert_eq!(found.crossings.len(), 2);
587 assert!(
588 found.crossings[0]
589 .point
590 .is_equal(Point::new(-2.0, 0.0, 0.0), T)
591 );
592 assert!(
593 found.crossings[1]
594 .point
595 .is_equal(Point::new(2.0, 0.0, 0.0), T)
596 );
597 for hit in &found.crossings {
598 assert!(hit.gap < 1e-12);
599 let lifted = ball
601 .point_at(hit.on_surface.0, hit.on_surface.1, T)
602 .unwrap();
603 assert!(lifted.is_equal(hit.point, T));
604 }
605
606 let grazing = segment(Point::new(-5.0, 0.0, 2.0), Point::new(5.0, 0.0, 2.0));
608 assert_eq!(
609 intersect_curve_surface(&grazing, &ball, CurveSurfaceOptions::default(), T)
610 .unwrap()
611 .crossings
612 .len(),
613 1
614 );
615 let missing = segment(Point::new(-5.0, 0.0, 3.0), Point::new(5.0, 0.0, 3.0));
616 assert!(
617 intersect_curve_surface(&missing, &ball, CurveSurfaceOptions::default(), T)
618 .unwrap()
619 .is_empty()
620 );
621 }
622
623 #[test]
624 fn a_line_through_a_cylinder_respects_its_height() {
625 let drum = cylinder(2.0, (-1.0, 1.0));
626 let level = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
628 assert_eq!(
629 intersect_curve_surface(&level, &drum, CurveSurfaceOptions::default(), T)
630 .unwrap()
631 .crossings
632 .len(),
633 2
634 );
635 let high = segment(Point::new(-5.0, 0.0, 3.0), Point::new(5.0, 0.0, 3.0));
638 assert!(
639 intersect_curve_surface(&high, &drum, CurveSurfaceOptions::default(), T)
640 .unwrap()
641 .is_empty()
642 );
643 }
644
645 #[test]
646 fn a_line_lying_in_a_plane_is_an_overlap_not_a_crossing_list() {
647 let ground = plane(Point::ORIGIN, Vector::Z);
648 let lying = segment(Point::new(-3.0, 1.0, 0.0), Point::new(3.0, 1.0, 0.0));
649 let found =
650 intersect_curve_surface(&lying, &ground, CurveSurfaceOptions::default(), T).unwrap();
651 assert!(found.crossings.is_empty());
652 assert_eq!(found.lying.len(), 1);
653
654 let crossing = segment(Point::new(0.0, 0.0, -1.0), Point::new(0.0, 0.0, 1.0));
655 let found =
656 intersect_curve_surface(&crossing, &ground, CurveSurfaceOptions::default(), T).unwrap();
657 assert_eq!(found.crossings.len(), 1);
658 assert!(found.crossings[0].point.is_equal(Point::ORIGIN, T));
659
660 let parallel = segment(Point::new(-3.0, 0.0, 1.0), Point::new(3.0, 0.0, 1.0));
661 assert!(
662 intersect_curve_surface(¶llel, &ground, CurveSurfaceOptions::default(), T)
663 .unwrap()
664 .is_empty()
665 );
666 }
667
668 #[test]
669 fn a_circle_pierces_a_plane_twice_through_the_general_path() {
670 let ring: Curve = CircleCurve::new(
674 Circle::new(
675 Frame::new(Point::new(0.0, 0.0, 0.0), -Direction::Y, Direction::X, T).unwrap(),
676 2.0,
677 T,
678 )
679 .unwrap(),
680 )
681 .into();
682 let ground = plane(Point::ORIGIN, Vector::Z);
683 let found =
684 intersect_curve_surface(&ring, &ground, CurveSurfaceOptions::default(), T).unwrap();
685 assert_eq!(found.crossings.len(), 2);
686 for hit in &found.crossings {
687 assert!(hit.gap < 1e-9);
688 assert!(hit.point.z.abs() < 1e-9);
689 assert!((hit.point.to_vector().magnitude() - 2.0).abs() < 1e-9);
690 }
691 }
692
693 #[test]
694 fn a_spline_through_a_sphere_is_found_and_polished() {
695 let wander: Curve = BSplineCurve::new(
699 KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
700 vec![
701 Point::new(-4.0, -1.0, -1.0),
702 Point::new(-1.0, 2.0, 1.0),
703 Point::new(1.0, -2.0, -1.0),
704 Point::new(4.0, 1.0, 1.0),
705 ],
706 T,
707 )
708 .unwrap()
709 .into();
710 let ball = sphere(2.0);
711 let found =
712 intersect_curve_surface(&wander, &ball, CurveSurfaceOptions::default(), T).unwrap();
713 assert!(!found.crossings.is_empty(), "the spline passes through");
714 for hit in &found.crossings {
715 assert!(hit.gap < 1e-9);
716 let SurfaceGeometry::Sphere(s) = &ball else {
717 unreachable!()
718 };
719 assert!(s.sphere().distance_to(hit.point).abs() < 1e-9);
720 }
721 }
722
723 #[test]
724 fn unusable_options_are_refused() {
725 let ball = sphere(1.0);
726 let ray = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
727 for options in [
728 CurveSurfaceOptions {
729 samples: 1,
730 ..CurveSurfaceOptions::default()
731 },
732 CurveSurfaceOptions {
733 grid: 1,
734 ..CurveSurfaceOptions::default()
735 },
736 CurveSurfaceOptions {
737 gap: 0.0,
738 ..CurveSurfaceOptions::default()
739 },
740 ] {
741 assert!(intersect_curve_surface(&ray, &ball, options, T).is_err());
742 }
743 }
744}