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 if let Some(sections) = near_parallel_plane_drum(a, b, tol) {
141 return Ok(if sections.is_empty() {
142 SurfaceIntersection::Apart
143 } else {
144 SurfaceIntersection::Along(sections)
145 });
146 }
147 match surface_surface(a, b, tol) {
148 Ok(Meeting::Apart) => Ok(SurfaceIntersection::Apart),
149 Ok(Meeting::Same) => Ok(SurfaceIntersection::Same),
150 Ok(Meeting::Touching(points)) => Ok(SurfaceIntersection::Touching(points)),
151 Ok(Meeting::Along(curves)) => {
152 let sections: Vec<SectionCurve> = curves
153 .into_iter()
154 .filter_map(|curve| exact_section(curve, a, b, tol))
155 .collect();
156 Ok(if sections.is_empty() {
157 SurfaceIntersection::Apart
160 } else {
161 SurfaceIntersection::Along(sections)
162 })
163 }
164 Err(_) => match near_parallel_drums(a, b, tol).or_else(|| ball_through_drum(a, b, tol)) {
167 Some(sections) if sections.is_empty() => Ok(SurfaceIntersection::Apart),
168 Some(sections) => Ok(SurfaceIntersection::Along(sections)),
169 None => marched(a, b, options, tol),
170 },
171 }
172}
173
174fn near_parallel_drums(
191 a: &SurfaceGeometry,
192 b: &SurfaceGeometry,
193 tol: Tolerances,
194) -> Option<Vec<SectionCurve>> {
195 const LEAN: f64 = 1e-3;
196 let (SurfaceGeometry::Cylinder(sa), SurfaceGeometry::Cylinder(sb)) = (a, b) else {
197 return None;
198 };
199 let (ca, cb) = (sa.cylinder(), sb.cylinder());
200 let (axis_a, axis_b) = (ca.axis(), cb.axis());
201 let (da, db) = (axis_a.direction.vector(), axis_b.direction.vector());
202 let (ra, rb) = (ca.radius(), cb.radius());
203 let cos = da.dot(db);
204 if da.cross(db).magnitude() > LEAN || cos.abs() < 0.5 {
205 return None;
206 }
207 let (pa, pb) = (axis_a.location, axis_b.location);
208 let (_, (a0, a1)) = a.domain();
210 let (_, (b0, b1)) = b.domain();
211 let along = |v: f64| (pb - pa).dot(da) + v * cos;
212 let (lo, hi) = (
213 a0.min(a1).max(along(b0).min(along(b1))),
214 a0.max(a1).min(along(b0).max(along(b1))),
215 );
216 if !(lo.is_finite() && hi.is_finite()) {
217 return None;
218 }
219 if hi - lo <= tol.confusion() {
220 return Some(Vec::new());
221 }
222 let meet = |z: f64| -> Option<[Point; 2]> {
226 let centre_a = pa + da * z;
227 let s = (centre_a - pb).dot(da) / cos;
228 let centre_b = pb + db * s;
229 let mut between = centre_b - centre_a;
230 between = between - da * between.dot(da);
231 let d = between.magnitude();
232 let margin = tol.confusion() * 1e3;
233 if d <= margin || d >= ra + rb - margin || d <= (ra - rb).abs() + margin {
234 return None;
235 }
236 let x = (d * d + ra * ra - rb * rb) / (2.0 * d);
237 let h = (ra * ra - x * x).max(0.0).sqrt();
238 let ex = between / d;
239 let ey = da.cross(ex);
240 Some([centre_a + ex * x + ey * h, centre_a + ex * x - ey * h])
241 };
242 lines_through_stations(lo, hi, meet, rb * (1.0 / cos.abs() - 1.0), tol)
243}
244
245const NEAR_PARALLEL_STRAY: f64 = 1e-5;
248
249fn lines_through_stations(
257 lo: f64,
258 hi: f64,
259 meet: impl Fn(f64) -> Option<[Point; 2]>,
260 stated: f64,
261 tol: Tolerances,
262) -> Option<Vec<SectionCurve>> {
263 const STATIONS: u32 = 32;
264 const STRAIGHT: f64 = 1e-6;
265 let at = |k: f64| (hi - lo).mul_add(k / f64::from(STATIONS), lo);
266 let heights: Vec<f64> = (0..=STATIONS).map(|k| at(f64::from(k))).collect();
267 let met: Vec<[Point; 2]> = heights.iter().map(|&z| meet(z)).collect::<Option<_>>()?;
268 let between: Vec<[Point; 2]> = (0..STATIONS)
269 .map(|k| meet(at(f64::from(k) + 0.5)))
270 .collect::<Option<_>>()?;
271 let mut out = Vec::with_capacity(2);
272 for side in 0..2 {
273 let (from, to) = (met[0][side], met[met.len() - 1][side]);
274 let span = to - from;
275 let length = span.magnitude();
276 if length <= tol.confusion() {
277 return None;
278 }
279 let off_line = |p: Point| {
280 let t = (p - from).dot(span) / (length * length);
281 p.distance(from + span * t)
282 };
283 let stray = met
284 .iter()
285 .chain(&between)
286 .map(|pair| off_line(pair[side]))
287 .fold(0.0_f64, f64::max);
288 let (curve, stray): (Curve, f64) = if stray <= STRAIGHT {
289 (
290 ogeom_geom::LineCurve::segment(from, to, tol).ok()?.into(),
291 stray,
292 )
293 } else {
294 let points: Vec<Point> = met.iter().map(|pair| pair[side]).collect();
295 let fitted =
296 ogeom_geom::fit::fit_points_at(&heights, &points, 3, tol.confusion(), tol).ok()?;
297 let curve: Curve = fitted.curve.into();
298 let mut worst = fitted.error;
299 for (k, pair) in (0..STATIONS).zip(&between) {
300 let p = curve.point_at(at(f64::from(k) + 0.5), tol).ok()?;
301 worst = worst.max(p.distance(pair[side]));
302 }
303 (curve, worst)
304 };
305 let tolerance = stray + stated + tol.confusion();
306 if tolerance > NEAR_PARALLEL_STRAY {
307 return None;
308 }
309 out.push(SectionCurve {
310 curve,
311 on_a: None,
312 on_b: None,
313 tolerance,
314 exact: false,
315 closed: false,
316 tangential: false,
317 });
318 }
319 Some(out)
320}
321
322fn ball_through_drum(
334 a: &SurfaceGeometry,
335 b: &SurfaceGeometry,
336 tol: Tolerances,
337) -> Option<Vec<SectionCurve>> {
338 const SAMPLES: u32 = 256;
339 const STRAY: f64 = 1e-5;
340 let (ball, drum, ball_first) = match (a, b) {
341 (SurfaceGeometry::Sphere(s), SurfaceGeometry::Cylinder(c)) => (s, c, true),
342 (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Sphere(s)) => (s, c, false),
343 _ => return None,
344 };
345 let (sphere, cylinder) = (ball.sphere(), drum.cylinder());
346 let frame = cylinder.frame();
347 let (x, y, d) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
348 let (origin, r) = (frame.origin(), cylinder.radius());
349 let (centre, big) = (sphere.centre(), sphere.radius());
350 let ball_frame = sphere.frame();
351 let (_, (h0, h1)) = drum.domain();
352 let margin = r * 0.1;
356 let heights = |angle: f64| -> Option<[f64; 2]> {
358 let foot = origin + (x * angle.cos() + y * angle.sin()) * r;
359 let w = foot - centre;
360 let half = d.dot(w);
361 let disc = half.mul_add(half, -(w.dot(w) - big * big));
362 if disc <= margin * margin {
363 return None;
364 }
365 let root = disc.sqrt();
366 let pair = [-half - root, -half + root];
367 pair.iter().all(|v| *v >= h0 && *v <= h1).then_some(pair)
368 };
369 let at = |angle: f64, v: f64| origin + (x * angle.cos() + y * angle.sin()) * r + d * v;
370 let on_ball = |p: Point, before: Option<Point2>| -> Point2 {
372 let local = ball_frame.to_local(p);
373 let lat = local.z.atan2(local.x.hypot(local.y));
374 let mut lon = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
375 if let Some(prev) = before {
376 while lon - prev.x > core::f64::consts::PI {
377 lon -= core::f64::consts::TAU;
378 }
379 while prev.x - lon > core::f64::consts::PI {
380 lon += core::f64::consts::TAU;
381 }
382 }
383 Point2::new(lon, lat)
384 };
385 let angle_of = |k: f64| core::f64::consts::TAU * k / f64::from(SAMPLES);
386 let params: Vec<f64> = (0..=SAMPLES).map(|k| angle_of(f64::from(k))).collect();
387 let mut sampled: Vec<[f64; 2]> = Vec::with_capacity(params.len());
388 for &angle in ¶ms {
389 sampled.push(heights(angle)?);
390 }
391 let mut out = Vec::with_capacity(2);
392 for side in 0..2 {
393 let points: Vec<Point> = params
394 .iter()
395 .zip(&sampled)
396 .map(|(&angle, pair)| at(angle, pair[side]))
397 .collect();
398 let on_drum: Vec<Point2> = params
399 .iter()
400 .zip(&sampled)
401 .map(|(&angle, pair)| Point2::new(angle, pair[side]))
402 .collect();
403 let mut on_sphere: Vec<Point2> = Vec::with_capacity(points.len());
404 for p in &points {
405 let q = on_ball(*p, on_sphere.last().copied());
406 on_sphere.push(q);
407 }
408 let target = tol.confusion() * 10.0;
409 let curve: Curve = ogeom_geom::fit::fit_points_at(¶ms, &points, 3, target, tol)
410 .ok()?
411 .curve
412 .into();
413 let drum_image: PlanarCurve =
414 ogeom_geom::fit::fit_points_2d_at(¶ms, &on_drum, 3, target, tol)
415 .ok()?
416 .curve
417 .into();
418 let ball_image: PlanarCurve =
419 ogeom_geom::fit::fit_points_2d_at(¶ms, &on_sphere, 3, target, tol)
420 .ok()?
421 .curve
422 .into();
423 let mut stray = 0.0_f64;
426 for k in 0..(2 * SAMPLES) {
427 let angle = angle_of(f64::from(k) / 2.0);
428 let truth = at(angle, heights(angle)?[side]);
429 let on_curve = curve.point_at(angle, tol).ok()?;
430 let uv = drum_image.point_at(angle, tol).ok()?;
431 let through_drum = drum.point_at(uv.x, uv.y, tol).ok()?;
432 let uv = ball_image.point_at(angle, tol).ok()?;
433 let through_ball = ball.point_at(uv.x, uv.y, tol).ok()?;
434 stray = stray
435 .max(truth.distance(on_curve))
436 .max(truth.distance(through_drum))
437 .max(truth.distance(through_ball));
438 }
439 let tolerance = stray.max(tol.confusion());
440 if tolerance > STRAY {
441 return None;
442 }
443 let (on_a, on_b) = if ball_first {
444 (ball_image, drum_image)
445 } else {
446 (drum_image, ball_image)
447 };
448 out.push(SectionCurve {
449 curve,
450 on_a: Some(on_a),
451 on_b: Some(on_b),
452 tolerance,
453 exact: false,
454 closed: true,
455 tangential: false,
456 });
457 }
458 Some(out)
459}
460
461fn near_parallel_plane_drum(
473 a: &SurfaceGeometry,
474 b: &SurfaceGeometry,
475 tol: Tolerances,
476) -> Option<Vec<SectionCurve>> {
477 const LEAN: f64 = 1e-3;
478 const SPAN: f64 = 3e4;
479 let (plane, drum, surface) = match (a, b) {
480 (SurfaceGeometry::Plane(p), SurfaceGeometry::Cylinder(c)) => (p.plane(), c.cylinder(), b),
481 (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Plane(p)) => (p.plane(), c.cylinder(), a),
482 _ => return None,
483 };
484 let axis = drum.axis();
485 let (d, r) = (axis.direction.vector(), drum.radius());
486 let n = plane.normal().vector();
487 let lean = n.dot(d).abs();
488 if lean <= tol.angular() || lean > LEAN || r / lean < SPAN {
493 return None;
494 }
495 let across = n - d * n.dot(d);
496 let k = across.magnitude();
497 let e1 = across / k;
498 let e2 = d.cross(e1);
499 let (_, (lo, hi)) = surface.domain();
500 if !(lo.is_finite() && hi.is_finite()) || hi - lo <= tol.confusion() {
501 return None;
502 }
503 let meet = |z: f64| -> Option<[Point; 2]> {
504 let centre = axis.location + d * z;
505 let u = -plane.signed_distance_to(centre) / k;
506 let margin = tol.confusion() * 1e3;
507 if u.abs() >= r - margin {
508 return None;
509 }
510 let w = r.mul_add(r, -(u * u)).sqrt();
511 Some([centre + e1 * u + e2 * w, centre + e1 * u - e2 * w])
512 };
513 lines_through_stations(lo, hi, meet, 0.0, tol)
514}
515
516fn exact_section(
531 curve: Curve,
532 a: &SurfaceGeometry,
533 b: &SurfaceGeometry,
534 tol: Tolerances,
535) -> Option<SectionCurve> {
536 let closed = match &curve {
537 Curve::Circle(_) | Curve::Ellipse(_) => true,
538 _ => curve.is_closed(tol),
539 };
540 let range = curve.domain();
541 let on_a = exact_pcurve(&curve, range, a, tol);
542 let on_b = exact_pcurve(&curve, range, b, tol);
543
544 if let Curve::Line(_) = &curve {
545 let mut interval = curve.domain();
548 if let Some(p) = &on_a {
549 interval = intersect_intervals(interval, inside_box(p, a))?;
550 }
551 if let Some(p) = &on_b {
552 interval = intersect_intervals(interval, inside_box(p, b))?;
553 }
554 let (lo, hi) = interval;
555 let Curve::Line(line) = &curve else {
556 unreachable!()
557 };
558 let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
559 .ok()?
560 .into();
561 let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
562 let PlanarCurve::Line(l) = p else {
563 return Some(p.clone());
564 };
565 Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
566 };
567 let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
568 let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
569 return Some(SectionCurve {
570 on_a: ca,
571 on_b: cb,
572 tolerance: 0.0,
573 exact: true,
574 closed: false,
575 tangential,
576 curve: clipped,
577 });
578 }
579
580 for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
583 if let Some(p) = pcurve
584 && !touches_box(p, surface, tol)
585 {
586 return None;
587 }
588 }
589 let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
590 Some(SectionCurve {
591 on_a,
592 on_b,
593 tolerance: 0.0,
594 exact: true,
595 closed,
596 tangential,
597 curve,
598 })
599}
600
601fn touching_along(
610 curve: &Curve,
611 on_a: Option<&PlanarCurve>,
612 on_b: Option<&PlanarCurve>,
613 a: &SurfaceGeometry,
614 b: &SurfaceGeometry,
615 tol: Tolerances,
616) -> bool {
617 let sample_uv = |pc: Option<&PlanarCurve>,
625 surface: &SurfaceGeometry,
626 t: f64|
627 -> Option<ogeom_math::Point2> {
628 if let Some(pc) = pc {
629 return pc.point_at(t, tol).ok();
630 }
631 let p = curve.point_at(t, tol).ok()?;
632 chart_inversion(surface, p, tol)
633 };
634 let (lo, hi) = curve.domain();
635 let mut judged = 0_usize;
641 for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
642 let t = (hi - lo).mul_add(f, lo);
643 let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
644 continue;
645 };
646 let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
647 continue;
648 };
649 if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
650 return false;
651 }
652 judged += 1;
653 }
654 judged >= 3
655}
656
657fn chart_inversion(
659 surface: &SurfaceGeometry,
660 p: ogeom_math::Point,
661 tol: Tolerances,
662) -> Option<ogeom_math::Point2> {
663 use ogeom_math::elementary;
664 let (u, v) = match surface {
665 SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
666 SurfaceGeometry::Cylinder(s) => {
667 elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
668 }
669 SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
670 SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
671 SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
672 _ => return None,
673 };
674 Some(ogeom_math::Point2::new(u, v))
675}
676
677fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
680 let PlanarCurve::Line(line) = pcurve else {
681 return None;
682 };
683 let ((ua, ub), (va, vb)) = surface.domain();
684 let axis = line.axis();
685 let (o, d) = (axis.location, axis.direction.vector());
686
687 let mut lo = f64::NEG_INFINITY;
689 let mut hi = f64::INFINITY;
690 for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
691 if direction.abs() <= f64::MIN_POSITIVE {
692 if origin < low || origin > high {
693 return None;
694 }
695 continue;
696 }
697 let (a, b) = ((low - origin) / direction, (high - origin) / direction);
698 let (near, far) = if a < b { (a, b) } else { (b, a) };
699 lo = lo.max(near);
700 hi = hi.min(far);
701 }
702 if lo >= hi {
703 return None;
704 }
705 Some((lo, hi))
706}
707
708fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
710 use ogeom_geom::Curve2d;
711 let ((ua, ub), (va, vb)) = surface.domain();
712 let (lo, hi) = pcurve.domain();
713 const SPANS: u32 = 64;
722 let points: Vec<Option<ogeom_math::Point2>> = (0..=SPANS)
723 .map(|i| {
724 pcurve
725 .point_at(lo + (hi - lo) * f64::from(i) / f64::from(SPANS), tol)
726 .ok()
727 })
728 .collect();
729 points.windows(2).any(|pair| {
730 let (Some(p), Some(q)) = (pair[0], pair[1]) else {
731 return false;
732 };
733 let pad = p.distance(q);
734 let u_ok =
736 surface.is_periodic_u() || (p.x.max(q.x) + pad >= ua && p.x.min(q.x) - pad <= ub);
737 let v_ok =
738 surface.is_periodic_v() || (p.y.max(q.y) + pad >= va && p.y.min(q.y) - pad <= vb);
739 u_ok && v_ok
740 })
741}
742
743fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
745 let b = b?;
746 let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
747 if lo >= hi {
748 return None;
749 }
750 Some((lo, hi))
751}
752
753fn marched(
755 a: &SurfaceGeometry,
756 b: &SurfaceGeometry,
757 options: IntersectOptions,
758 tol: Tolerances,
759) -> OgeomResult<SurfaceIntersection> {
760 let traced = branches(a, b, options.marching, tol)?;
761 if traced.is_empty() {
762 return Ok(SurfaceIntersection::Apart);
763 }
764 let mut out = Vec::with_capacity(traced.len());
765 let mut contacts: Vec<crate::march::Traced> = Vec::new();
766 for branch in &traced {
767 if branch_is_tangential(a, b, branch, tol)? {
776 if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
777 contacts.push(contact);
778 }
779 continue;
780 }
781 if branch.stopped == crate::march::Stopped::RanOut {
782 ogeom_bail!(
783 NotDone,
784 "a marched section ran out of its point budget before \
785 finishing; the seam is longer than the chord affords and \
786 fitting the truncation would state a curve that is not there"
787 );
788 }
789 for fitted in fitted_in_pieces(a, b, branch, options.tolerance, tol)? {
798 out.push(SectionCurve {
799 curve: fitted.curve.into(),
800 on_a: Some(fitted.on_a.into()),
801 on_b: Some(fitted.on_b.into()),
802 tolerance: options.marching.chord + fitted.fit_error,
805 exact: false,
806 closed: fitted.closed,
807 tangential: false,
808 });
809 }
810 }
811 for contact in &contacts {
812 let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
813 out.push(SectionCurve {
814 curve: fitted.curve.into(),
815 on_a: Some(fitted.on_a.into()),
816 on_b: Some(fitted.on_b.into()),
817 tolerance: options.marching.chord + fitted.fit_error,
818 exact: false,
819 closed: fitted.closed,
820 tangential: true,
821 });
822 }
823 if out.is_empty() {
824 return Ok(SurfaceIntersection::Apart);
825 }
826 Ok(SurfaceIntersection::Along(out))
827}
828
829fn fitted_in_pieces(
842 a: &SurfaceGeometry,
843 b: &SurfaceGeometry,
844 branch: &crate::march::Traced,
845 tolerance: f64,
846 tol: Tolerances,
847) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
848 const DEPTH: u32 = 6;
849 const FLOOR: usize = 16;
850 fn go(
851 a: &SurfaceGeometry,
852 b: &SurfaceGeometry,
853 branch: &crate::march::Traced,
854 tolerance: f64,
855 depth: u32,
856 tol: Tolerances,
857 ) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
858 let whole = approximate_branch(a, b, branch, tolerance, tol)?;
859 let step = branch
860 .points
861 .windows(2)
862 .map(|w| w[0].distance(w[1]))
863 .fold(0.0_f64, f64::max);
864 if whole.met
865 || whole.fit_error <= step
866 || branch.closed()
867 || depth == 0
868 || branch.points.len() < 2 * FLOOR
869 {
870 return Ok(vec![whole]);
871 }
872 let middle = branch.points.len() / 2;
873 let half = |range: core::ops::RangeInclusive<usize>| crate::march::Traced {
874 points: branch.points[range.clone()].to_vec(),
875 on_a: branch.on_a[range.clone()].to_vec(),
876 on_b: branch.on_b[range].to_vec(),
877 stopped: branch.stopped,
878 };
879 let mut pieces = go(a, b, &half(0..=middle), tolerance, depth - 1, tol)?;
880 pieces.extend(go(
881 a,
882 b,
883 &half(middle..=branch.points.len() - 1),
884 tolerance,
885 depth - 1,
886 tol,
887 )?);
888 let worst = pieces.iter().map(|p| p.fit_error).fold(0.0_f64, f64::max);
891 Ok(if worst < whole.fit_error {
892 pieces
893 } else {
894 vec![whole]
895 })
896 }
897 go(a, b, branch, tolerance, DEPTH, tol)
898}
899
900fn walk_contact(
909 a: &SurfaceGeometry,
910 b: &SurfaceGeometry,
911 fragment: &crate::march::Traced,
912 already: &[crate::march::Traced],
913 marching: Marching,
914 tol: Tolerances,
915) -> OgeomResult<Option<crate::march::Traced>> {
916 let middle = fragment.points.len() / 2;
917 let Some(point) = fragment.points.get(middle).copied() else {
918 return Ok(None);
919 };
920 for traced in already {
921 let spacing = traced
924 .points
925 .windows(2)
926 .map(|w| w[0].distance(w[1]))
927 .fold(0.0f64, f64::max);
928 let near = traced
929 .points
930 .iter()
931 .map(|p| p.distance(point))
932 .fold(f64::INFINITY, f64::min);
933 if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
934 return Ok(None);
935 }
936 }
937 let seed = crate::march::Contact {
938 point,
939 on_a: fragment.on_a[middle],
940 on_b: fragment.on_b[middle],
941 };
942 Ok(trace_tangential(a, b, seed, marching, tol)
947 .ok()
948 .filter(|traced| traced.points.len() >= 4))
949}
950
951fn branch_is_tangential(
954 a: &SurfaceGeometry,
955 b: &SurfaceGeometry,
956 branch: &crate::march::Traced,
957 tol: Tolerances,
958) -> OgeomResult<bool> {
959 use ogeom_geom::Surface as _;
960 let count = branch.points.len();
961 if count == 0 {
962 return Ok(true);
963 }
964 for k in 0..5 {
965 let i = (k * (count - 1)) / 4;
966 let (ua, va) = branch.on_a[i.min(count - 1)];
967 let (ub, vb) = branch.on_b[i.min(count - 1)];
968 let (dau, dav) = a.d1_at(ua, va, tol)?;
969 let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
970 let na = dau.cross(dav);
971 let nb = dbu.cross(dbv);
972 let (ma, mb) = (na.magnitude(), nb.magnitude());
973 if ma <= tol.confusion() || mb <= tol.confusion() {
974 continue;
975 }
976 if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
983 return Ok(false);
984 }
985 }
986 Ok(true)
987}
988
989#[must_use]
997pub fn exact_pcurve_of(
998 curve: &Curve,
999 surface: &SurfaceGeometry,
1000 tol: Tolerances,
1001) -> Option<PlanarCurve> {
1002 exact_pcurve(curve, curve.domain(), surface, tol)
1003}
1004
1005#[must_use]
1014pub fn exact_pcurve_over(
1015 curve: &Curve,
1016 range: (f64, f64),
1017 surface: &SurfaceGeometry,
1018 tol: Tolerances,
1019) -> Option<PlanarCurve> {
1020 exact_pcurve(curve, range, surface, tol)
1021}
1022
1023fn exact_pcurve(
1033 curve: &Curve,
1034 range: (f64, f64),
1035 surface: &SurfaceGeometry,
1036 tol: Tolerances,
1037) -> Option<PlanarCurve> {
1038 if let Curve::Trimmed(trimmed) = curve
1045 && !trimmed.is_reversed()
1046 {
1047 let window = ogeom_geom::Curve3d::domain(&**trimmed);
1048 let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
1049 return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
1050 .ok()
1051 .map(Into::into);
1052 }
1053 match surface {
1054 SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
1055 SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
1056 SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
1057 SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
1058 SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
1059 _ => None,
1060 }
1061}
1062
1063fn on_cone(
1072 curve: &Curve,
1073 range: (f64, f64),
1074 cone: ogeom_math::Cone,
1075 tol: Tolerances,
1076) -> Option<PlanarCurve> {
1077 let frame = cone.frame();
1078 let axis_z = frame.z().vector();
1079 let tau = core::f64::consts::TAU;
1080 match curve {
1081 Curve::Circle(c) => {
1082 let circle = c.circle();
1083 if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1084 return None;
1085 }
1086 let local = frame.to_local(circle.centre());
1087 if local.x.hypot(local.y) > tol.confusion() {
1088 return None;
1089 }
1090 let expected = cone
1092 .half_angle()
1093 .tan()
1094 .mul_add(local.z, cone.reference_radius());
1095 if (expected - circle.radius()).abs() > tol.confusion() * 10.0 {
1096 return None;
1097 }
1098 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1099 let at = frame.to_local(start);
1100 let phase = at.y.atan2(at.x);
1101 let winding = circle.frame().z().vector().dot(axis_z).signum();
1102 let towards =
1103 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1104 Some(
1105 Line2d::over(
1106 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1107 0.0,
1108 tau,
1109 )
1110 .ok()?
1111 .into(),
1112 )
1113 }
1114 Curve::Line(line) => {
1115 let axis = line.axis();
1118 let on = |t: f64| {
1119 let p = axis.location + axis.direction.vector() * t;
1120 cone.distance_to(p) <= tol.confusion() * 10.0
1121 };
1122 if !on(0.0) || !on(1.0) || !on(-1.0) {
1123 return None;
1124 }
1125 let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
1132 range
1133 } else {
1134 line.domain()
1135 };
1136 let mut local: Option<ogeom_math::Point> = None;
1142 for t in [lo, hi] {
1143 if !t.is_finite() {
1144 continue;
1145 }
1146 let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
1147 if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
1148 local = Some(candidate);
1149 }
1150 }
1151 let local = local?;
1152 if local.x.hypot(local.y) <= tol.confusion() {
1153 return None;
1154 }
1155 let u = local.y.atan2(local.x).rem_euclid(tau);
1156 let v_at = |t: f64| {
1160 frame
1161 .to_local(axis.location + axis.direction.vector() * t)
1162 .z
1163 };
1164 let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
1165 Some(
1166 ogeom_geom::BSpline2d::new(
1167 knots,
1168 vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
1169 tol,
1170 )
1171 .ok()?
1172 .into(),
1173 )
1174 }
1175 _ => None,
1176 }
1177}
1178
1179fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
1189 let Curve::Circle(c) = curve else {
1190 return None;
1191 };
1192 let circle = c.circle();
1193 let frame = torus.frame();
1194 let axis_z = frame.z().vector();
1195 let normal = circle.frame().z().vector();
1196 let local = frame.to_local(circle.centre());
1197 let tau = core::f64::consts::TAU;
1198
1199 if normal.cross(axis_z).magnitude() <= tol.angular()
1201 && local.x.hypot(local.y) <= tol.confusion()
1202 {
1203 let sin_v = local.z / torus.minor_radius();
1204 let cos_v = (circle.radius() - torus.major_radius()) / torus.minor_radius();
1205 if (sin_v.hypot(cos_v) - 1.0).abs() > tol.confusion() {
1206 return None;
1207 }
1208 let v = sin_v.atan2(cos_v);
1209 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1210 let at = frame.to_local(start);
1211 let phase = at.y.atan2(at.x);
1212 let winding = normal.dot(axis_z).signum();
1213 let towards =
1214 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1215 return Some(
1216 Line2d::over(
1217 ogeom_math::Axis2::new(Point2::new(phase, v), towards),
1218 0.0,
1219 tau,
1220 )
1221 .ok()?
1222 .into(),
1223 );
1224 }
1225
1226 if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
1228 && normal.dot(axis_z).abs() <= tol.angular()
1229 && (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
1230 && local.z.abs() <= tol.confusion()
1231 {
1232 let u = local.y.atan2(local.x);
1233 let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
1234 let xc = circle.frame().x().vector();
1235 let phase = xc.dot(axis_z).atan2(xc.dot(radial));
1236 let winding = normal.dot(radial.cross(axis_z)).signum();
1237 let towards =
1238 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
1239 return Some(
1240 Line2d::over(
1241 ogeom_math::Axis2::new(Point2::new(u, phase), towards),
1242 0.0,
1243 tau,
1244 )
1245 .ok()?
1246 .into(),
1247 );
1248 }
1249 None
1250}
1251
1252fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
1258 let frame = plane.frame();
1259 let flat = |p: Point| {
1260 let local = frame.to_local(p);
1261 Point2::new(local.x, local.y)
1262 };
1263 let flat_direction = |d: ogeom_math::Direction| {
1264 let tip = flat(frame.origin() + d.vector());
1265 ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
1266 };
1267 match curve {
1268 Curve::Line(line) => {
1269 let axis = line.axis();
1270 let through = flat(axis.location);
1271 let direction = flat_direction(axis.direction)?;
1272 let (lo, hi) = line.domain();
1273 Some(
1274 Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
1275 .ok()?
1276 .into(),
1277 )
1278 }
1279 Curve::Circle(c) => {
1280 let circle = c.circle();
1281 let frame2 = Frame2::from_axes(
1282 flat(circle.centre()),
1283 flat_direction(circle.frame().x())?,
1284 flat_direction(circle.frame().y())?,
1285 tol,
1286 )
1287 .ok()?;
1288 Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
1289 }
1290 Curve::Ellipse(e) => {
1291 let ellipse = e.ellipse();
1292 let frame2 = Frame2::from_axes(
1293 flat(ellipse.centre()),
1294 flat_direction(ellipse.frame().x())?,
1295 flat_direction(ellipse.frame().y())?,
1296 tol,
1297 )
1298 .ok()?;
1299 Some(
1300 Ellipse2d::new(
1301 Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
1302 .ok()?,
1303 )
1304 .into(),
1305 )
1306 }
1307 Curve::BSpline(b) => {
1308 let control = b
1313 .control_points()
1314 .iter()
1315 .map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
1316 .collect::<Result<Vec<_>, _>>()
1317 .ok()?;
1318 Some(
1319 ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
1320 .ok()?
1321 .into(),
1322 )
1323 }
1324 _ => None,
1325 }
1326}
1327
1328fn on_cylinder(
1335 curve: &Curve,
1336 range: (f64, f64),
1337 cylinder: ogeom_math::Cylinder,
1338 tol: Tolerances,
1339) -> Option<PlanarCurve> {
1340 let axis = cylinder.axis();
1341 let frame = cylinder.frame();
1342 match curve {
1343 Curve::Line(line) => {
1344 let direction = line.axis().direction;
1346 let along = direction.dot(axis.direction);
1347 if (along.abs() - 1.0).abs() > tol.angular() {
1348 return None;
1349 }
1350 let through = line.axis().location;
1351 if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
1352 return None;
1353 }
1354 let local = frame.to_local(through);
1355 let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
1356 let (lo, hi) = line.domain();
1360 let start = Point2::new(u, local.z);
1361 let towards =
1362 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
1363 .ok()?;
1364 Some(
1365 Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
1366 .ok()?
1367 .into(),
1368 )
1369 }
1370 Curve::Circle(c) => {
1371 let circle = c.circle();
1372 if circle
1374 .frame()
1375 .z()
1376 .cross_with(axis.direction.vector())
1377 .magnitude()
1378 > tol.angular()
1379 {
1380 return None;
1381 }
1382 if axis.distance_to(circle.centre()) > tol.confusion() {
1383 return None;
1384 }
1385 if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
1386 return None;
1387 }
1388 let local = frame.to_local(circle.centre());
1389 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1398 let at = frame.to_local(start);
1399 let phase = at.y.atan2(at.x);
1400 let winding = circle.frame().z().dot(axis.direction).signum();
1401 let towards =
1402 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1403 Some(
1404 Line2d::over(
1405 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1406 0.0,
1407 core::f64::consts::TAU,
1408 )
1409 .ok()?
1410 .into(),
1411 )
1412 }
1413 Curve::Ellipse(_) => {
1414 use ogeom_geom::Curve3d as _;
1420 let tau = core::f64::consts::TAU;
1421 let local = |t: f64| -> Option<ogeom_math::Point> {
1422 Some(frame.to_local(curve.point_at(t, tol).ok()?))
1423 };
1424 let l0 = local(0.0)?;
1425 let lq = local(tau / 4.0)?;
1426 let lh = local(tau / 2.0)?;
1427 let r = cylinder.radius();
1429 for l in [&l0, &lq, &lh] {
1430 if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
1431 return None;
1432 }
1433 }
1434 let phase = l0.y.atan2(l0.x);
1435 let uq = lq.y.atan2(lq.x);
1438 let step = (uq - phase).rem_euclid(tau);
1439 let winding = if (step - tau / 4.0).abs() < 1e-6 {
1440 1.0
1441 } else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
1442 -1.0
1443 } else {
1444 return None;
1445 };
1446 let c0 = f64::midpoint(l0.z, lh.z);
1448 let a = (l0.z - lh.z) / 2.0;
1449 let b = lq.z - c0;
1450 let candidate = ogeom_geom::Trig2d::new(
1454 Point2::new(phase, c0),
1455 ogeom_math::Vector2::new(winding, 0.0),
1456 ogeom_math::Vector2::new(0.0, a),
1457 ogeom_math::Vector2::new(0.0, b),
1458 range,
1459 )
1460 .ok()?;
1461 use ogeom_geom::Curve2d as _;
1464 for i in 0..7 {
1465 let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
1466 let l = local(t)?;
1467 let chart = candidate.point_at(t, tol).ok()?;
1468 let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
1469 if du.min(tau - du) > 1e-9 {
1470 return None;
1471 }
1472 if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
1473 return None;
1474 }
1475 }
1476 Some(PlanarCurve::Trig(candidate))
1477 }
1478 _ => None,
1479 }
1480}
1481
1482fn on_meridian(
1500 curve: &ogeom_geom::CircleCurve,
1501 range: (f64, f64),
1502 sphere: ogeom_math::Sphere,
1503 tol: Tolerances,
1504) -> Option<PlanarCurve> {
1505 let circle = curve.circle();
1506 let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
1511 let frame = sphere.frame();
1512 let z = frame.z().vector();
1513 if circle.centre().distance(sphere.centre()) > tol.confusion() {
1516 return None;
1517 }
1518 if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
1519 return None;
1520 }
1521 let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
1522 let (xz, yz) = (cx.dot(z), cy.dot(z));
1523 if xz.hypot(yz) < 1.0 - tol.angular() {
1526 return None;
1527 }
1528 let raw_alpha = yz.atan2(xz);
1529 let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
1532 let local = frame.to_local(sphere.centre() + w);
1533 let longitude = local.y.atan2(local.x);
1534
1535 let half = core::f64::consts::PI;
1536 let mid = f64::midpoint(range.0, range.1);
1537 let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
1540 let x_mid = if x_mid > half {
1541 x_mid - core::f64::consts::TAU
1542 } else {
1543 x_mid
1544 };
1545 let span = sweep * (range.1 - range.0);
1546 let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
1547 if x0 > x1 {
1548 core::mem::swap(&mut x0, &mut x1);
1549 }
1550 let alpha = sweep.mul_add(mid, -x_mid);
1555 let slack = tol.parametric().max(1e-9);
1556 let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
1557 (
1560 Point2::new(longitude, half.mul_add(0.5, alpha)),
1561 ogeom_math::Vector2::new(0.0, -sweep),
1562 )
1563 } else if x0 >= -half - slack && x1 <= slack {
1564 (
1566 Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
1567 ogeom_math::Vector2::new(0.0, sweep),
1568 )
1569 } else {
1570 return None;
1572 };
1573 let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
1574 let margin = (range.1 - range.0) * 0.25;
1575 let line: PlanarCurve = Line2d::over(
1576 ogeom_math::Axis2::new(axis_point, towards),
1577 range.0 - margin,
1578 range.1 + margin,
1579 )
1580 .ok()?
1581 .into();
1582
1583 for k in 0..=4 {
1586 let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
1587 let uv = line.point_at(t, tol).ok()?;
1588 let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
1589 let want = curve.point_at(t, tol).ok()?;
1590 if lifted.distance(want) > tol.confusion() {
1591 return None;
1592 }
1593 }
1594 Some(line)
1595}
1596
1597fn on_sphere(
1600 curve: &Curve,
1601 range: (f64, f64),
1602 sphere: ogeom_math::Sphere,
1603 tol: Tolerances,
1604) -> Option<PlanarCurve> {
1605 let Curve::Circle(c) = curve else {
1606 return None;
1607 };
1608 let circle = c.circle();
1609 let frame = sphere.frame();
1610 if circle
1613 .frame()
1614 .z()
1615 .cross_with(frame.z().vector())
1616 .magnitude()
1617 > tol.angular()
1618 {
1619 return on_meridian(c, range, sphere, tol);
1620 }
1621 let local = frame.to_local(circle.centre());
1622 if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
1623 return None;
1624 }
1625 let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
1626 if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
1628 return None;
1629 }
1630 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1631 let at = frame.to_local(start);
1632 let phase = at.y.atan2(at.x);
1633 let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
1636 let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1637 Some(
1638 Line2d::over(
1639 ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
1640 0.0,
1641 core::f64::consts::TAU,
1642 )
1643 .ok()?
1644 .into(),
1645 )
1646}
1647
1648#[cfg(test)]
1649#[allow(clippy::unwrap_used, clippy::expect_used)]
1650mod tests {
1651 use super::*;
1652 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
1653 use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
1654
1655 const T: Tolerances = Tolerances::millimetres();
1656
1657 fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
1658 SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
1659 }
1660
1661 fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
1662 let frame = Frame::new(
1663 Point::ORIGIN,
1664 Direction::new(axis, T).unwrap(),
1665 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1666 T,
1667 )
1668 .unwrap();
1669 CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
1670 .unwrap()
1671 .into()
1672 }
1673
1674 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1675 PlaneSurface::over(
1676 Plane::through(origin, Direction::new(normal, T).unwrap()),
1677 (-6.0, 6.0),
1678 (-6.0, 6.0),
1679 )
1680 .unwrap()
1681 .into()
1682 }
1683
1684 fn assert_same_parameter(
1687 section: &SectionCurve,
1688 surface: &SurfaceGeometry,
1689 pcurve: &PlanarCurve,
1690 samples: usize,
1691 ) {
1692 let (lo, hi) = section.curve.domain();
1693 let (plo, phi) = pcurve.domain();
1694 assert!(
1695 (lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
1696 "domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
1697 );
1698 for i in 0..=samples {
1699 #[allow(clippy::cast_precision_loss)]
1700 let t = lo + (hi - lo) * i as f64 / samples as f64;
1701 let on_curve = section.curve.point_at(t, T).unwrap();
1702 let at = pcurve.point_at(t, T).unwrap();
1703 let lifted = surface.point_at(at.x, at.y, T).unwrap();
1704 assert!(
1705 on_curve.is_equal(lifted, T),
1706 "at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
1707 );
1708 }
1709 }
1710
1711 #[test]
1712 fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
1713 let drum = cylinder(Vector::Z, 2.0);
1717 let cut = plane(Point::ORIGIN, Vector::X);
1718 let SurfaceIntersection::Along(curves) =
1719 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1720 else {
1721 panic!("a plane through a cylinder meets it along curves");
1722 };
1723 assert_eq!(curves.len(), 2);
1724 for section in &curves {
1725 assert!(section.exact);
1726 assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
1727 let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
1728 let on_b = section.on_b.as_ref().expect("and a plane pcurve");
1729 assert_same_parameter(section, &drum, on_a, 50);
1730 assert_same_parameter(section, &cut, on_b, 50);
1731 }
1732 }
1733
1734 #[test]
1735 fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
1736 let drum = cylinder(Vector::Z, 2.0);
1740 let angle: f64 = 0.5;
1741 let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
1742 let SurfaceIntersection::Along(curves) =
1743 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1744 else {
1745 panic!("an oblique plane meets the cylinder along its ellipse");
1746 };
1747 assert_eq!(curves.len(), 1);
1748 let section = &curves[0];
1749 assert!(section.exact);
1750 assert!(matches!(section.curve, Curve::Ellipse(_)));
1751 let on_drum = section
1752 .on_a
1753 .as_ref()
1754 .expect("the oblique ellipse now carries its cylinder pcurve");
1755 assert!(
1756 matches!(on_drum, PlanarCurve::Trig(_)),
1757 "the chart trace is trig-affine: {on_drum:?}"
1758 );
1759 assert_same_parameter(section, &drum, on_drum, 60);
1760 let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
1761 assert_same_parameter(section, &cut, on_plane, 60);
1762 }
1763
1764 #[test]
1765 fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
1766 let drum = cylinder(Vector::Z, 2.0);
1767 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
1768 let SurfaceIntersection::Along(curves) =
1769 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
1770 else {
1771 panic!("expected curves");
1772 };
1773 assert_eq!(curves.len(), 1);
1774 let section = &curves[0];
1775 assert!(section.closed);
1776 assert!(matches!(section.curve, Curve::Circle(_)));
1777 assert!(matches!(
1779 section.on_a.as_ref().unwrap(),
1780 PlanarCurve::Line(_)
1781 ));
1782 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
1783 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
1784 }
1785
1786 #[test]
1787 fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
1788 let drum = cylinder(Vector::Z, 1.5);
1789 let ball = sphere(Point::ORIGIN, 3.0);
1790 let SurfaceIntersection::Along(curves) =
1791 intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
1792 else {
1793 panic!("expected curves");
1794 };
1795 assert_eq!(curves.len(), 2);
1796 for section in &curves {
1797 assert!(section.exact);
1798 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
1799 assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
1800 }
1801 }
1802
1803 fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
1804 let frame = Frame::new(
1805 origin,
1806 Direction::new(axis, T).unwrap(),
1807 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
1808 T,
1809 )
1810 .unwrap();
1811 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
1812 .into()
1813 }
1814
1815 #[test]
1816 fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
1817 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1818 let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
1819 let SurfaceIntersection::Along(curves) =
1820 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1821 else {
1822 panic!("an axis-normal plane through the tube meets it along curves");
1823 };
1824 assert_eq!(curves.len(), 2);
1825 let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
1826 let mut radii: Vec<f64> = curves
1827 .iter()
1828 .map(|s| {
1829 let Curve::Circle(c) = &s.curve else {
1830 panic!("a parallel is a circle");
1831 };
1832 c.circle().radius()
1833 })
1834 .collect();
1835 radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
1836 assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
1837 assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
1838 for section in &curves {
1839 assert!(section.exact);
1840 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1841 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
1842 }
1843 }
1844
1845 #[test]
1846 fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
1847 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1852 let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
1853 let SurfaceIntersection::Along(curves) =
1854 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
1855 else {
1856 panic!("the rolling plane touches along a circle, not at points");
1857 };
1858 assert_eq!(curves.len(), 1);
1859 let Curve::Circle(c) = &curves[0].curve else {
1860 panic!("the tangency is a circle");
1861 };
1862 assert!((c.circle().radius() - 2.0).abs() < 1e-12);
1863 assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
1864 assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
1865 }
1866
1867 #[test]
1868 fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
1869 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1870 let drum = cylinder(Vector::Z, 2.2);
1871 let SurfaceIntersection::Along(curves) =
1872 intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
1873 else {
1874 panic!("a coaxial cylinder through the tube meets it along curves");
1875 };
1876 assert_eq!(curves.len(), 2);
1877 for section in &curves {
1878 assert!(section.exact);
1879 let Curve::Circle(c) = §ion.curve else {
1880 panic!("a parallel is a circle");
1881 };
1882 assert!((c.circle().radius() - 2.2).abs() < 1e-12);
1883 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
1884 assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
1885 }
1886
1887 let grazing = cylinder(Vector::Z, 2.5);
1889 let SurfaceIntersection::Along(touch) =
1890 intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
1891 else {
1892 panic!("the grazing cylinder touches along the equator");
1893 };
1894 assert_eq!(touch.len(), 1);
1895 assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
1896 assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
1897 }
1898
1899 #[test]
1900 fn coaxial_tori_are_the_same_or_meet_in_parallels() {
1901 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
1902 assert!(matches!(
1903 intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
1904 SurfaceIntersection::Same
1905 ));
1906
1907 let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
1910 let SurfaceIntersection::Along(curves) =
1911 intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
1912 else {
1913 panic!("lifted coaxial tori meet along curves");
1914 };
1915 assert_eq!(curves.len(), 2);
1916 for section in &curves {
1917 assert!(section.exact);
1918 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
1919 assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
1920 }
1921 }
1922
1923 #[test]
1924 fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
1925 let a = cylinder(Vector::Z, 1.0);
1927 let b = cylinder(Vector::X, 1.6);
1928 let options = IntersectOptions {
1929 tolerance: 1e-5,
1930 marching: Marching {
1931 chord: 1e-5,
1932 ..Marching::default()
1933 },
1934 };
1935 let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
1936 else {
1937 panic!("crossed cylinders meet along curves");
1938 };
1939 assert_eq!(curves.len(), 2);
1940 for section in &curves {
1941 assert!(!section.exact);
1942 assert!(section.closed);
1943 assert!(
1944 section.tolerance <= 1e-5 + 1e-4,
1945 "got {}",
1946 section.tolerance
1947 );
1948 assert!(section.on_a.is_some() && section.on_b.is_some());
1949
1950 let (lo, hi) = section.curve.domain();
1952 for i in 0..=200 {
1953 #[allow(clippy::cast_precision_loss)]
1954 let t = lo + (hi - lo) * f64::from(i) / 200.0;
1955 let p = section.curve.point_at(t, T).unwrap();
1956 let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
1957 unreachable!()
1958 };
1959 let off = x
1960 .cylinder()
1961 .distance_to(p)
1962 .abs()
1963 .max(y.cylinder().distance_to(p).abs());
1964 assert!(
1965 off <= section.tolerance * 2.0,
1966 "at t = {t} the fitted curve is {off:e} off, tolerance {}",
1967 section.tolerance
1968 );
1969 }
1970 }
1971 }
1972
1973 #[test]
1977 fn a_plane_all_but_along_the_axis_still_meets_a_short_drum() {
1978 let drum = cylinder(Vector::Z, 1.0);
1979 let wall: SurfaceGeometry = PlaneSurface::over(
1980 Plane::through(
1981 Point::new(0.0, 0.6, 0.0),
1982 Direction::new(Vector::new(0.0, 1.0, 1e-4), T).unwrap(),
1983 ),
1984 (-1e9, 1e9),
1985 (-1e9, 1e9),
1986 )
1987 .unwrap()
1988 .into();
1989 let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
1990 let SurfaceIntersection::Along(sections) = met else {
1991 panic!("the wall crosses the drum: {met:?}");
1992 };
1993 assert_eq!(sections.len(), 1);
1994 let curve = §ions[0].curve;
1995 let (lo, hi) = curve.domain();
1996 let inside = (0..=100_000).any(|k| {
1997 let p = curve
1998 .point_at(lo + (hi - lo) * f64::from(k) / 100_000.0, T)
1999 .unwrap();
2000 p.z.abs() <= 4.0
2001 });
2002 assert!(inside, "and the section runs through the drum's height");
2003 }
2004
2005 fn on_both(section: &SectionCurve, a: &SurfaceGeometry, b: &SurfaceGeometry) {
2008 let (lo, hi) = section.curve.domain();
2009 for k in 0..=64 {
2010 let p = section
2011 .curve
2012 .point_at(lo + (hi - lo) * f64::from(k) / 64.0, T)
2013 .unwrap();
2014 for surface in [a, b] {
2015 let off = match surface {
2016 SurfaceGeometry::Plane(plane) => plane.plane().signed_distance_to(p).abs(),
2017 SurfaceGeometry::Cylinder(drum) => {
2018 let axis = drum.cylinder().axis();
2019 let rel = p - axis.location;
2020 let d = axis.direction.vector();
2021 ((rel - d * rel.dot(d)).magnitude() - drum.cylinder().radius()).abs()
2022 }
2023 _ => unreachable!("planes and drums only"),
2024 };
2025 assert!(
2026 off <= section.tolerance + 1e-9,
2027 "{p:?} is {off:e} off, stated {:e}",
2028 section.tolerance
2029 );
2030 }
2031 }
2032 }
2033
2034 #[test]
2040 fn a_plane_all_but_along_a_drums_axis_meets_it_in_two_near_lines() {
2041 let drum = cylinder(Vector::Z, 1.0);
2042 let wall: SurfaceGeometry = PlaneSurface::over(
2043 Plane::through(
2044 Point::new(0.0, 0.99, 0.0),
2045 Direction::new(Vector::new(0.0, 1.0, 2e-5), T).unwrap(),
2046 ),
2047 (-1e9, 1e9),
2048 (-1e9, 1e9),
2049 )
2050 .unwrap()
2051 .into();
2052 let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
2053 let SurfaceIntersection::Along(sections) = met else {
2054 panic!("the wall crosses the drum: {met:?}");
2055 };
2056 assert_eq!(sections.len(), 2);
2057 for section in §ions {
2058 assert!(section.tolerance > 0.0 && section.tolerance <= 1e-5);
2059 on_both(section, &wall, &drum);
2060 }
2061 }
2062
2063 #[test]
2067 fn drums_all_but_parallel_meet_in_two_near_lines() {
2068 let drill = cylinder(Vector::Z, 1.0);
2069 let frame = Frame::new(
2070 Point::new(1.5, 0.0, 0.0),
2071 Direction::new(Vector::new(5e-5, 0.0, 1.0), T).unwrap(),
2072 Direction::X,
2073 T,
2074 )
2075 .unwrap();
2076 let bore: SurfaceGeometry =
2077 CylinderSurface::new(Cylinder::new(frame, 1.0, T).unwrap(), (-3.0, 3.0))
2078 .unwrap()
2079 .into();
2080 let met = intersect_surfaces(&drill, &bore, IntersectOptions::default(), T).unwrap();
2081 let SurfaceIntersection::Along(sections) = met else {
2082 panic!("the drums cross: {met:?}");
2083 };
2084 assert_eq!(sections.len(), 2);
2085 for section in §ions {
2086 assert!(!section.exact && section.tolerance <= 1e-5);
2087 let (lo, hi) = section.curve.domain();
2088 let (p, q) = (
2089 section.curve.point_at(lo, T).unwrap(),
2090 section.curve.point_at(hi, T).unwrap(),
2091 );
2092 assert!(
2093 (p.z - q.z).abs() > 5.9,
2094 "over the shared height: {p:?} {q:?}"
2095 );
2096 on_both(section, &drill, &bore);
2097 }
2098 }
2099
2100 #[test]
2101 fn exact_lines_are_clipped_to_the_surfaces_extents() {
2102 let drum = cylinder(Vector::Z, 2.0);
2107 let cut = plane(Point::ORIGIN, Vector::X);
2108 let SurfaceIntersection::Along(curves) =
2109 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2110 else {
2111 panic!("expected curves");
2112 };
2113 for section in &curves {
2114 let (lo, hi) = section.curve.domain();
2115 assert!(
2117 hi - lo <= 8.0 + 1e-9,
2118 "the line was not clipped: [{lo}, {hi}]"
2119 );
2120 let start = section.curve.point_at(lo, T).unwrap();
2121 let end = section.curve.point_at(hi, T).unwrap();
2122 assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
2123 }
2124
2125 let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
2129 assert_eq!(
2130 intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
2131 SurfaceIntersection::Apart
2132 );
2133 }
2134
2135 #[test]
2136 fn the_degenerate_answers_pass_through() {
2137 assert_eq!(
2138 intersect_surfaces(
2139 &sphere(Point::ORIGIN, 1.0),
2140 &sphere(Point::new(5.0, 0.0, 0.0), 1.0),
2141 IntersectOptions::default(),
2142 T
2143 )
2144 .unwrap(),
2145 SurfaceIntersection::Apart
2146 );
2147 assert_eq!(
2148 intersect_surfaces(
2149 &sphere(Point::ORIGIN, 1.0),
2150 &sphere(Point::ORIGIN, 1.0),
2151 IntersectOptions::default(),
2152 T
2153 )
2154 .unwrap(),
2155 SurfaceIntersection::Same
2156 );
2157 assert!(matches!(
2158 intersect_surfaces(
2159 &plane(Point::ORIGIN, Vector::Z),
2160 &sphere(Point::new(0.0, 0.0, 2.0), 2.0),
2161 IntersectOptions::default(),
2162 T
2163 )
2164 .unwrap(),
2165 SurfaceIntersection::Touching(ref p) if p.len() == 1
2166 ));
2167 }
2168
2169 #[test]
2170 fn unusable_options_are_refused() {
2171 let a = sphere(Point::ORIGIN, 1.0);
2172 let b = plane(Point::ORIGIN, Vector::Z);
2173 for tolerance in [0.0, -1.0, f64::NAN] {
2174 let options = IntersectOptions {
2175 tolerance,
2176 ..IntersectOptions::default()
2177 };
2178 assert!(intersect_surfaces(&a, &b, options, T).is_err());
2179 }
2180 }
2181
2182 #[test]
2183 fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
2184 let drum: SurfaceGeometry = CylinderSurface::new(
2192 Cylinder::new(
2193 Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
2194 0.5,
2195 T,
2196 )
2197 .unwrap(),
2198 (0.0, 3.0),
2199 )
2200 .unwrap()
2201 .into();
2202 for normal in [Direction::Z, -Direction::Z] {
2203 let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
2204 let ground: SurfaceGeometry =
2205 PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
2206 .unwrap()
2207 .into();
2208 let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
2209 let SurfaceIntersection::Along(curves) = met else {
2210 panic!("a plane through a cylinder sections it");
2211 };
2212 for sc in &curves {
2213 let pcurve = sc
2214 .on_b
2215 .as_ref()
2216 .expect("a circle on its cylinder has a pcurve");
2217 let (lo, hi) = sc.curve.domain();
2218 for i in 0..8 {
2219 let t = lo + (hi - lo) * f64::from(i) / 8.0;
2220 let p3 = sc.curve.point_at(t, T).unwrap();
2221 let uv = pcurve.point_at(t, T).unwrap();
2222 let lifted = drum
2223 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2224 .unwrap();
2225 assert!(
2226 p3.distance(lifted) < 1e-9,
2227 "normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
2228 );
2229 }
2230 }
2231 }
2232 }
2233
2234 #[test]
2240 fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
2241 use ogeom_geom::Surface as _;
2242 let half = core::f64::consts::PI;
2243 for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
2244 let ball = sphere(centre, radius);
2245 let SurfaceGeometry::Sphere(s) = &ball else {
2246 panic!("a sphere surface");
2247 };
2248 for azimuth in [0.0_f64, 0.7, 2.4] {
2251 let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
2252 let cut = plane(centre, normal);
2253 let SurfaceIntersection::Along(curves) =
2254 intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
2255 else {
2256 panic!("a plane through the centre meets the ball along a circle");
2257 };
2258 assert_eq!(curves.len(), 1, "one great circle");
2259 let circle = &curves[0].curve;
2260 assert!(curves[0].exact);
2261 assert!(
2263 exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
2264 "the whole meridian has no single chart image"
2265 );
2266 for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
2267 let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
2268 .expect("half a meridian has an exact pcurve");
2269 assert!(
2270 matches!(pcurve, PlanarCurve::Line(_)),
2271 "and it is a straight line in the chart"
2272 );
2273 for i in 0..=16 {
2274 let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
2275 let want = circle.point_at(t, T).unwrap();
2276 let uv = pcurve.point_at(t, T).unwrap();
2277 assert!(
2278 uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
2279 "the latitude stays inside the chart: {}",
2280 uv.y
2281 );
2282 let lifted = ball
2283 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2284 .unwrap();
2285 assert!(
2286 want.distance(lifted) < 1e-9,
2287 "azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
2288 );
2289 }
2290 }
2291 assert!(
2294 exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
2295 "a range across a pole has no one line"
2296 );
2297 let _ = s;
2298 }
2299 }
2300 }
2301
2302 #[test]
2311 fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
2312 use ogeom_geom::TrimmedCurve;
2313 let drum = cylinder(Vector::Z, 2.0);
2314 let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
2315 let SurfaceIntersection::Along(curves) =
2317 intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
2318 else {
2319 panic!("a plane across a cylinder meets it in a circle");
2320 };
2321 let whole = curves[0].curve.clone();
2322 let (lo, hi) = whole.domain();
2323 let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
2324 .unwrap()
2325 .into();
2326
2327 for surface in [&drum, &ground] {
2328 let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
2329 let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
2330 let (a, b) = quarter.domain();
2333 for i in 0..=8 {
2334 let t = (b - a).mul_add(f64::from(i) / 8.0, a);
2335 let (whole_at, part_at) =
2336 (full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
2337 assert!(
2338 whole_at.distance(part_at) < 1e-12,
2339 "the trim carries the basis: {whole_at:?} against {part_at:?}"
2340 );
2341 let lifted = surface
2343 .point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
2344 .or_else(|_| surface.point_at(part_at.x, part_at.y, T))
2345 .unwrap();
2346 assert!(
2347 lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
2348 "same-parameter, still"
2349 );
2350 }
2351 }
2352 }
2353 #[test]
2354 fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
2355 use ogeom_geom::ConeSurface;
2356 let cone =
2364 ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2365 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
2366 let u_true = 0.01_f64;
2367 let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
2368 let direction =
2371 Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
2372 let far = -7.0e5;
2373 let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
2374 let line = ogeom_geom::LineCurve::over(
2375 ogeom_math::Axis::new(origin, direction),
2376 far.abs() - 1.0,
2377 far.abs() + 1.0,
2378 )
2379 .unwrap();
2380 let curve: Curve = line.into();
2381 let range = ogeom_geom::Curve3d::domain(&curve);
2382 let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
2383 let at = pcurve.point_at(range.0, T).unwrap();
2384 let tau = core::f64::consts::TAU;
2385 let gap = (at.x - u_true)
2386 .rem_euclid(tau)
2387 .min(tau - (at.x - u_true).rem_euclid(tau));
2388 assert!(
2389 gap < 1e-6,
2390 "the ruling's chart angle must be the used side's: got u {} against {u_true}",
2391 at.x
2392 );
2393 }
2394}