1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
28use ogeom_geom::{
29 Circle2d, Curve, Curve2d as _, Curve3d, Ellipse2d, Line2d, PlanarCurve, Surface,
30 SurfaceGeometry,
31};
32use ogeom_math::{Circle2, Ellipse2, Frame2, Point, Point2};
33
34use crate::approx::approximate_branch;
35use crate::march::{Marching, branches, trace_tangential};
36use crate::surface::{Meeting, surface_surface};
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct IntersectOptions {
41 pub tolerance: f64,
43 pub marching: Marching,
45}
46
47impl Default for IntersectOptions {
48 fn default() -> Self {
49 Self {
50 tolerance: 1e-6,
51 marching: Marching::default(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct SectionCurve {
59 pub curve: Curve,
61 pub on_a: Option<PlanarCurve>,
68 pub on_b: Option<PlanarCurve>,
70 pub tolerance: f64,
75 pub exact: bool,
77 pub closed: bool,
79 pub tangential: bool,
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub enum SurfaceIntersection {
94 Apart,
101 Touching(Vec<Point>),
103 Along(Vec<SectionCurve>),
105 Same,
107}
108
109pub fn intersect_surfaces(
123 a: &SurfaceGeometry,
124 b: &SurfaceGeometry,
125 options: IntersectOptions,
126 tol: Tolerances,
127) -> OgeomResult<SurfaceIntersection> {
128 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
129 ogeom_bail!(
130 Construction,
131 "a tolerance of {} is not a distance",
132 options.tolerance
133 );
134 }
135
136 match surface_surface(a, b, tol) {
137 Ok(Meeting::Apart) => Ok(SurfaceIntersection::Apart),
138 Ok(Meeting::Same) => Ok(SurfaceIntersection::Same),
139 Ok(Meeting::Touching(points)) => Ok(SurfaceIntersection::Touching(points)),
140 Ok(Meeting::Along(curves)) => {
141 let sections: Vec<SectionCurve> = curves
142 .into_iter()
143 .filter_map(|curve| exact_section(curve, a, b, tol))
144 .collect();
145 Ok(if sections.is_empty() {
146 SurfaceIntersection::Apart
149 } else {
150 SurfaceIntersection::Along(sections)
151 })
152 }
153 Err(_) => marched(a, b, options, tol),
155 }
156}
157
158fn exact_section(
173 curve: Curve,
174 a: &SurfaceGeometry,
175 b: &SurfaceGeometry,
176 tol: Tolerances,
177) -> Option<SectionCurve> {
178 let closed = match &curve {
179 Curve::Circle(_) | Curve::Ellipse(_) => true,
180 _ => curve.is_closed(tol),
181 };
182 let range = curve.domain();
183 let on_a = exact_pcurve(&curve, range, a, tol);
184 let on_b = exact_pcurve(&curve, range, b, tol);
185
186 if let Curve::Line(_) = &curve {
187 let mut interval = curve.domain();
190 if let Some(p) = &on_a {
191 interval = intersect_intervals(interval, inside_box(p, a))?;
192 }
193 if let Some(p) = &on_b {
194 interval = intersect_intervals(interval, inside_box(p, b))?;
195 }
196 let (lo, hi) = interval;
197 let Curve::Line(line) = &curve else {
198 unreachable!()
199 };
200 let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
201 .ok()?
202 .into();
203 let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
204 let PlanarCurve::Line(l) = p else {
205 return Some(p.clone());
206 };
207 Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
208 };
209 let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
210 let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
211 return Some(SectionCurve {
212 on_a: ca,
213 on_b: cb,
214 tolerance: 0.0,
215 exact: true,
216 closed: false,
217 tangential,
218 curve: clipped,
219 });
220 }
221
222 for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
225 if let Some(p) = pcurve
226 && !touches_box(p, surface, tol)
227 {
228 return None;
229 }
230 }
231 let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
232 Some(SectionCurve {
233 on_a,
234 on_b,
235 tolerance: 0.0,
236 exact: true,
237 closed,
238 tangential,
239 curve,
240 })
241}
242
243fn touching_along(
252 curve: &Curve,
253 on_a: Option<&PlanarCurve>,
254 on_b: Option<&PlanarCurve>,
255 a: &SurfaceGeometry,
256 b: &SurfaceGeometry,
257 tol: Tolerances,
258) -> bool {
259 let sample_uv = |pc: Option<&PlanarCurve>,
267 surface: &SurfaceGeometry,
268 t: f64|
269 -> Option<ogeom_math::Point2> {
270 if let Some(pc) = pc {
271 return pc.point_at(t, tol).ok();
272 }
273 let p = curve.point_at(t, tol).ok()?;
274 chart_inversion(surface, p, tol)
275 };
276 let (lo, hi) = curve.domain();
277 let mut judged = 0_usize;
283 for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
284 let t = (hi - lo).mul_add(f, lo);
285 let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
286 continue;
287 };
288 let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
289 continue;
290 };
291 if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
292 return false;
293 }
294 judged += 1;
295 }
296 judged >= 3
297}
298
299fn chart_inversion(
301 surface: &SurfaceGeometry,
302 p: ogeom_math::Point,
303 tol: Tolerances,
304) -> Option<ogeom_math::Point2> {
305 use ogeom_math::elementary;
306 let (u, v) = match surface {
307 SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
308 SurfaceGeometry::Cylinder(s) => {
309 elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
310 }
311 SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
312 SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
313 SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
314 _ => return None,
315 };
316 Some(ogeom_math::Point2::new(u, v))
317}
318
319fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
322 let PlanarCurve::Line(line) = pcurve else {
323 return None;
324 };
325 let ((ua, ub), (va, vb)) = surface.domain();
326 let axis = line.axis();
327 let (o, d) = (axis.location, axis.direction.vector());
328
329 let mut lo = f64::NEG_INFINITY;
331 let mut hi = f64::INFINITY;
332 for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
333 if direction.abs() <= f64::MIN_POSITIVE {
334 if origin < low || origin > high {
335 return None;
336 }
337 continue;
338 }
339 let (a, b) = ((low - origin) / direction, (high - origin) / direction);
340 let (near, far) = if a < b { (a, b) } else { (b, a) };
341 lo = lo.max(near);
342 hi = hi.min(far);
343 }
344 if lo >= hi {
345 return None;
346 }
347 Some((lo, hi))
348}
349
350fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
352 use ogeom_geom::Curve2d;
353 let ((ua, ub), (va, vb)) = surface.domain();
354 let (lo, hi) = pcurve.domain();
355 (0..=16).any(|i| {
356 let t = lo + (hi - lo) * f64::from(i) / 16.0;
357 pcurve.point_at(t, tol).is_ok_and(|p| {
358 let u_ok = surface.is_periodic_u() || (p.x >= ua && p.x <= ub);
360 let v_ok = surface.is_periodic_v() || (p.y >= va && p.y <= vb);
361 u_ok && v_ok
362 })
363 })
364}
365
366fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
368 let b = b?;
369 let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
370 if lo >= hi {
371 return None;
372 }
373 Some((lo, hi))
374}
375
376fn marched(
378 a: &SurfaceGeometry,
379 b: &SurfaceGeometry,
380 options: IntersectOptions,
381 tol: Tolerances,
382) -> OgeomResult<SurfaceIntersection> {
383 let traced = branches(a, b, options.marching, tol)?;
384 if traced.is_empty() {
385 return Ok(SurfaceIntersection::Apart);
386 }
387 let mut out = Vec::with_capacity(traced.len());
388 let mut contacts: Vec<crate::march::Traced> = Vec::new();
389 for branch in &traced {
390 if branch_is_tangential(a, b, branch, tol)? {
399 if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
400 contacts.push(contact);
401 }
402 continue;
403 }
404 if branch.stopped == crate::march::Stopped::RanOut {
405 ogeom_bail!(
406 NotDone,
407 "a marched section ran out of its point budget before \
408 finishing; the seam is longer than the chord affords and \
409 fitting the truncation would state a curve that is not there"
410 );
411 }
412 let fitted = approximate_branch(a, b, branch, options.tolerance, tol)?;
421 out.push(SectionCurve {
422 curve: fitted.curve.into(),
423 on_a: Some(fitted.on_a.into()),
424 on_b: Some(fitted.on_b.into()),
425 tolerance: options.marching.chord + fitted.fit_error,
428 exact: false,
429 closed: fitted.closed,
430 tangential: false,
431 });
432 }
433 for contact in &contacts {
434 let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
435 out.push(SectionCurve {
436 curve: fitted.curve.into(),
437 on_a: Some(fitted.on_a.into()),
438 on_b: Some(fitted.on_b.into()),
439 tolerance: options.marching.chord + fitted.fit_error,
440 exact: false,
441 closed: fitted.closed,
442 tangential: true,
443 });
444 }
445 if out.is_empty() {
446 return Ok(SurfaceIntersection::Apart);
447 }
448 Ok(SurfaceIntersection::Along(out))
449}
450
451fn walk_contact(
460 a: &SurfaceGeometry,
461 b: &SurfaceGeometry,
462 fragment: &crate::march::Traced,
463 already: &[crate::march::Traced],
464 marching: Marching,
465 tol: Tolerances,
466) -> OgeomResult<Option<crate::march::Traced>> {
467 let middle = fragment.points.len() / 2;
468 let Some(point) = fragment.points.get(middle).copied() else {
469 return Ok(None);
470 };
471 for traced in already {
472 let spacing = traced
475 .points
476 .windows(2)
477 .map(|w| w[0].distance(w[1]))
478 .fold(0.0f64, f64::max);
479 let near = traced
480 .points
481 .iter()
482 .map(|p| p.distance(point))
483 .fold(f64::INFINITY, f64::min);
484 if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
485 return Ok(None);
486 }
487 }
488 let seed = crate::march::Contact {
489 point,
490 on_a: fragment.on_a[middle],
491 on_b: fragment.on_b[middle],
492 };
493 Ok(trace_tangential(a, b, seed, marching, tol)
498 .ok()
499 .filter(|traced| traced.points.len() >= 4))
500}
501
502fn branch_is_tangential(
505 a: &SurfaceGeometry,
506 b: &SurfaceGeometry,
507 branch: &crate::march::Traced,
508 tol: Tolerances,
509) -> OgeomResult<bool> {
510 use ogeom_geom::Surface as _;
511 let count = branch.points.len();
512 if count == 0 {
513 return Ok(true);
514 }
515 for k in 0..5 {
516 let i = (k * (count - 1)) / 4;
517 let (ua, va) = branch.on_a[i.min(count - 1)];
518 let (ub, vb) = branch.on_b[i.min(count - 1)];
519 let (dau, dav) = a.d1_at(ua, va, tol)?;
520 let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
521 let na = dau.cross(dav);
522 let nb = dbu.cross(dbv);
523 let (ma, mb) = (na.magnitude(), nb.magnitude());
524 if ma <= tol.confusion() || mb <= tol.confusion() {
525 continue;
526 }
527 if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
534 return Ok(false);
535 }
536 }
537 Ok(true)
538}
539
540#[must_use]
548pub fn exact_pcurve_of(
549 curve: &Curve,
550 surface: &SurfaceGeometry,
551 tol: Tolerances,
552) -> Option<PlanarCurve> {
553 exact_pcurve(curve, curve.domain(), surface, tol)
554}
555
556#[must_use]
565pub fn exact_pcurve_over(
566 curve: &Curve,
567 range: (f64, f64),
568 surface: &SurfaceGeometry,
569 tol: Tolerances,
570) -> Option<PlanarCurve> {
571 exact_pcurve(curve, range, surface, tol)
572}
573
574fn exact_pcurve(
584 curve: &Curve,
585 range: (f64, f64),
586 surface: &SurfaceGeometry,
587 tol: Tolerances,
588) -> Option<PlanarCurve> {
589 if let Curve::Trimmed(trimmed) = curve
596 && !trimmed.is_reversed()
597 {
598 let window = ogeom_geom::Curve3d::domain(&**trimmed);
599 let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
600 return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
601 .ok()
602 .map(Into::into);
603 }
604 match surface {
605 SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
606 SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
607 SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
608 SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
609 SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
610 _ => None,
611 }
612}
613
614fn on_cone(
623 curve: &Curve,
624 range: (f64, f64),
625 cone: ogeom_math::Cone,
626 tol: Tolerances,
627) -> Option<PlanarCurve> {
628 let frame = cone.frame();
629 let axis_z = frame.z().vector();
630 let tau = core::f64::consts::TAU;
631 match curve {
632 Curve::Circle(c) => {
633 let circle = c.circle();
634 if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
635 return None;
636 }
637 let local = frame.to_local(circle.centre());
638 if local.x.hypot(local.y) > tol.confusion() {
639 return None;
640 }
641 let expected = cone
643 .half_angle()
644 .tan()
645 .mul_add(local.z, cone.reference_radius());
646 if (expected - circle.radius()).abs() > tol.confusion() * 10.0 {
647 return None;
648 }
649 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
650 let at = frame.to_local(start);
651 let phase = at.y.atan2(at.x);
652 let winding = circle.frame().z().vector().dot(axis_z).signum();
653 let towards =
654 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
655 Some(
656 Line2d::over(
657 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
658 0.0,
659 tau,
660 )
661 .ok()?
662 .into(),
663 )
664 }
665 Curve::Line(line) => {
666 let axis = line.axis();
669 let on = |t: f64| {
670 let p = axis.location + axis.direction.vector() * t;
671 cone.distance_to(p) <= tol.confusion() * 10.0
672 };
673 if !on(0.0) || !on(1.0) || !on(-1.0) {
674 return None;
675 }
676 let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
683 range
684 } else {
685 line.domain()
686 };
687 let mut local: Option<ogeom_math::Point> = None;
693 for t in [lo, hi] {
694 if !t.is_finite() {
695 continue;
696 }
697 let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
698 if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
699 local = Some(candidate);
700 }
701 }
702 let local = local?;
703 if local.x.hypot(local.y) <= tol.confusion() {
704 return None;
705 }
706 let u = local.y.atan2(local.x).rem_euclid(tau);
707 let v_at = |t: f64| {
711 frame
712 .to_local(axis.location + axis.direction.vector() * t)
713 .z
714 };
715 let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
716 Some(
717 ogeom_geom::BSpline2d::new(
718 knots,
719 vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
720 tol,
721 )
722 .ok()?
723 .into(),
724 )
725 }
726 _ => None,
727 }
728}
729
730fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
740 let Curve::Circle(c) = curve else {
741 return None;
742 };
743 let circle = c.circle();
744 let frame = torus.frame();
745 let axis_z = frame.z().vector();
746 let normal = circle.frame().z().vector();
747 let local = frame.to_local(circle.centre());
748 let tau = core::f64::consts::TAU;
749
750 if normal.cross(axis_z).magnitude() <= tol.angular()
752 && local.x.hypot(local.y) <= tol.confusion()
753 {
754 let sin_v = local.z / torus.minor_radius();
755 let cos_v = (circle.radius() - torus.major_radius()) / torus.minor_radius();
756 if (sin_v.hypot(cos_v) - 1.0).abs() > tol.confusion() {
757 return None;
758 }
759 let v = sin_v.atan2(cos_v);
760 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
761 let at = frame.to_local(start);
762 let phase = at.y.atan2(at.x);
763 let winding = normal.dot(axis_z).signum();
764 let towards =
765 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
766 return Some(
767 Line2d::over(
768 ogeom_math::Axis2::new(Point2::new(phase, v), towards),
769 0.0,
770 tau,
771 )
772 .ok()?
773 .into(),
774 );
775 }
776
777 if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
779 && normal.dot(axis_z).abs() <= tol.angular()
780 && (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
781 && local.z.abs() <= tol.confusion()
782 {
783 let u = local.y.atan2(local.x);
784 let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
785 let xc = circle.frame().x().vector();
786 let phase = xc.dot(axis_z).atan2(xc.dot(radial));
787 let winding = normal.dot(radial.cross(axis_z)).signum();
788 let towards =
789 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
790 return Some(
791 Line2d::over(
792 ogeom_math::Axis2::new(Point2::new(u, phase), towards),
793 0.0,
794 tau,
795 )
796 .ok()?
797 .into(),
798 );
799 }
800 None
801}
802
803fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
809 let frame = plane.frame();
810 let flat = |p: Point| {
811 let local = frame.to_local(p);
812 Point2::new(local.x, local.y)
813 };
814 let flat_direction = |d: ogeom_math::Direction| {
815 let tip = flat(frame.origin() + d.vector());
816 ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
817 };
818 match curve {
819 Curve::Line(line) => {
820 let axis = line.axis();
821 let through = flat(axis.location);
822 let direction = flat_direction(axis.direction)?;
823 let (lo, hi) = line.domain();
824 Some(
825 Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
826 .ok()?
827 .into(),
828 )
829 }
830 Curve::Circle(c) => {
831 let circle = c.circle();
832 let frame2 = Frame2::from_axes(
833 flat(circle.centre()),
834 flat_direction(circle.frame().x())?,
835 flat_direction(circle.frame().y())?,
836 tol,
837 )
838 .ok()?;
839 Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
840 }
841 Curve::Ellipse(e) => {
842 let ellipse = e.ellipse();
843 let frame2 = Frame2::from_axes(
844 flat(ellipse.centre()),
845 flat_direction(ellipse.frame().x())?,
846 flat_direction(ellipse.frame().y())?,
847 tol,
848 )
849 .ok()?;
850 Some(
851 Ellipse2d::new(
852 Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
853 .ok()?,
854 )
855 .into(),
856 )
857 }
858 Curve::BSpline(b) => {
859 let control = b
864 .control_points()
865 .iter()
866 .map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
867 .collect::<Result<Vec<_>, _>>()
868 .ok()?;
869 Some(
870 ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
871 .ok()?
872 .into(),
873 )
874 }
875 _ => None,
876 }
877}
878
879fn on_cylinder(
886 curve: &Curve,
887 range: (f64, f64),
888 cylinder: ogeom_math::Cylinder,
889 tol: Tolerances,
890) -> Option<PlanarCurve> {
891 let axis = cylinder.axis();
892 let frame = cylinder.frame();
893 match curve {
894 Curve::Line(line) => {
895 let direction = line.axis().direction;
897 let along = direction.dot(axis.direction);
898 if (along.abs() - 1.0).abs() > tol.angular() {
899 return None;
900 }
901 let through = line.axis().location;
902 if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
903 return None;
904 }
905 let local = frame.to_local(through);
906 let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
907 let (lo, hi) = line.domain();
911 let start = Point2::new(u, local.z);
912 let towards =
913 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
914 .ok()?;
915 Some(
916 Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
917 .ok()?
918 .into(),
919 )
920 }
921 Curve::Circle(c) => {
922 let circle = c.circle();
923 if circle
925 .frame()
926 .z()
927 .cross_with(axis.direction.vector())
928 .magnitude()
929 > tol.angular()
930 {
931 return None;
932 }
933 if axis.distance_to(circle.centre()) > tol.confusion() {
934 return None;
935 }
936 if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
937 return None;
938 }
939 let local = frame.to_local(circle.centre());
940 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
949 let at = frame.to_local(start);
950 let phase = at.y.atan2(at.x);
951 let winding = circle.frame().z().dot(axis.direction).signum();
952 let towards =
953 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
954 Some(
955 Line2d::over(
956 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
957 0.0,
958 core::f64::consts::TAU,
959 )
960 .ok()?
961 .into(),
962 )
963 }
964 Curve::Ellipse(_) => {
965 use ogeom_geom::Curve3d as _;
971 let tau = core::f64::consts::TAU;
972 let local = |t: f64| -> Option<ogeom_math::Point> {
973 Some(frame.to_local(curve.point_at(t, tol).ok()?))
974 };
975 let l0 = local(0.0)?;
976 let lq = local(tau / 4.0)?;
977 let lh = local(tau / 2.0)?;
978 let r = cylinder.radius();
980 for l in [&l0, &lq, &lh] {
981 if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
982 return None;
983 }
984 }
985 let phase = l0.y.atan2(l0.x);
986 let uq = lq.y.atan2(lq.x);
989 let step = (uq - phase).rem_euclid(tau);
990 let winding = if (step - tau / 4.0).abs() < 1e-6 {
991 1.0
992 } else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
993 -1.0
994 } else {
995 return None;
996 };
997 let c0 = f64::midpoint(l0.z, lh.z);
999 let a = (l0.z - lh.z) / 2.0;
1000 let b = lq.z - c0;
1001 let candidate = ogeom_geom::Trig2d::new(
1005 Point2::new(phase, c0),
1006 ogeom_math::Vector2::new(winding, 0.0),
1007 ogeom_math::Vector2::new(0.0, a),
1008 ogeom_math::Vector2::new(0.0, b),
1009 range,
1010 )
1011 .ok()?;
1012 use ogeom_geom::Curve2d as _;
1015 for i in 0..7 {
1016 let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
1017 let l = local(t)?;
1018 let chart = candidate.point_at(t, tol).ok()?;
1019 let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
1020 if du.min(tau - du) > 1e-9 {
1021 return None;
1022 }
1023 if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
1024 return None;
1025 }
1026 }
1027 Some(PlanarCurve::Trig(candidate))
1028 }
1029 _ => None,
1030 }
1031}
1032
1033fn on_meridian(
1051 curve: &ogeom_geom::CircleCurve,
1052 range: (f64, f64),
1053 sphere: ogeom_math::Sphere,
1054 tol: Tolerances,
1055) -> Option<PlanarCurve> {
1056 let circle = curve.circle();
1057 let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
1062 let frame = sphere.frame();
1063 let z = frame.z().vector();
1064 if circle.centre().distance(sphere.centre()) > tol.confusion() {
1067 return None;
1068 }
1069 if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
1070 return None;
1071 }
1072 let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
1073 let (xz, yz) = (cx.dot(z), cy.dot(z));
1074 if xz.hypot(yz) < 1.0 - tol.angular() {
1077 return None;
1078 }
1079 let raw_alpha = yz.atan2(xz);
1080 let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
1083 let local = frame.to_local(sphere.centre() + w);
1084 let longitude = local.y.atan2(local.x);
1085
1086 let half = core::f64::consts::PI;
1087 let mid = f64::midpoint(range.0, range.1);
1088 let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
1091 let x_mid = if x_mid > half {
1092 x_mid - core::f64::consts::TAU
1093 } else {
1094 x_mid
1095 };
1096 let span = sweep * (range.1 - range.0);
1097 let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
1098 if x0 > x1 {
1099 core::mem::swap(&mut x0, &mut x1);
1100 }
1101 let alpha = sweep.mul_add(mid, -x_mid);
1106 let slack = tol.parametric().max(1e-9);
1107 let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
1108 (
1111 Point2::new(longitude, half.mul_add(0.5, alpha)),
1112 ogeom_math::Vector2::new(0.0, -sweep),
1113 )
1114 } else if x0 >= -half - slack && x1 <= slack {
1115 (
1117 Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
1118 ogeom_math::Vector2::new(0.0, sweep),
1119 )
1120 } else {
1121 return None;
1123 };
1124 let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
1125 let margin = (range.1 - range.0) * 0.25;
1126 let line: PlanarCurve = Line2d::over(
1127 ogeom_math::Axis2::new(axis_point, towards),
1128 range.0 - margin,
1129 range.1 + margin,
1130 )
1131 .ok()?
1132 .into();
1133
1134 for k in 0..=4 {
1137 let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
1138 let uv = line.point_at(t, tol).ok()?;
1139 let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
1140 let want = curve.point_at(t, tol).ok()?;
1141 if lifted.distance(want) > tol.confusion() {
1142 return None;
1143 }
1144 }
1145 Some(line)
1146}
1147
1148fn on_sphere(
1151 curve: &Curve,
1152 range: (f64, f64),
1153 sphere: ogeom_math::Sphere,
1154 tol: Tolerances,
1155) -> Option<PlanarCurve> {
1156 let Curve::Circle(c) = curve else {
1157 return None;
1158 };
1159 let circle = c.circle();
1160 let frame = sphere.frame();
1161 if circle
1164 .frame()
1165 .z()
1166 .cross_with(frame.z().vector())
1167 .magnitude()
1168 > tol.angular()
1169 {
1170 return on_meridian(c, range, sphere, tol);
1171 }
1172 let local = frame.to_local(circle.centre());
1173 if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
1174 return None;
1175 }
1176 let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
1177 if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
1179 return None;
1180 }
1181 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1182 let at = frame.to_local(start);
1183 let phase = at.y.atan2(at.x);
1184 let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
1187 let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1188 Some(
1189 Line2d::over(
1190 ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
1191 0.0,
1192 core::f64::consts::TAU,
1193 )
1194 .ok()?
1195 .into(),
1196 )
1197}
1198
1199#[cfg(test)]
1200#[allow(clippy::unwrap_used, clippy::expect_used)]
1201mod tests {
1202 use super::*;
1203 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
1204 use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
1205
1206 const T: Tolerances = Tolerances::millimetres();
1207
1208 fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
1209 SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
1210 }
1211
1212 fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
1213 let frame = Frame::new(
1214 Point::ORIGIN,
1215 Direction::new(axis, T).unwrap(),
1216 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1217 T,
1218 )
1219 .unwrap();
1220 CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
1221 .unwrap()
1222 .into()
1223 }
1224
1225 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1226 PlaneSurface::over(
1227 Plane::through(origin, Direction::new(normal, T).unwrap()),
1228 (-6.0, 6.0),
1229 (-6.0, 6.0),
1230 )
1231 .unwrap()
1232 .into()
1233 }
1234
1235 fn assert_same_parameter(
1238 section: &SectionCurve,
1239 surface: &SurfaceGeometry,
1240 pcurve: &PlanarCurve,
1241 samples: usize,
1242 ) {
1243 let (lo, hi) = section.curve.domain();
1244 let (plo, phi) = pcurve.domain();
1245 assert!(
1246 (lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
1247 "domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
1248 );
1249 for i in 0..=samples {
1250 #[allow(clippy::cast_precision_loss)]
1251 let t = lo + (hi - lo) * i as f64 / samples as f64;
1252 let on_curve = section.curve.point_at(t, T).unwrap();
1253 let at = pcurve.point_at(t, T).unwrap();
1254 let lifted = surface.point_at(at.x, at.y, T).unwrap();
1255 assert!(
1256 on_curve.is_equal(lifted, T),
1257 "at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
1258 );
1259 }
1260 }
1261
1262 #[test]
1263 fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
1264 let drum = cylinder(Vector::Z, 2.0);
1268 let cut = plane(Point::ORIGIN, Vector::X);
1269 let SurfaceIntersection::Along(curves) =
1270 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1271 else {
1272 panic!("a plane through a cylinder meets it along curves");
1273 };
1274 assert_eq!(curves.len(), 2);
1275 for section in &curves {
1276 assert!(section.exact);
1277 assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
1278 let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
1279 let on_b = section.on_b.as_ref().expect("and a plane pcurve");
1280 assert_same_parameter(section, &drum, on_a, 50);
1281 assert_same_parameter(section, &cut, on_b, 50);
1282 }
1283 }
1284
1285 #[test]
1286 fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
1287 let drum = cylinder(Vector::Z, 2.0);
1291 let angle: f64 = 0.5;
1292 let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
1293 let SurfaceIntersection::Along(curves) =
1294 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1295 else {
1296 panic!("an oblique plane meets the cylinder along its ellipse");
1297 };
1298 assert_eq!(curves.len(), 1);
1299 let section = &curves[0];
1300 assert!(section.exact);
1301 assert!(matches!(section.curve, Curve::Ellipse(_)));
1302 let on_drum = section
1303 .on_a
1304 .as_ref()
1305 .expect("the oblique ellipse now carries its cylinder pcurve");
1306 assert!(
1307 matches!(on_drum, PlanarCurve::Trig(_)),
1308 "the chart trace is trig-affine: {on_drum:?}"
1309 );
1310 assert_same_parameter(section, &drum, on_drum, 60);
1311 let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
1312 assert_same_parameter(section, &cut, on_plane, 60);
1313 }
1314
1315 #[test]
1316 fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
1317 let drum = cylinder(Vector::Z, 2.0);
1318 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
1319 let SurfaceIntersection::Along(curves) =
1320 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1321 else {
1322 panic!("expected curves");
1323 };
1324 assert_eq!(curves.len(), 1);
1325 let section = &curves[0];
1326 assert!(section.closed);
1327 assert!(matches!(section.curve, Curve::Circle(_)));
1328 assert!(matches!(
1330 section.on_a.as_ref().unwrap(),
1331 PlanarCurve::Line(_)
1332 ));
1333 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
1334 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
1335 }
1336
1337 #[test]
1338 fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
1339 let drum = cylinder(Vector::Z, 1.5);
1340 let ball = sphere(Point::ORIGIN, 3.0);
1341 let SurfaceIntersection::Along(curves) =
1342 intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
1343 else {
1344 panic!("expected curves");
1345 };
1346 assert_eq!(curves.len(), 2);
1347 for section in &curves {
1348 assert!(section.exact);
1349 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
1350 assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
1351 }
1352 }
1353
1354 fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
1355 let frame = Frame::new(
1356 origin,
1357 Direction::new(axis, T).unwrap(),
1358 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1359 T,
1360 )
1361 .unwrap();
1362 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
1363 .into()
1364 }
1365
1366 #[test]
1367 fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
1368 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1369 let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
1370 let SurfaceIntersection::Along(curves) =
1371 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1372 else {
1373 panic!("an axis-normal plane through the tube meets it along curves");
1374 };
1375 assert_eq!(curves.len(), 2);
1376 let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
1377 let mut radii: Vec<f64> = curves
1378 .iter()
1379 .map(|s| {
1380 let Curve::Circle(c) = &s.curve else {
1381 panic!("a parallel is a circle");
1382 };
1383 c.circle().radius()
1384 })
1385 .collect();
1386 radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
1387 assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
1388 assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
1389 for section in &curves {
1390 assert!(section.exact);
1391 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1392 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
1393 }
1394 }
1395
1396 #[test]
1397 fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
1398 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1403 let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
1404 let SurfaceIntersection::Along(curves) =
1405 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1406 else {
1407 panic!("the rolling plane touches along a circle, not at points");
1408 };
1409 assert_eq!(curves.len(), 1);
1410 let Curve::Circle(c) = &curves[0].curve else {
1411 panic!("the tangency is a circle");
1412 };
1413 assert!((c.circle().radius() - 2.0).abs() < 1e-12);
1414 assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
1415 assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
1416 }
1417
1418 #[test]
1419 fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
1420 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1421 let drum = cylinder(Vector::Z, 2.2);
1422 let SurfaceIntersection::Along(curves) =
1423 intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
1424 else {
1425 panic!("a coaxial cylinder through the tube meets it along curves");
1426 };
1427 assert_eq!(curves.len(), 2);
1428 for section in &curves {
1429 assert!(section.exact);
1430 let Curve::Circle(c) = §ion.curve else {
1431 panic!("a parallel is a circle");
1432 };
1433 assert!((c.circle().radius() - 2.2).abs() < 1e-12);
1434 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
1435 assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
1436 }
1437
1438 let grazing = cylinder(Vector::Z, 2.5);
1440 let SurfaceIntersection::Along(touch) =
1441 intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
1442 else {
1443 panic!("the grazing cylinder touches along the equator");
1444 };
1445 assert_eq!(touch.len(), 1);
1446 assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
1447 assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
1448 }
1449
1450 #[test]
1451 fn coaxial_tori_are_the_same_or_meet_in_parallels() {
1452 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1453 assert!(matches!(
1454 intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
1455 SurfaceIntersection::Same
1456 ));
1457
1458 let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
1461 let SurfaceIntersection::Along(curves) =
1462 intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
1463 else {
1464 panic!("lifted coaxial tori meet along curves");
1465 };
1466 assert_eq!(curves.len(), 2);
1467 for section in &curves {
1468 assert!(section.exact);
1469 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1470 assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
1471 }
1472 }
1473
1474 #[test]
1475 fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
1476 let a = cylinder(Vector::Z, 1.0);
1478 let b = cylinder(Vector::X, 1.6);
1479 let options = IntersectOptions {
1480 tolerance: 1e-5,
1481 marching: Marching {
1482 chord: 1e-5,
1483 ..Marching::default()
1484 },
1485 };
1486 let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
1487 else {
1488 panic!("crossed cylinders meet along curves");
1489 };
1490 assert_eq!(curves.len(), 2);
1491 for section in &curves {
1492 assert!(!section.exact);
1493 assert!(section.closed);
1494 assert!(
1495 section.tolerance <= 1e-5 + 1e-4,
1496 "got {}",
1497 section.tolerance
1498 );
1499 assert!(section.on_a.is_some() && section.on_b.is_some());
1500
1501 let (lo, hi) = section.curve.domain();
1503 for i in 0..=200 {
1504 #[allow(clippy::cast_precision_loss)]
1505 let t = lo + (hi - lo) * f64::from(i) / 200.0;
1506 let p = section.curve.point_at(t, T).unwrap();
1507 let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
1508 unreachable!()
1509 };
1510 let off = x
1511 .cylinder()
1512 .distance_to(p)
1513 .abs()
1514 .max(y.cylinder().distance_to(p).abs());
1515 assert!(
1516 off <= section.tolerance * 2.0,
1517 "at t = {t} the fitted curve is {off:e} off, tolerance {}",
1518 section.tolerance
1519 );
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn exact_lines_are_clipped_to_the_surfaces_extents() {
1526 let drum = cylinder(Vector::Z, 2.0);
1531 let cut = plane(Point::ORIGIN, Vector::X);
1532 let SurfaceIntersection::Along(curves) =
1533 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1534 else {
1535 panic!("expected curves");
1536 };
1537 for section in &curves {
1538 let (lo, hi) = section.curve.domain();
1539 assert!(
1541 hi - lo <= 8.0 + 1e-9,
1542 "the line was not clipped: [{lo}, {hi}]"
1543 );
1544 let start = section.curve.point_at(lo, T).unwrap();
1545 let end = section.curve.point_at(hi, T).unwrap();
1546 assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
1547 }
1548
1549 let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
1553 assert_eq!(
1554 intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
1555 SurfaceIntersection::Apart
1556 );
1557 }
1558
1559 #[test]
1560 fn the_degenerate_answers_pass_through() {
1561 assert_eq!(
1562 intersect_surfaces(
1563 &sphere(Point::ORIGIN, 1.0),
1564 &sphere(Point::new(5.0, 0.0, 0.0), 1.0),
1565 IntersectOptions::default(),
1566 T
1567 )
1568 .unwrap(),
1569 SurfaceIntersection::Apart
1570 );
1571 assert_eq!(
1572 intersect_surfaces(
1573 &sphere(Point::ORIGIN, 1.0),
1574 &sphere(Point::ORIGIN, 1.0),
1575 IntersectOptions::default(),
1576 T
1577 )
1578 .unwrap(),
1579 SurfaceIntersection::Same
1580 );
1581 assert!(matches!(
1582 intersect_surfaces(
1583 &plane(Point::ORIGIN, Vector::Z),
1584 &sphere(Point::new(0.0, 0.0, 2.0), 2.0),
1585 IntersectOptions::default(),
1586 T
1587 )
1588 .unwrap(),
1589 SurfaceIntersection::Touching(ref p) if p.len() == 1
1590 ));
1591 }
1592
1593 #[test]
1594 fn unusable_options_are_refused() {
1595 let a = sphere(Point::ORIGIN, 1.0);
1596 let b = plane(Point::ORIGIN, Vector::Z);
1597 for tolerance in [0.0, -1.0, f64::NAN] {
1598 let options = IntersectOptions {
1599 tolerance,
1600 ..IntersectOptions::default()
1601 };
1602 assert!(intersect_surfaces(&a, &b, options, T).is_err());
1603 }
1604 }
1605
1606 #[test]
1607 fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
1608 let drum: SurfaceGeometry = CylinderSurface::new(
1616 Cylinder::new(
1617 Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
1618 0.5,
1619 T,
1620 )
1621 .unwrap(),
1622 (0.0, 3.0),
1623 )
1624 .unwrap()
1625 .into();
1626 for normal in [Direction::Z, -Direction::Z] {
1627 let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
1628 let ground: SurfaceGeometry =
1629 PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
1630 .unwrap()
1631 .into();
1632 let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
1633 let SurfaceIntersection::Along(curves) = met else {
1634 panic!("a plane through a cylinder sections it");
1635 };
1636 for sc in &curves {
1637 let pcurve = sc
1638 .on_b
1639 .as_ref()
1640 .expect("a circle on its cylinder has a pcurve");
1641 let (lo, hi) = sc.curve.domain();
1642 for i in 0..8 {
1643 let t = lo + (hi - lo) * f64::from(i) / 8.0;
1644 let p3 = sc.curve.point_at(t, T).unwrap();
1645 let uv = pcurve.point_at(t, T).unwrap();
1646 let lifted = drum
1647 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
1648 .unwrap();
1649 assert!(
1650 p3.distance(lifted) < 1e-9,
1651 "normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
1652 );
1653 }
1654 }
1655 }
1656 }
1657
1658 #[test]
1664 fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
1665 use ogeom_geom::Surface as _;
1666 let half = core::f64::consts::PI;
1667 for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
1668 let ball = sphere(centre, radius);
1669 let SurfaceGeometry::Sphere(s) = &ball else {
1670 panic!("a sphere surface");
1671 };
1672 for azimuth in [0.0_f64, 0.7, 2.4] {
1675 let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
1676 let cut = plane(centre, normal);
1677 let SurfaceIntersection::Along(curves) =
1678 intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
1679 else {
1680 panic!("a plane through the centre meets the ball along a circle");
1681 };
1682 assert_eq!(curves.len(), 1, "one great circle");
1683 let circle = &curves[0].curve;
1684 assert!(curves[0].exact);
1685 assert!(
1687 exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
1688 "the whole meridian has no single chart image"
1689 );
1690 for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
1691 let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
1692 .expect("half a meridian has an exact pcurve");
1693 assert!(
1694 matches!(pcurve, PlanarCurve::Line(_)),
1695 "and it is a straight line in the chart"
1696 );
1697 for i in 0..=16 {
1698 let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
1699 let want = circle.point_at(t, T).unwrap();
1700 let uv = pcurve.point_at(t, T).unwrap();
1701 assert!(
1702 uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
1703 "the latitude stays inside the chart: {}",
1704 uv.y
1705 );
1706 let lifted = ball
1707 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
1708 .unwrap();
1709 assert!(
1710 want.distance(lifted) < 1e-9,
1711 "azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
1712 );
1713 }
1714 }
1715 assert!(
1718 exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
1719 "a range across a pole has no one line"
1720 );
1721 let _ = s;
1722 }
1723 }
1724 }
1725
1726 #[test]
1735 fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
1736 use ogeom_geom::TrimmedCurve;
1737 let drum = cylinder(Vector::Z, 2.0);
1738 let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
1739 let SurfaceIntersection::Along(curves) =
1741 intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
1742 else {
1743 panic!("a plane across a cylinder meets it in a circle");
1744 };
1745 let whole = curves[0].curve.clone();
1746 let (lo, hi) = whole.domain();
1747 let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
1748 .unwrap()
1749 .into();
1750
1751 for surface in [&drum, &ground] {
1752 let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
1753 let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
1754 let (a, b) = quarter.domain();
1757 for i in 0..=8 {
1758 let t = (b - a).mul_add(f64::from(i) / 8.0, a);
1759 let (whole_at, part_at) =
1760 (full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
1761 assert!(
1762 whole_at.distance(part_at) < 1e-12,
1763 "the trim carries the basis: {whole_at:?} against {part_at:?}"
1764 );
1765 let lifted = surface
1767 .point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
1768 .or_else(|_| surface.point_at(part_at.x, part_at.y, T))
1769 .unwrap();
1770 assert!(
1771 lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
1772 "same-parameter, still"
1773 );
1774 }
1775 }
1776 }
1777 #[test]
1778 fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
1779 use ogeom_geom::ConeSurface;
1780 let cone =
1788 ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
1789 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
1790 let u_true = 0.01_f64;
1791 let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
1792 let direction =
1795 Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
1796 let far = -7.0e5;
1797 let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
1798 let line = ogeom_geom::LineCurve::over(
1799 ogeom_math::Axis::new(origin, direction),
1800 far.abs() - 1.0,
1801 far.abs() + 1.0,
1802 )
1803 .unwrap();
1804 let curve: Curve = line.into();
1805 let range = ogeom_geom::Curve3d::domain(&curve);
1806 let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
1807 let at = pcurve.point_at(range.0, T).unwrap();
1808 let tau = core::f64::consts::TAU;
1809 let gap = (at.x - u_true)
1810 .rem_euclid(tau)
1811 .min(tau - (at.x - u_true).rem_euclid(tau));
1812 assert!(
1813 gap < 1e-6,
1814 "the ruling's chart angle must be the used side's: got u {} against {u_true}",
1815 at.x
1816 );
1817 }
1818}