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)
167 .or_else(|| ball_through_drum(a, b, tol))
168 .or_else(|| axial_plane_revolution(a, b, tol))
169 .or_else(|| plane_along_spline_lines(a, b, tol))
170 {
171 Some(sections) if sections.is_empty() => Ok(SurfaceIntersection::Apart),
172 Some(sections) => Ok(SurfaceIntersection::Along(sections)),
173 None => marched(a, b, options, tol),
174 },
175 }
176}
177
178fn axial_plane_revolution(
191 a: &SurfaceGeometry,
192 b: &SurfaceGeometry,
193 tol: Tolerances,
194) -> Option<Vec<SectionCurve>> {
195 const SAMPLES: u32 = 64;
196 let (plane, revolution, plane_first) = match (a, b) {
197 (SurfaceGeometry::Plane(p), SurfaceGeometry::Revolution(r)) => (p, r, true),
198 (SurfaceGeometry::Revolution(r), SurfaceGeometry::Plane(p)) => (p, r, false),
199 _ => return None,
200 };
201 let cut = plane.plane();
202 let axis = revolution.axis();
203 let normal = cut.normal();
204 if normal.dot(axis.direction).abs() > tol.angular()
205 || cut.signed_distance_to(axis.location).abs() > tol.confusion()
206 {
207 return None;
208 }
209 let profile = revolution.curve();
210 let (v0, v1) = profile.domain();
211 let at = |k: u32| v0 + (v1 - v0) * f64::from(k) / f64::from(SAMPLES);
212 let radial = |v: f64| {
213 let p = profile.point_at(v, tol).ok()?;
214 Some(p - axis.project(p))
215 };
216 let mut widest = ogeom_math::Vector::ZERO;
218 for k in 0..=SAMPLES {
219 let r = radial(at(k))?;
220 if r.magnitude() > widest.magnitude() {
221 widest = r;
222 }
223 }
224 let side = ogeom_math::Direction::new(widest, tol).ok()?;
225 let across = axis.direction.cross_with(side.vector());
226 let offset = |v: f64| radial(v).map(|r| (r.dot(side.vector()), r.dot(across)));
228 for k in 0..=SAMPLES {
229 let (_, off) = offset(at(k))?;
230 if off.abs() > tol.confusion() {
231 return None;
232 }
233 }
234 let mut cuts = vec![v0];
237 for k in 0..SAMPLES {
238 let (mut lo, mut hi) = (at(k), at(k + 1));
239 let (s_lo, s_hi) = (offset(lo)?.0, offset(hi)?.0);
240 if s_lo.abs() <= tol.confusion() || s_lo * s_hi >= 0.0 {
241 continue;
242 }
243 for _ in 0..80 {
244 let mid = f64::midpoint(lo, hi);
245 if offset(mid)?.0 * s_lo > 0.0 {
246 lo = mid;
247 } else {
248 hi = mid;
249 }
250 }
251 cuts.push(f64::midpoint(lo, hi));
252 }
253 cuts.push(v1);
254 let out = axis.direction.cross_with(normal.vector());
256 let first = across.dot(out).atan2(side.vector().dot(out));
257 let (u0, u1) = revolution.domain().0;
258 let turns: Vec<f64> = [first, first + core::f64::consts::PI]
259 .into_iter()
260 .filter_map(|u| {
261 let u = u0 + (u - u0).rem_euclid(core::f64::consts::TAU);
262 let u = if (u - u0 - core::f64::consts::TAU).abs() <= tol.angular() {
263 u0
264 } else {
265 u
266 };
267 (u <= u1 + tol.angular()).then_some(u.min(u1))
268 })
269 .collect();
270 let mut sections = Vec::new();
271 for &u in &turns {
272 let turned = ogeom_geom::Transformable::transformed(
273 profile,
274 &ogeom_math::Transform::rotation(axis, u),
275 tol,
276 )
277 .ok()?;
278 for piece in cuts.windows(2) {
279 let (va, vb) = (piece[0], piece[1]);
280 if vb - va <= tol.parametric() {
281 continue;
282 }
283 let curve: Curve = ogeom_geom::TrimmedCurve::new(turned.clone(), va, vb, tol)
284 .ok()?
285 .into();
286 let column: PlanarCurve = Line2d::over(
287 ogeom_math::Axis2::new(Point2::new(u, 0.0), ogeom_math::Direction2::Y),
288 va,
289 vb,
290 )
291 .ok()?
292 .into();
293 let flat = exact_pcurve(&curve, (va, vb), a_or_b(plane_first, a, b), tol);
294 let (on_a, on_b) = if plane_first {
295 (flat, Some(column))
296 } else {
297 (Some(column), flat)
298 };
299 sections.push(SectionCurve {
300 on_a,
301 on_b,
302 tolerance: 0.0,
303 exact: true,
304 closed: false,
305 tangential: false,
306 curve,
307 });
308 }
309 }
310 Some(sections)
311}
312
313fn plane_along_spline_lines(
326 a: &SurfaceGeometry,
327 b: &SurfaceGeometry,
328 tol: Tolerances,
329) -> Option<Vec<SectionCurve>> {
330 const SAMPLES: u32 = 96;
331 let (plane, spline, plane_first) = match (a, b) {
332 (SurfaceGeometry::Plane(p), SurfaceGeometry::BSpline(s)) => (p.plane(), s, true),
333 (SurfaceGeometry::BSpline(s), SurfaceGeometry::Plane(p)) => (p.plane(), s, false),
334 _ => return None,
335 };
336 let distance = |p: Point| plane.signed_distance_to(p);
337 let ((u0, u1), (v0, v1)) = spline.domain();
338 let line_at = |along_u: bool, t: f64| -> Option<ogeom_geom::BSplineCurve> {
341 if along_u {
342 spline.iso_u_curve(t, tol).ok()
343 } else {
344 spline.iso_v_curve(t, tol).ok()
345 }
346 };
347 let weighted = |curve: &ogeom_geom::BSplineCurve| -> Vec<f64> {
348 curve
349 .control_points()
350 .iter()
351 .map(|w| w.weight * distance(w.point()))
352 .collect()
353 };
354 let lies_in = |curve: &ogeom_geom::BSplineCurve| {
355 curve
356 .control_points()
357 .iter()
358 .all(|w| distance(w.point()).abs() <= tol.confusion())
359 };
360 let has_length = |curve: &ogeom_geom::BSplineCurve| {
361 let first = curve.control_points()[0].point();
362 curve
363 .control_points()
364 .iter()
365 .any(|w| w.point().distance(first) > tol.confusion())
366 };
367 let found = |along_u: bool| -> Option<Vec<f64>> {
370 let (lo, hi) = if along_u { (u0, u1) } else { (v0, v1) };
371 let at = |k: u32| lo + (hi - lo) * f64::from(k) / f64::from(SAMPLES);
372 let rows: Vec<Vec<f64>> = (0..=SAMPLES)
373 .map(|k| line_at(along_u, at(k)).map(|c| weighted(&c)))
374 .collect::<Option<_>>()?;
375 let count = rows[0].len();
376 if rows.iter().any(|r| r.len() != count) {
377 return None;
378 }
379 let widest = (0..count).max_by(|&i, &j| {
380 let spread = |i: usize| rows.iter().fold(0.0_f64, |m, r| m.max(r[i].abs()));
381 spread(i).total_cmp(&spread(j))
382 })?;
383 let mut roots = vec![lo, hi];
384 for k in 0..SAMPLES {
385 let (mut a, mut b) = (at(k), at(k + 1));
386 let (da, db) = (rows[k as usize][widest], rows[k as usize + 1][widest]);
387 if da == 0.0 {
388 roots.push(a);
389 continue;
390 }
391 if da * db > 0.0 {
392 continue;
393 }
394 let sign = da.signum();
395 for _ in 0..80 {
396 let mid = f64::midpoint(a, b);
397 let d = weighted(&line_at(along_u, mid)?)[widest];
398 if d * sign > 0.0 {
399 a = mid;
400 } else {
401 b = mid;
402 }
403 }
404 roots.push(f64::midpoint(a, b));
405 }
406 roots.sort_by(f64::total_cmp);
407 roots.dedup_by(|x, y| (*x - *y).abs() <= tol.parametric());
408 Some(
409 roots
410 .into_iter()
411 .filter(|&t| line_at(along_u, t).is_some_and(|c| lies_in(&c) && has_length(&c)))
412 .collect(),
413 )
414 };
415 let columns = found(true)?;
416 let rows = found(false)?;
417 if columns.is_empty() && rows.is_empty() {
418 return None;
419 }
420 let strip = |lines: &[f64], t: f64| lines.iter().filter(|&&x| x < t).count();
423 let near_line = |lines: &[f64], t: f64, span: f64| {
424 lines
425 .iter()
426 .any(|&x| (x - t).abs() <= span / f64::from(SAMPLES) * 0.25)
427 };
428 let mut sides: std::collections::HashMap<(usize, usize), f64> =
429 std::collections::HashMap::new();
430 let band = tol.confusion() * 10.0;
431 for i in 0..=SAMPLES {
432 let u = u0 + (u1 - u0) * (f64::from(i) + 0.5) / f64::from(SAMPLES + 1);
433 if near_line(&columns, u, u1 - u0) {
434 continue;
435 }
436 for j in 0..=SAMPLES {
437 let v = v0 + (v1 - v0) * (f64::from(j) + 0.5) / f64::from(SAMPLES + 1);
438 if near_line(&rows, v, v1 - v0) {
439 continue;
440 }
441 let d = distance(spline.point_at(u, v, tol).ok()?);
442 if d.abs() <= band {
443 continue;
444 }
445 let cell = (strip(&columns, u), strip(&rows, v));
446 match sides.get(&cell) {
447 Some(side) if side * d < 0.0 => return None,
448 Some(_) => {}
449 None => {
450 sides.insert(cell, d.signum());
451 }
452 }
453 }
454 }
455 let closed_u = spline.is_closed_u(tol);
461 let closed_v = spline.is_closed_v(tol);
462 let side_of = |cu: Option<usize>, cv: Option<usize>| -> Option<f64> {
463 let mut found = sides
464 .iter()
465 .filter(|((u, v), _)| cu.is_none_or(|c| c == *u) && cv.is_none_or(|c| c == *v))
466 .map(|(_, s)| *s);
467 let first = found.next()?;
468 found.all(|s| s == first).then_some(first)
469 };
470 let crosses = |k: usize, count: usize, closed: bool, cell: &dyn Fn(usize) -> Option<f64>| {
471 let below = cell(k).or_else(|| closed.then(|| (0..=count).rev().find_map(cell)).flatten());
472 let above = cell(k + 1).or_else(|| closed.then(|| (0..=count).find_map(cell)).flatten());
473 matches!((below, above), (Some(x), Some(y)) if x * y < 0.0)
474 };
475 for k in 0..columns.len() {
476 if !crosses(k, columns.len(), closed_u, &|c| side_of(Some(c), None)) {
477 return None;
478 }
479 }
480 for k in 0..rows.len() {
481 if !crosses(k, rows.len(), closed_v, &|c| side_of(None, Some(c))) {
482 return None;
483 }
484 }
485 let mut sections = Vec::new();
486 let mut emit = |along_u: bool, t: f64| -> Option<()> {
487 let iso = line_at(along_u, t)?;
488 let curve: Curve = iso.into();
489 let range = curve.domain();
490 let chart: PlanarCurve = if along_u {
491 Line2d::over(
492 ogeom_math::Axis2::new(Point2::new(t, 0.0), ogeom_math::Direction2::Y),
493 range.0,
494 range.1,
495 )
496 } else {
497 Line2d::over(
498 ogeom_math::Axis2::new(Point2::new(0.0, t), ogeom_math::Direction2::X),
499 range.0,
500 range.1,
501 )
502 }
503 .ok()?
504 .into();
505 let flat = exact_pcurve(&curve, range, a_or_b(plane_first, a, b), tol)?;
506 let (on_a, on_b) = if plane_first {
507 (Some(flat), Some(chart))
508 } else {
509 (Some(chart), Some(flat))
510 };
511 sections.push(SectionCurve {
512 on_a,
513 on_b,
514 tolerance: 0.0,
515 exact: true,
516 closed: curve.is_closed(tol),
517 tangential: false,
518 curve,
519 });
520 Some(())
521 };
522 for (k, &u) in columns.iter().enumerate() {
525 if closed_u
526 && k + 1 == columns.len()
527 && k > 0
528 && columns[0] == u0
529 && (u - u1).abs() <= tol.parametric()
530 {
531 continue;
532 }
533 emit(true, u)?;
534 }
535 for (k, &v) in rows.iter().enumerate() {
536 if closed_v
537 && k + 1 == rows.len()
538 && k > 0
539 && rows[0] == v0
540 && (v - v1).abs() <= tol.parametric()
541 {
542 continue;
543 }
544 emit(false, v)?;
545 }
546 Some(sections)
547}
548
549fn a_or_b<'s>(first: bool, a: &'s SurfaceGeometry, b: &'s SurfaceGeometry) -> &'s SurfaceGeometry {
551 if first { a } else { b }
552}
553
554fn near_parallel_drums(
571 a: &SurfaceGeometry,
572 b: &SurfaceGeometry,
573 tol: Tolerances,
574) -> Option<Vec<SectionCurve>> {
575 const LEAN: f64 = 1e-3;
576 let (SurfaceGeometry::Cylinder(sa), SurfaceGeometry::Cylinder(sb)) = (a, b) else {
577 return None;
578 };
579 let (ca, cb) = (sa.cylinder(), sb.cylinder());
580 let (axis_a, axis_b) = (ca.axis(), cb.axis());
581 let (da, db) = (axis_a.direction.vector(), axis_b.direction.vector());
582 let (ra, rb) = (ca.radius(), cb.radius());
583 let cos = da.dot(db);
584 if da.cross(db).magnitude() > LEAN || cos.abs() < 0.5 {
585 return None;
586 }
587 let (pa, pb) = (axis_a.location, axis_b.location);
588 let (_, (a0, a1)) = a.domain();
590 let (_, (b0, b1)) = b.domain();
591 let along = |v: f64| (pb - pa).dot(da) + v * cos;
592 let (lo, hi) = (
593 a0.min(a1).max(along(b0).min(along(b1))),
594 a0.max(a1).min(along(b0).max(along(b1))),
595 );
596 if !(lo.is_finite() && hi.is_finite()) {
597 return None;
598 }
599 if hi - lo <= tol.confusion() {
600 return Some(Vec::new());
601 }
602 let meet = |z: f64| -> Option<[Point; 2]> {
606 let centre_a = pa + da * z;
607 let s = (centre_a - pb).dot(da) / cos;
608 let centre_b = pb + db * s;
609 let mut between = centre_b - centre_a;
610 between = between - da * between.dot(da);
611 let d = between.magnitude();
612 let margin = tol.confusion() * 50.0;
617 if d <= margin || d >= ra + rb - margin || d <= (ra - rb).abs() + margin {
618 return None;
619 }
620 let x = (d * d + ra * ra - rb * rb) / (2.0 * d);
621 let h = (ra * ra - x * x).max(0.0).sqrt();
622 let ex = between / d;
623 let ey = da.cross(ex);
624 Some([centre_a + ex * x + ey * h, centre_a + ex * x - ey * h])
625 };
626 lines_through_stations(lo, hi, meet, rb * (1.0 / cos.abs() - 1.0), tol)
627}
628
629const NEAR_PARALLEL_STRAY: f64 = 1e-5;
632
633fn lines_through_stations(
641 lo: f64,
642 hi: f64,
643 meet: impl Fn(f64) -> Option<[Point; 2]>,
644 stated: f64,
645 tol: Tolerances,
646) -> Option<Vec<SectionCurve>> {
647 const STATIONS: u32 = 32;
648 const STRAIGHT: f64 = 1e-6;
649 let at = |k: f64| (hi - lo).mul_add(k / f64::from(STATIONS), lo);
650 let heights: Vec<f64> = (0..=STATIONS).map(|k| at(f64::from(k))).collect();
651 let met: Vec<[Point; 2]> = heights.iter().map(|&z| meet(z)).collect::<Option<_>>()?;
652 let between: Vec<[Point; 2]> = (0..STATIONS)
653 .map(|k| meet(at(f64::from(k) + 0.5)))
654 .collect::<Option<_>>()?;
655 let mut out = Vec::with_capacity(2);
656 for side in 0..2 {
657 let (from, to) = (met[0][side], met[met.len() - 1][side]);
658 let span = to - from;
659 let length = span.magnitude();
660 if length <= tol.confusion() {
661 return None;
662 }
663 let off_line = |p: Point| {
664 let t = (p - from).dot(span) / (length * length);
665 p.distance(from + span * t)
666 };
667 let stray = met
668 .iter()
669 .chain(&between)
670 .map(|pair| off_line(pair[side]))
671 .fold(0.0_f64, f64::max);
672 let (curve, stray): (Curve, f64) = if stray <= STRAIGHT {
673 (
674 ogeom_geom::LineCurve::segment(from, to, tol).ok()?.into(),
675 stray,
676 )
677 } else {
678 let points: Vec<Point> = met.iter().map(|pair| pair[side]).collect();
679 let fitted =
680 ogeom_geom::fit::fit_points_at(&heights, &points, 3, tol.confusion(), tol).ok()?;
681 let curve: Curve = fitted.curve.into();
682 let mut worst = fitted.error;
683 for (k, pair) in (0..STATIONS).zip(&between) {
684 let p = curve.point_at(at(f64::from(k) + 0.5), tol).ok()?;
685 worst = worst.max(p.distance(pair[side]));
686 }
687 (curve, worst)
688 };
689 let tolerance = stray + stated + tol.confusion();
690 if tolerance > NEAR_PARALLEL_STRAY {
691 return None;
692 }
693 out.push(SectionCurve {
694 curve,
695 on_a: None,
696 on_b: None,
697 tolerance,
698 exact: false,
699 closed: false,
700 tangential: false,
701 });
702 }
703 Some(out)
704}
705
706fn ball_through_drum(
718 a: &SurfaceGeometry,
719 b: &SurfaceGeometry,
720 tol: Tolerances,
721) -> Option<Vec<SectionCurve>> {
722 const SAMPLES: u32 = 256;
723 const STRAY: f64 = 1e-5;
724 let (ball, drum, ball_first) = match (a, b) {
725 (SurfaceGeometry::Sphere(s), SurfaceGeometry::Cylinder(c)) => (s, c, true),
726 (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Sphere(s)) => (s, c, false),
727 _ => return None,
728 };
729 let (sphere, cylinder) = (ball.sphere(), drum.cylinder());
730 let frame = cylinder.frame();
731 let (x, y, d) = (frame.x().vector(), frame.y().vector(), frame.z().vector());
732 let (origin, r) = (frame.origin(), cylinder.radius());
733 let (centre, big) = (sphere.centre(), sphere.radius());
734 let ball_frame = sphere.frame();
735 let (_, (h0, h1)) = drum.domain();
736 let margin = r * 0.1;
740 let heights = |angle: f64| -> Option<[f64; 2]> {
742 let foot = origin + (x * angle.cos() + y * angle.sin()) * r;
743 let w = foot - centre;
744 let half = d.dot(w);
745 let disc = half.mul_add(half, -(w.dot(w) - big * big));
746 if disc <= margin * margin {
747 return None;
748 }
749 let root = disc.sqrt();
750 let pair = [-half - root, -half + root];
751 pair.iter().all(|v| *v >= h0 && *v <= h1).then_some(pair)
752 };
753 let at = |angle: f64, v: f64| origin + (x * angle.cos() + y * angle.sin()) * r + d * v;
754 let on_ball = |p: Point, before: Option<Point2>| -> Point2 {
756 let local = ball_frame.to_local(p);
757 let lat = local.z.atan2(local.x.hypot(local.y));
758 let mut lon = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
759 if let Some(prev) = before {
760 while lon - prev.x > core::f64::consts::PI {
761 lon -= core::f64::consts::TAU;
762 }
763 while prev.x - lon > core::f64::consts::PI {
764 lon += core::f64::consts::TAU;
765 }
766 }
767 Point2::new(lon, lat)
768 };
769 let angle_of = |k: f64| core::f64::consts::TAU * k / f64::from(SAMPLES);
770 let params: Vec<f64> = (0..=SAMPLES).map(|k| angle_of(f64::from(k))).collect();
771 let mut sampled: Vec<[f64; 2]> = Vec::with_capacity(params.len());
772 for &angle in ¶ms {
773 sampled.push(heights(angle)?);
774 }
775 let mut out = Vec::with_capacity(2);
776 for side in 0..2 {
777 let points: Vec<Point> = params
778 .iter()
779 .zip(&sampled)
780 .map(|(&angle, pair)| at(angle, pair[side]))
781 .collect();
782 let on_drum: Vec<Point2> = params
783 .iter()
784 .zip(&sampled)
785 .map(|(&angle, pair)| Point2::new(angle, pair[side]))
786 .collect();
787 let mut on_sphere: Vec<Point2> = Vec::with_capacity(points.len());
788 for p in &points {
789 let q = on_ball(*p, on_sphere.last().copied());
790 on_sphere.push(q);
791 }
792 let target = tol.confusion() * 10.0;
793 let curve: Curve = ogeom_geom::fit::fit_points_at(¶ms, &points, 3, target, tol)
794 .ok()?
795 .curve
796 .into();
797 let drum_image: PlanarCurve =
798 ogeom_geom::fit::fit_points_2d_at(¶ms, &on_drum, 3, target, tol)
799 .ok()?
800 .curve
801 .into();
802 let ball_image: PlanarCurve =
803 ogeom_geom::fit::fit_points_2d_at(¶ms, &on_sphere, 3, target, tol)
804 .ok()?
805 .curve
806 .into();
807 let mut stray = 0.0_f64;
810 for k in 0..(2 * SAMPLES) {
811 let angle = angle_of(f64::from(k) / 2.0);
812 let truth = at(angle, heights(angle)?[side]);
813 let on_curve = curve.point_at(angle, tol).ok()?;
814 let uv = drum_image.point_at(angle, tol).ok()?;
815 let through_drum = drum.point_at(uv.x, uv.y, tol).ok()?;
816 let uv = ball_image.point_at(angle, tol).ok()?;
817 let through_ball = ball.point_at(uv.x, uv.y, tol).ok()?;
818 stray = stray
819 .max(truth.distance(on_curve))
820 .max(truth.distance(through_drum))
821 .max(truth.distance(through_ball));
822 }
823 let tolerance = stray.max(tol.confusion());
824 if tolerance > STRAY {
825 return None;
826 }
827 let (on_a, on_b) = if ball_first {
828 (ball_image, drum_image)
829 } else {
830 (drum_image, ball_image)
831 };
832 out.push(SectionCurve {
833 curve,
834 on_a: Some(on_a),
835 on_b: Some(on_b),
836 tolerance,
837 exact: false,
838 closed: true,
839 tangential: false,
840 });
841 }
842 Some(out)
843}
844
845fn near_parallel_plane_drum(
857 a: &SurfaceGeometry,
858 b: &SurfaceGeometry,
859 tol: Tolerances,
860) -> Option<Vec<SectionCurve>> {
861 const LEAN: f64 = 1e-3;
862 const SPAN: f64 = 3e4;
863 let (plane, drum, surface) = match (a, b) {
864 (SurfaceGeometry::Plane(p), SurfaceGeometry::Cylinder(c)) => (p.plane(), c.cylinder(), b),
865 (SurfaceGeometry::Cylinder(c), SurfaceGeometry::Plane(p)) => (p.plane(), c.cylinder(), a),
866 _ => return None,
867 };
868 let axis = drum.axis();
869 let (d, r) = (axis.direction.vector(), drum.radius());
870 let n = plane.normal().vector();
871 let lean = n.dot(d).abs();
872 if lean <= tol.angular() || lean > LEAN || r / lean < SPAN {
877 return None;
878 }
879 let across = n - d * n.dot(d);
880 let k = across.magnitude();
881 let e1 = across / k;
882 let e2 = d.cross(e1);
883 let (_, (lo, hi)) = surface.domain();
884 if !(lo.is_finite() && hi.is_finite()) || hi - lo <= tol.confusion() {
885 return None;
886 }
887 let meet = |z: f64| -> Option<[Point; 2]> {
888 let centre = axis.location + d * z;
889 let u = -plane.signed_distance_to(centre) / k;
890 let margin = tol.confusion() * 1e3;
891 if u.abs() >= r - margin {
892 return None;
893 }
894 let w = r.mul_add(r, -(u * u)).sqrt();
895 Some([centre + e1 * u + e2 * w, centre + e1 * u - e2 * w])
896 };
897 lines_through_stations(lo, hi, meet, 0.0, tol)
898}
899
900fn exact_section(
915 curve: Curve,
916 a: &SurfaceGeometry,
917 b: &SurfaceGeometry,
918 tol: Tolerances,
919) -> Option<SectionCurve> {
920 let closed = match &curve {
921 Curve::Circle(_) | Curve::Ellipse(_) => true,
922 _ => curve.is_closed(tol),
923 };
924 let range = curve.domain();
925 let on_a = exact_pcurve(&curve, range, a, tol);
926 let on_b = exact_pcurve(&curve, range, b, tol);
927
928 if let Curve::Line(_) = &curve {
929 let mut interval = curve.domain();
932 if let Some(p) = &on_a {
933 interval = intersect_intervals(interval, inside_box(p, a))?;
934 }
935 if let Some(p) = &on_b {
936 interval = intersect_intervals(interval, inside_box(p, b))?;
937 }
938 let (lo, hi) = interval;
939 let Curve::Line(line) = &curve else {
940 unreachable!()
941 };
942 let clipped: Curve = ogeom_geom::LineCurve::over(line.axis(), lo, hi)
943 .ok()?
944 .into();
945 let clip2 = |p: &PlanarCurve| -> Option<PlanarCurve> {
946 let PlanarCurve::Line(l) = p else {
947 return Some(p.clone());
948 };
949 Some(Line2d::over(l.axis(), lo, hi).ok()?.into())
950 };
951 let (ca, cb) = (on_a.as_ref().and_then(clip2), on_b.as_ref().and_then(clip2));
952 let tangential = touching_along(&clipped, ca.as_ref(), cb.as_ref(), a, b, tol);
953 return Some(SectionCurve {
954 on_a: ca,
955 on_b: cb,
956 tolerance: 0.0,
957 exact: true,
958 closed: false,
959 tangential,
960 curve: clipped,
961 });
962 }
963
964 for (pcurve, surface) in [(&on_a, a), (&on_b, b)] {
967 if let Some(p) = pcurve
968 && !touches_box(p, surface, tol)
969 {
970 return None;
971 }
972 }
973 let tangential = touching_along(&curve, on_a.as_ref(), on_b.as_ref(), a, b, tol);
974 Some(SectionCurve {
975 on_a,
976 on_b,
977 tolerance: 0.0,
978 exact: true,
979 closed,
980 tangential,
981 curve,
982 })
983}
984
985fn touching_along(
994 curve: &Curve,
995 on_a: Option<&PlanarCurve>,
996 on_b: Option<&PlanarCurve>,
997 a: &SurfaceGeometry,
998 b: &SurfaceGeometry,
999 tol: Tolerances,
1000) -> bool {
1001 let sample_uv = |pc: Option<&PlanarCurve>,
1009 surface: &SurfaceGeometry,
1010 t: f64|
1011 -> Option<ogeom_math::Point2> {
1012 if let Some(pc) = pc {
1013 return pc.point_at(t, tol).ok();
1014 }
1015 let p = curve.point_at(t, tol).ok()?;
1016 chart_inversion(surface, p, tol)
1017 };
1018 let (lo, hi) = curve.domain();
1019 let mut judged = 0_usize;
1025 for f in [0.07, 0.19, 0.37, 0.53, 0.71, 0.89] {
1026 let t = (hi - lo).mul_add(f, lo);
1027 let (Some(ua), Some(ub)) = (sample_uv(on_a, a, t), sample_uv(on_b, b, t)) else {
1028 continue;
1029 };
1030 let (Ok(na), Ok(nb)) = (a.normal_at(ua.x, ua.y, tol), b.normal_at(ub.x, ub.y, tol)) else {
1031 continue;
1032 };
1033 if na.vector().cross(nb.vector()).magnitude() > 1e-6 {
1034 return false;
1035 }
1036 judged += 1;
1037 }
1038 judged >= 3
1039}
1040
1041fn chart_inversion(
1043 surface: &SurfaceGeometry,
1044 p: ogeom_math::Point,
1045 tol: Tolerances,
1046) -> Option<ogeom_math::Point2> {
1047 use ogeom_math::elementary;
1048 let (u, v) = match surface {
1049 SurfaceGeometry::Plane(s) => elementary::plane_parameters(&s.plane(), p),
1050 SurfaceGeometry::Cylinder(s) => {
1051 elementary::cylinder_parameters(&s.cylinder(), p, tol).ok()?
1052 }
1053 SurfaceGeometry::Cone(s) => elementary::cone_parameters(&s.cone(), p, tol).ok()?,
1054 SurfaceGeometry::Sphere(s) => elementary::sphere_parameters(&s.sphere(), p, tol).ok()?,
1055 SurfaceGeometry::Torus(s) => elementary::torus_parameters(&s.torus(), p, tol).ok()?,
1056 _ => return None,
1057 };
1058 Some(ogeom_math::Point2::new(u, v))
1059}
1060
1061fn inside_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry) -> Option<(f64, f64)> {
1064 let (o, d) = match pcurve {
1067 PlanarCurve::Line(line) => {
1068 let axis = line.axis();
1069 (axis.location, axis.direction.vector())
1070 }
1071 PlanarCurve::BSpline(spline)
1072 if spline.knots().degree() == 1 && spline.control_points().len() == 2 =>
1073 {
1074 let (t0, t1) = spline.knots().domain();
1075 let (p0, p1) = (
1076 spline.control_points()[0].point(),
1077 spline.control_points()[1].point(),
1078 );
1079 if t1 <= t0 {
1080 return None;
1081 }
1082 let rate = (p1 - p0) / (t1 - t0);
1083 (p0 - rate * t0, rate)
1084 }
1085 _ => return None,
1086 };
1087 let ((ua, ub), (va, vb)) = surface.domain();
1088
1089 let mut lo = f64::NEG_INFINITY;
1091 let mut hi = f64::INFINITY;
1092 for (origin, direction, low, high) in [(o.x, d.x, ua, ub), (o.y, d.y, va, vb)] {
1093 if direction.abs() <= f64::MIN_POSITIVE {
1094 if origin < low || origin > high {
1095 return None;
1096 }
1097 continue;
1098 }
1099 let (a, b) = ((low - origin) / direction, (high - origin) / direction);
1100 let (near, far) = if a < b { (a, b) } else { (b, a) };
1101 lo = lo.max(near);
1102 hi = hi.min(far);
1103 }
1104 if lo >= hi {
1105 return None;
1106 }
1107 Some((lo, hi))
1108}
1109
1110fn touches_box(pcurve: &PlanarCurve, surface: &SurfaceGeometry, tol: Tolerances) -> bool {
1112 use ogeom_geom::Curve2d;
1113 let ((ua, ub), (va, vb)) = surface.domain();
1114 let (lo, hi) = pcurve.domain();
1115 const SPANS: u32 = 64;
1124 let points: Vec<Option<ogeom_math::Point2>> = (0..=SPANS)
1125 .map(|i| {
1126 pcurve
1127 .point_at(lo + (hi - lo) * f64::from(i) / f64::from(SPANS), tol)
1128 .ok()
1129 })
1130 .collect();
1131 points.windows(2).any(|pair| {
1132 let (Some(p), Some(q)) = (pair[0], pair[1]) else {
1133 return false;
1134 };
1135 let pad = p.distance(q);
1136 let u_ok =
1138 surface.is_periodic_u() || (p.x.max(q.x) + pad >= ua && p.x.min(q.x) - pad <= ub);
1139 let v_ok =
1140 surface.is_periodic_v() || (p.y.max(q.y) + pad >= va && p.y.min(q.y) - pad <= vb);
1141 u_ok && v_ok
1142 })
1143}
1144
1145fn intersect_intervals(a: (f64, f64), b: Option<(f64, f64)>) -> Option<(f64, f64)> {
1147 let b = b?;
1148 let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
1149 if lo >= hi {
1150 return None;
1151 }
1152 Some((lo, hi))
1153}
1154
1155fn marched(
1157 a: &SurfaceGeometry,
1158 b: &SurfaceGeometry,
1159 options: IntersectOptions,
1160 tol: Tolerances,
1161) -> OgeomResult<SurfaceIntersection> {
1162 let traced = branches(a, b, options.marching, tol)?;
1163 if traced.is_empty() {
1164 return Ok(SurfaceIntersection::Apart);
1165 }
1166 let mut out = Vec::with_capacity(traced.len());
1167 let mut contacts: Vec<crate::march::Traced> = Vec::new();
1168 for branch in &traced {
1169 if branch_is_tangential(a, b, branch, tol)? {
1178 if let Some(contact) = walk_contact(a, b, branch, &contacts, options.marching, tol)? {
1179 contacts.push(contact);
1180 }
1181 continue;
1182 }
1183 if branch.stopped == crate::march::Stopped::RanOut {
1184 ogeom_bail!(
1185 NotDone,
1186 "a marched section ran out of its point budget before \
1187 finishing; the seam is longer than the chord affords and \
1188 fitting the truncation would state a curve that is not there"
1189 );
1190 }
1191 for fitted in fitted_in_pieces(a, b, branch, options.tolerance, tol)? {
1200 out.push(SectionCurve {
1201 curve: fitted.curve.into(),
1202 on_a: Some(fitted.on_a.into()),
1203 on_b: Some(fitted.on_b.into()),
1204 tolerance: options.marching.chord + fitted.fit_error,
1207 exact: false,
1208 closed: fitted.closed,
1209 tangential: false,
1210 });
1211 }
1212 }
1213 for contact in &contacts {
1214 let fitted = approximate_branch(a, b, contact, options.tolerance, tol)?;
1215 out.push(SectionCurve {
1216 curve: fitted.curve.into(),
1217 on_a: Some(fitted.on_a.into()),
1218 on_b: Some(fitted.on_b.into()),
1219 tolerance: options.marching.chord + fitted.fit_error,
1220 exact: false,
1221 closed: fitted.closed,
1222 tangential: true,
1223 });
1224 }
1225 if out.is_empty() {
1226 return Ok(SurfaceIntersection::Apart);
1227 }
1228 Ok(SurfaceIntersection::Along(out))
1229}
1230
1231fn fitted_in_pieces(
1244 a: &SurfaceGeometry,
1245 b: &SurfaceGeometry,
1246 branch: &crate::march::Traced,
1247 tolerance: f64,
1248 tol: Tolerances,
1249) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
1250 const DEPTH: u32 = 6;
1251 const FLOOR: usize = 16;
1252 fn go(
1253 a: &SurfaceGeometry,
1254 b: &SurfaceGeometry,
1255 branch: &crate::march::Traced,
1256 tolerance: f64,
1257 depth: u32,
1258 tol: Tolerances,
1259 ) -> OgeomResult<Vec<crate::approx::IntersectionCurve>> {
1260 let whole = approximate_branch(a, b, branch, tolerance, tol)?;
1261 let step = branch
1262 .points
1263 .windows(2)
1264 .map(|w| w[0].distance(w[1]))
1265 .fold(0.0_f64, f64::max);
1266 if whole.met
1267 || whole.fit_error <= step
1268 || branch.closed()
1269 || depth == 0
1270 || branch.points.len() < 2 * FLOOR
1271 {
1272 return Ok(vec![whole]);
1273 }
1274 let middle = branch.points.len() / 2;
1275 let half = |range: core::ops::RangeInclusive<usize>| crate::march::Traced {
1276 points: branch.points[range.clone()].to_vec(),
1277 on_a: branch.on_a[range.clone()].to_vec(),
1278 on_b: branch.on_b[range].to_vec(),
1279 stopped: branch.stopped,
1280 };
1281 let mut pieces = go(a, b, &half(0..=middle), tolerance, depth - 1, tol)?;
1282 pieces.extend(go(
1283 a,
1284 b,
1285 &half(middle..=branch.points.len() - 1),
1286 tolerance,
1287 depth - 1,
1288 tol,
1289 )?);
1290 let worst = pieces.iter().map(|p| p.fit_error).fold(0.0_f64, f64::max);
1293 Ok(if worst < whole.fit_error {
1294 pieces
1295 } else {
1296 vec![whole]
1297 })
1298 }
1299 go(a, b, branch, tolerance, DEPTH, tol)
1300}
1301
1302fn walk_contact(
1311 a: &SurfaceGeometry,
1312 b: &SurfaceGeometry,
1313 fragment: &crate::march::Traced,
1314 already: &[crate::march::Traced],
1315 marching: Marching,
1316 tol: Tolerances,
1317) -> OgeomResult<Option<crate::march::Traced>> {
1318 let middle = fragment.points.len() / 2;
1319 let Some(point) = fragment.points.get(middle).copied() else {
1320 return Ok(None);
1321 };
1322 for traced in already {
1323 let spacing = traced
1326 .points
1327 .windows(2)
1328 .map(|w| w[0].distance(w[1]))
1329 .fold(0.0f64, f64::max);
1330 let near = traced
1331 .points
1332 .iter()
1333 .map(|p| p.distance(point))
1334 .fold(f64::INFINITY, f64::min);
1335 if near <= spacing.mul_add(0.5, marching.chord.max(tol.confusion())) {
1336 return Ok(None);
1337 }
1338 }
1339 let seed = crate::march::Contact {
1340 point,
1341 on_a: fragment.on_a[middle],
1342 on_b: fragment.on_b[middle],
1343 };
1344 Ok(trace_tangential(a, b, seed, marching, tol)
1349 .ok()
1350 .filter(|traced| traced.points.len() >= 4))
1351}
1352
1353fn branch_is_tangential(
1356 a: &SurfaceGeometry,
1357 b: &SurfaceGeometry,
1358 branch: &crate::march::Traced,
1359 tol: Tolerances,
1360) -> OgeomResult<bool> {
1361 use ogeom_geom::Surface as _;
1362 let count = branch.points.len();
1363 if count == 0 {
1364 return Ok(true);
1365 }
1366 for k in 0..5 {
1367 let i = (k * (count - 1)) / 4;
1368 let (ua, va) = branch.on_a[i.min(count - 1)];
1369 let (ub, vb) = branch.on_b[i.min(count - 1)];
1370 let (dau, dav) = a.d1_at(ua, va, tol)?;
1371 let (dbu, dbv) = b.d1_at(ub, vb, tol)?;
1372 let na = dau.cross(dav);
1373 let nb = dbu.cross(dbv);
1374 let (ma, mb) = (na.magnitude(), nb.magnitude());
1375 if ma <= tol.confusion() || mb <= tol.confusion() {
1376 continue;
1377 }
1378 if na.cross(nb).magnitude() / (ma * mb) > 3e-2 {
1385 return Ok(false);
1386 }
1387 }
1388 Ok(true)
1389}
1390
1391#[must_use]
1399pub fn exact_pcurve_of(
1400 curve: &Curve,
1401 surface: &SurfaceGeometry,
1402 tol: Tolerances,
1403) -> Option<PlanarCurve> {
1404 exact_pcurve(curve, curve.domain(), surface, tol)
1405}
1406
1407#[must_use]
1416pub fn exact_pcurve_over(
1417 curve: &Curve,
1418 range: (f64, f64),
1419 surface: &SurfaceGeometry,
1420 tol: Tolerances,
1421) -> Option<PlanarCurve> {
1422 exact_pcurve(curve, range, surface, tol)
1423}
1424
1425fn exact_pcurve(
1435 curve: &Curve,
1436 range: (f64, f64),
1437 surface: &SurfaceGeometry,
1438 tol: Tolerances,
1439) -> Option<PlanarCurve> {
1440 if let Curve::Trimmed(trimmed) = curve
1447 && !trimmed.is_reversed()
1448 {
1449 let window = ogeom_geom::Curve3d::domain(&**trimmed);
1450 let basis = exact_pcurve(trimmed.basis(), range, surface, tol)?;
1451 return ogeom_geom::Trimmed2d::new(basis, window.0, window.1, tol)
1452 .ok()
1453 .map(Into::into);
1454 }
1455 match surface {
1456 SurfaceGeometry::Plane(p) => on_plane(curve, p.plane(), tol),
1457 SurfaceGeometry::Cylinder(c) => on_cylinder(curve, range, c.cylinder(), tol),
1458 SurfaceGeometry::Sphere(s) => on_sphere(curve, range, s.sphere(), tol),
1459 SurfaceGeometry::Torus(t) => on_torus(curve, t.torus(), tol),
1460 SurfaceGeometry::Cone(c) => on_cone(curve, range, c.cone(), tol),
1461 _ => None,
1462 }
1463}
1464
1465fn on_cone(
1474 curve: &Curve,
1475 range: (f64, f64),
1476 cone: ogeom_math::Cone,
1477 tol: Tolerances,
1478) -> Option<PlanarCurve> {
1479 let frame = cone.frame();
1480 let axis_z = frame.z().vector();
1481 let tau = core::f64::consts::TAU;
1482 match curve {
1483 Curve::Circle(c) => {
1484 let circle = c.circle();
1485 if circle.frame().z().vector().cross(axis_z).magnitude() > tol.angular() {
1486 return None;
1487 }
1488 let local = frame.to_local(circle.centre());
1489 if local.x.hypot(local.y) > tol.confusion() {
1490 return None;
1491 }
1492 let expected = cone
1496 .half_angle()
1497 .tan()
1498 .mul_add(local.z, cone.reference_radius());
1499 let turned = if (expected - circle.radius()).abs() <= tol.confusion() * 10.0 {
1500 0.0
1501 } else if (expected + circle.radius()).abs() <= tol.confusion() * 10.0 {
1502 core::f64::consts::PI
1503 } else {
1504 return None;
1505 };
1506 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1507 let at = frame.to_local(start);
1508 let phase = at.y.atan2(at.x) + turned;
1509 let winding = circle.frame().z().vector().dot(axis_z).signum();
1510 let towards =
1511 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1512 Some(
1513 Line2d::over(
1514 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1515 0.0,
1516 tau,
1517 )
1518 .ok()?
1519 .into(),
1520 )
1521 }
1522 Curve::Line(line) => {
1523 let axis = line.axis();
1526 let on = |t: f64| {
1527 let p = axis.location + axis.direction.vector() * t;
1528 cone.distance_to(p) <= tol.confusion() * 10.0
1529 };
1530 if !on(0.0) || !on(1.0) || !on(-1.0) {
1531 return None;
1532 }
1533 let (lo, hi) = if range.0.is_finite() && range.1.is_finite() && range.0 != range.1 {
1540 range
1541 } else {
1542 line.domain()
1543 };
1544 let mut local: Option<ogeom_math::Point> = None;
1550 for t in [lo, hi] {
1551 if !t.is_finite() {
1552 continue;
1553 }
1554 let candidate = frame.to_local(axis.location + axis.direction.vector() * t);
1555 if local.is_none_or(|held| candidate.x.hypot(candidate.y) > held.x.hypot(held.y)) {
1556 local = Some(candidate);
1557 }
1558 }
1559 let local = local?;
1560 if local.x.hypot(local.y) <= tol.confusion() {
1561 return None;
1562 }
1563 let u = local.y.atan2(local.x).rem_euclid(tau);
1564 let v_at = |t: f64| {
1568 frame
1569 .to_local(axis.location + axis.direction.vector() * t)
1570 .z
1571 };
1572 let knots = ogeom_math::KnotVector::new(vec![lo, lo, hi, hi], 1).ok()?;
1573 Some(
1574 ogeom_geom::BSpline2d::new(
1575 knots,
1576 vec![Point2::new(u, v_at(lo)), Point2::new(u, v_at(hi))],
1577 tol,
1578 )
1579 .ok()?
1580 .into(),
1581 )
1582 }
1583 _ => None,
1584 }
1585}
1586
1587fn on_torus(curve: &Curve, torus: ogeom_math::Torus, tol: Tolerances) -> Option<PlanarCurve> {
1597 let Curve::Circle(c) = curve else {
1598 return None;
1599 };
1600 let circle = c.circle();
1601 let frame = torus.frame();
1602 let axis_z = frame.z().vector();
1603 let normal = circle.frame().z().vector();
1604 let local = frame.to_local(circle.centre());
1605 let tau = core::f64::consts::TAU;
1606
1607 if normal.cross(axis_z).magnitude() <= tol.angular()
1609 && local.x.hypot(local.y) <= tol.confusion()
1610 {
1611 let sin_v = local.z / torus.minor_radius();
1612 let (cos_v, turned) = [
1616 (circle.radius() - torus.major_radius(), 0.0),
1617 (
1618 -circle.radius() - torus.major_radius(),
1619 core::f64::consts::PI,
1620 ),
1621 ]
1622 .into_iter()
1623 .map(|(reach, turned)| (reach / torus.minor_radius(), turned))
1624 .find(|(cos_v, _)| (sin_v.hypot(*cos_v) - 1.0).abs() <= tol.confusion())?;
1625 let v = sin_v.atan2(cos_v);
1626 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1627 let at = frame.to_local(start);
1628 let phase = at.y.atan2(at.x) + turned;
1629 let winding = normal.dot(axis_z).signum();
1630 let towards =
1631 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1632 return Some(
1633 Line2d::over(
1634 ogeom_math::Axis2::new(Point2::new(phase, v), towards),
1635 0.0,
1636 tau,
1637 )
1638 .ok()?
1639 .into(),
1640 );
1641 }
1642
1643 if (circle.radius() - torus.minor_radius()).abs() <= tol.confusion()
1645 && normal.dot(axis_z).abs() <= tol.angular()
1646 && (local.x.hypot(local.y) - torus.major_radius()).abs() <= tol.confusion()
1647 && local.z.abs() <= tol.confusion()
1648 {
1649 let u = local.y.atan2(local.x);
1650 let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
1651 let xc = circle.frame().x().vector();
1652 let phase = xc.dot(axis_z).atan2(xc.dot(radial));
1653 let winding = normal.dot(radial.cross(axis_z)).signum();
1654 let towards =
1655 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, winding), tol).ok()?;
1656 return Some(
1657 Line2d::over(
1658 ogeom_math::Axis2::new(Point2::new(u, phase), towards),
1659 0.0,
1660 tau,
1661 )
1662 .ok()?
1663 .into(),
1664 );
1665 }
1666 None
1667}
1668
1669fn on_plane(curve: &Curve, plane: ogeom_math::Plane, tol: Tolerances) -> Option<PlanarCurve> {
1675 let frame = plane.frame();
1676 let flat = |p: Point| {
1677 let local = frame.to_local(p);
1678 Point2::new(local.x, local.y)
1679 };
1680 let flat_direction = |d: ogeom_math::Direction| {
1681 let tip = flat(frame.origin() + d.vector());
1682 ogeom_math::Direction2::new(tip - flat(frame.origin()), tol).ok()
1683 };
1684 match curve {
1685 Curve::Line(line) => {
1686 let axis = line.axis();
1687 let through = flat(axis.location);
1688 let direction = flat_direction(axis.direction)?;
1689 let (lo, hi) = line.domain();
1690 Some(
1691 Line2d::over(ogeom_math::Axis2::new(through, direction), lo, hi)
1692 .ok()?
1693 .into(),
1694 )
1695 }
1696 Curve::Circle(c) => {
1697 let circle = c.circle();
1698 let frame2 = Frame2::from_axes(
1699 flat(circle.centre()),
1700 flat_direction(circle.frame().x())?,
1701 flat_direction(circle.frame().y())?,
1702 tol,
1703 )
1704 .ok()?;
1705 Some(Circle2d::new(Circle2::new(frame2, circle.radius(), tol).ok()?).into())
1706 }
1707 Curve::Ellipse(e) => {
1708 let ellipse = e.ellipse();
1709 let frame2 = Frame2::from_axes(
1710 flat(ellipse.centre()),
1711 flat_direction(ellipse.frame().x())?,
1712 flat_direction(ellipse.frame().y())?,
1713 tol,
1714 )
1715 .ok()?;
1716 Some(
1717 Ellipse2d::new(
1718 Ellipse2::new(frame2, ellipse.major_radius(), ellipse.minor_radius(), tol)
1719 .ok()?,
1720 )
1721 .into(),
1722 )
1723 }
1724 Curve::BSpline(b) => {
1725 let control = b
1730 .control_points()
1731 .iter()
1732 .map(|w| ogeom_math::Weighted::new(flat((*w).point()), w.weight, tol))
1733 .collect::<Result<Vec<_>, _>>()
1734 .ok()?;
1735 Some(
1736 ogeom_geom::BSpline2d::rational(b.knots().clone(), control)
1737 .ok()?
1738 .into(),
1739 )
1740 }
1741 _ => None,
1742 }
1743}
1744
1745fn on_cylinder(
1752 curve: &Curve,
1753 range: (f64, f64),
1754 cylinder: ogeom_math::Cylinder,
1755 tol: Tolerances,
1756) -> Option<PlanarCurve> {
1757 let axis = cylinder.axis();
1758 let frame = cylinder.frame();
1759 match curve {
1760 Curve::Line(line) => {
1761 let direction = line.axis().direction;
1763 let along = direction.dot(axis.direction);
1764 if !direction.is_parallel(axis.direction, tol) {
1765 return None;
1766 }
1767 let through = line.axis().location;
1768 if (axis.distance_to(through) - cylinder.radius()).abs() > tol.confusion() {
1769 return None;
1770 }
1771 let local = frame.to_local(through);
1772 let u = local.y.atan2(local.x).rem_euclid(core::f64::consts::TAU);
1773 let (lo, hi) = line.domain();
1777 let start = Point2::new(u, local.z);
1778 let towards =
1779 ogeom_math::Direction2::new(ogeom_math::Vector2::new(0.0, along.signum()), tol)
1780 .ok()?;
1781 Some(
1782 Line2d::over(ogeom_math::Axis2::new(start, towards), lo, hi)
1783 .ok()?
1784 .into(),
1785 )
1786 }
1787 Curve::Circle(c) => {
1788 let circle = c.circle();
1789 if circle
1791 .frame()
1792 .z()
1793 .cross_with(axis.direction.vector())
1794 .magnitude()
1795 > tol.angular()
1796 {
1797 return None;
1798 }
1799 if axis.distance_to(circle.centre()) > tol.confusion() {
1800 return None;
1801 }
1802 if (circle.radius() - cylinder.radius()).abs() > tol.confusion() {
1803 return None;
1804 }
1805 let local = frame.to_local(circle.centre());
1806 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
1815 let at = frame.to_local(start);
1816 let phase = at.y.atan2(at.x);
1817 let winding = circle.frame().z().dot(axis.direction).signum();
1818 let towards =
1819 ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
1820 Some(
1821 Line2d::over(
1822 ogeom_math::Axis2::new(Point2::new(phase, local.z), towards),
1823 0.0,
1824 core::f64::consts::TAU,
1825 )
1826 .ok()?
1827 .into(),
1828 )
1829 }
1830 Curve::Ellipse(_) => {
1831 use ogeom_geom::Curve3d as _;
1837 let tau = core::f64::consts::TAU;
1838 let local = |t: f64| -> Option<ogeom_math::Point> {
1839 Some(frame.to_local(curve.point_at(t, tol).ok()?))
1840 };
1841 let l0 = local(0.0)?;
1842 let lq = local(tau / 4.0)?;
1843 let lh = local(tau / 2.0)?;
1844 let r = cylinder.radius();
1846 for l in [&l0, &lq, &lh] {
1847 if (l.x.hypot(l.y) - r).abs() > tol.confusion() * 10.0 {
1848 return None;
1849 }
1850 }
1851 let phase = l0.y.atan2(l0.x);
1852 let uq = lq.y.atan2(lq.x);
1855 let step = (uq - phase).rem_euclid(tau);
1856 let winding = if (step - tau / 4.0).abs() < 1e-6 {
1857 1.0
1858 } else if (step - 3.0 * tau / 4.0).abs() < 1e-6 {
1859 -1.0
1860 } else {
1861 return None;
1862 };
1863 let c0 = f64::midpoint(l0.z, lh.z);
1865 let a = (l0.z - lh.z) / 2.0;
1866 let b = lq.z - c0;
1867 let candidate = ogeom_geom::Trig2d::new(
1871 Point2::new(phase, c0),
1872 ogeom_math::Vector2::new(winding, 0.0),
1873 ogeom_math::Vector2::new(0.0, a),
1874 ogeom_math::Vector2::new(0.0, b),
1875 range,
1876 )
1877 .ok()?;
1878 use ogeom_geom::Curve2d as _;
1881 for i in 0..7 {
1882 let t = range.0 + (range.1 - range.0) * (0.09 + 0.13 * f64::from(i)) / 0.91;
1883 let l = local(t)?;
1884 let chart = candidate.point_at(t, tol).ok()?;
1885 let du = (chart.x - l.y.atan2(l.x)).rem_euclid(tau);
1886 if du.min(tau - du) > 1e-9 {
1887 return None;
1888 }
1889 if (chart.y - l.z).abs() > tol.confusion() * 10.0 {
1890 return None;
1891 }
1892 }
1893 Some(PlanarCurve::Trig(candidate))
1894 }
1895 _ => None,
1896 }
1897}
1898
1899fn on_meridian(
1917 curve: &ogeom_geom::CircleCurve,
1918 range: (f64, f64),
1919 sphere: ogeom_math::Sphere,
1920 tol: Tolerances,
1921) -> Option<PlanarCurve> {
1922 let circle = curve.circle();
1923 let sweep = if curve.is_reversed() { -1.0 } else { 1.0 };
1928 let frame = sphere.frame();
1929 let z = frame.z().vector();
1930 if circle.centre().distance(sphere.centre()) > tol.confusion() {
1933 return None;
1934 }
1935 if (circle.radius() - sphere.radius()).abs() > tol.confusion() {
1936 return None;
1937 }
1938 let (cx, cy) = (circle.frame().x().vector(), circle.frame().y().vector());
1939 let (xz, yz) = (cx.dot(z), cy.dot(z));
1940 if xz.hypot(yz) < 1.0 - tol.angular() {
1943 return None;
1944 }
1945 let raw_alpha = yz.atan2(xz);
1946 let w = cx * -raw_alpha.sin() + cy * raw_alpha.cos();
1949 let local = frame.to_local(sphere.centre() + w);
1950 let longitude = local.y.atan2(local.x);
1951
1952 let half = core::f64::consts::PI;
1953 let mid = f64::midpoint(range.0, range.1);
1954 let x_mid = (sweep * mid - raw_alpha).rem_euclid(core::f64::consts::TAU);
1957 let x_mid = if x_mid > half {
1958 x_mid - core::f64::consts::TAU
1959 } else {
1960 x_mid
1961 };
1962 let span = sweep * (range.1 - range.0);
1963 let (mut x0, mut x1) = (x_mid - span / 2.0, x_mid + span / 2.0);
1964 if x0 > x1 {
1965 core::mem::swap(&mut x0, &mut x1);
1966 }
1967 let alpha = sweep.mul_add(mid, -x_mid);
1972 let slack = tol.parametric().max(1e-9);
1973 let (axis_point, towards) = if x0 >= -slack && x1 <= half + slack {
1974 (
1977 Point2::new(longitude, half.mul_add(0.5, alpha)),
1978 ogeom_math::Vector2::new(0.0, -sweep),
1979 )
1980 } else if x0 >= -half - slack && x1 <= slack {
1981 (
1983 Point2::new(longitude + half, half.mul_add(0.5, -alpha)),
1984 ogeom_math::Vector2::new(0.0, sweep),
1985 )
1986 } else {
1987 return None;
1989 };
1990 let towards = ogeom_math::Direction2::new(towards, tol).ok()?;
1991 let margin = (range.1 - range.0) * 0.25;
1992 let line: PlanarCurve = Line2d::over(
1993 ogeom_math::Axis2::new(axis_point, towards),
1994 range.0 - margin,
1995 range.1 + margin,
1996 )
1997 .ok()?
1998 .into();
1999
2000 for k in 0..=4 {
2003 let t = (range.1 - range.0).mul_add(f64::from(k) / 4.0, range.0);
2004 let uv = line.point_at(t, tol).ok()?;
2005 let lifted = ogeom_math::elementary::sphere_at(&sphere, uv.x, uv.y).point;
2006 let want = curve.point_at(t, tol).ok()?;
2007 if lifted.distance(want) > tol.confusion() {
2008 return None;
2009 }
2010 }
2011 Some(line)
2012}
2013
2014fn on_sphere(
2017 curve: &Curve,
2018 range: (f64, f64),
2019 sphere: ogeom_math::Sphere,
2020 tol: Tolerances,
2021) -> Option<PlanarCurve> {
2022 let Curve::Circle(c) = curve else {
2023 return None;
2024 };
2025 let circle = c.circle();
2026 let frame = sphere.frame();
2027 if circle
2030 .frame()
2031 .z()
2032 .cross_with(frame.z().vector())
2033 .magnitude()
2034 > tol.angular()
2035 {
2036 return on_meridian(c, range, sphere, tol);
2037 }
2038 let local = frame.to_local(circle.centre());
2039 if local.x.abs() > tol.confusion() || local.y.abs() > tol.confusion() {
2040 return None;
2041 }
2042 let latitude = (local.z / sphere.radius()).clamp(-1.0, 1.0).asin();
2043 if (circle.radius() - sphere.radius() * latitude.cos()).abs() > tol.confusion() {
2045 return None;
2046 }
2047 let start = circle.centre() + circle.frame().x().vector() * circle.radius();
2048 let at = frame.to_local(start);
2049 let phase = at.y.atan2(at.x);
2050 let winding = circle.frame().z().vector().dot(frame.z().vector()).signum();
2053 let towards = ogeom_math::Direction2::new(ogeom_math::Vector2::new(winding, 0.0), tol).ok()?;
2054 Some(
2055 Line2d::over(
2056 ogeom_math::Axis2::new(Point2::new(phase, latitude), towards),
2057 0.0,
2058 core::f64::consts::TAU,
2059 )
2060 .ok()?
2061 .into(),
2062 )
2063}
2064
2065#[cfg(test)]
2066#[allow(clippy::unwrap_used, clippy::expect_used)]
2067mod tests {
2068 use super::*;
2069 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
2070 use ogeom_math::{Cylinder, Direction, Frame, Plane, Sphere, Vector};
2071
2072 const T: Tolerances = Tolerances::millimetres();
2073
2074 fn sphere(centre: Point, radius: f64) -> SurfaceGeometry {
2075 SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
2076 }
2077
2078 fn cylinder(axis: Vector, radius: f64) -> SurfaceGeometry {
2079 let frame = Frame::new(
2080 Point::ORIGIN,
2081 Direction::new(axis, T).unwrap(),
2082 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
2083 T,
2084 )
2085 .unwrap();
2086 CylinderSurface::new(Cylinder::new(frame, radius, T).unwrap(), (-4.0, 4.0))
2087 .unwrap()
2088 .into()
2089 }
2090
2091 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
2092 PlaneSurface::over(
2093 Plane::through(origin, Direction::new(normal, T).unwrap()),
2094 (-6.0, 6.0),
2095 (-6.0, 6.0),
2096 )
2097 .unwrap()
2098 .into()
2099 }
2100
2101 fn assert_same_parameter(
2104 section: &SectionCurve,
2105 surface: &SurfaceGeometry,
2106 pcurve: &PlanarCurve,
2107 samples: usize,
2108 ) {
2109 let (lo, hi) = section.curve.domain();
2110 let (plo, phi) = pcurve.domain();
2111 assert!(
2112 (lo - plo).abs() < 1e-9 && (hi - phi).abs() < 1e-9,
2113 "domains disagree: [{lo}, {hi}] against [{plo}, {phi}]"
2114 );
2115 for i in 0..=samples {
2116 #[allow(clippy::cast_precision_loss)]
2117 let t = lo + (hi - lo) * i as f64 / samples as f64;
2118 let on_curve = section.curve.point_at(t, T).unwrap();
2119 let at = pcurve.point_at(t, T).unwrap();
2120 let lifted = surface.point_at(at.x, at.y, T).unwrap();
2121 assert!(
2122 on_curve.is_equal(lifted, T),
2123 "at t = {t}: curve {on_curve:?}, lifted {lifted:?}"
2124 );
2125 }
2126 }
2127
2128 #[test]
2129 fn an_analytic_pair_comes_back_exact_with_matching_pcurves() {
2130 let drum = cylinder(Vector::Z, 2.0);
2134 let cut = plane(Point::ORIGIN, Vector::X);
2135 let SurfaceIntersection::Along(curves) =
2136 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2137 else {
2138 panic!("a plane through a cylinder meets it along curves");
2139 };
2140 assert_eq!(curves.len(), 2);
2141 for section in &curves {
2142 assert!(section.exact);
2143 assert!((section.tolerance - 0.0).abs() < f64::EPSILON);
2144 let on_a = section.on_a.as_ref().expect("a line has a cylinder pcurve");
2145 let on_b = section.on_b.as_ref().expect("and a plane pcurve");
2146 assert_same_parameter(section, &drum, on_a, 50);
2147 assert_same_parameter(section, &cut, on_b, 50);
2148 }
2149 }
2150
2151 #[test]
2152 fn an_oblique_cut_gives_the_ellipse_a_trig_pcurve_on_the_drum() {
2153 let drum = cylinder(Vector::Z, 2.0);
2157 let angle: f64 = 0.5;
2158 let cut = plane(Point::ORIGIN, Vector::new(0.0, angle.sin(), angle.cos()));
2159 let SurfaceIntersection::Along(curves) =
2160 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2161 else {
2162 panic!("an oblique plane meets the cylinder along its ellipse");
2163 };
2164 assert_eq!(curves.len(), 1);
2165 let section = &curves[0];
2166 assert!(section.exact);
2167 assert!(matches!(section.curve, Curve::Ellipse(_)));
2168 let on_drum = section
2169 .on_a
2170 .as_ref()
2171 .expect("the oblique ellipse now carries its cylinder pcurve");
2172 assert!(
2173 matches!(on_drum, PlanarCurve::Trig(_)),
2174 "the chart trace is trig-affine: {on_drum:?}"
2175 );
2176 assert_same_parameter(section, &drum, on_drum, 60);
2177 let on_plane = section.on_b.as_ref().expect("and its plane pcurve");
2178 assert_same_parameter(section, &cut, on_plane, 60);
2179 }
2180
2181 #[test]
2182 fn a_perpendicular_cut_gives_a_circle_with_a_straight_pcurve() {
2183 let drum = cylinder(Vector::Z, 2.0);
2184 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
2185 let SurfaceIntersection::Along(curves) =
2186 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2187 else {
2188 panic!("expected curves");
2189 };
2190 assert_eq!(curves.len(), 1);
2191 let section = &curves[0];
2192 assert!(section.closed);
2193 assert!(matches!(section.curve, Curve::Circle(_)));
2194 assert!(matches!(
2196 section.on_a.as_ref().unwrap(),
2197 PlanarCurve::Line(_)
2198 ));
2199 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 60);
2200 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 60);
2201 }
2202
2203 #[test]
2204 fn coaxial_cylinder_and_sphere_give_circles_with_pcurves_on_both() {
2205 let drum = cylinder(Vector::Z, 1.5);
2206 let ball = sphere(Point::ORIGIN, 3.0);
2207 let SurfaceIntersection::Along(curves) =
2208 intersect_surfaces(&drum, &ball, IntersectOptions::default(), T).unwrap()
2209 else {
2210 panic!("expected curves");
2211 };
2212 assert_eq!(curves.len(), 2);
2213 for section in &curves {
2214 assert!(section.exact);
2215 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 40);
2216 assert_same_parameter(section, &ball, section.on_b.as_ref().unwrap(), 40);
2217 }
2218 }
2219
2220 fn torus(origin: Point, axis: Vector, major: f64, minor: f64) -> SurfaceGeometry {
2221 let frame = Frame::new(
2222 origin,
2223 Direction::new(axis, T).unwrap(),
2224 Direction::from_cross(axis, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
2225 T,
2226 )
2227 .unwrap();
2228 ogeom_geom::TorusSurface::new(ogeom_math::Torus::new(frame, major, minor, T).unwrap())
2229 .into()
2230 }
2231
2232 #[test]
2233 fn an_axis_normal_plane_meets_a_torus_in_two_parallels_with_pcurves() {
2234 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2235 let cut = plane(Point::new(0.0, 0.0, 0.3), Vector::Z);
2236 let SurfaceIntersection::Along(curves) =
2237 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
2238 else {
2239 panic!("an axis-normal plane through the tube meets it along curves");
2240 };
2241 assert_eq!(curves.len(), 2);
2242 let spread = 0.5_f64.mul_add(0.5, -(0.3 * 0.3)).sqrt();
2243 let mut radii: Vec<f64> = curves
2244 .iter()
2245 .map(|s| {
2246 let Curve::Circle(c) = &s.curve else {
2247 panic!("a parallel is a circle");
2248 };
2249 c.circle().radius()
2250 })
2251 .collect();
2252 radii.sort_by(|a, b| a.partial_cmp(b).unwrap());
2253 assert!((radii[0] - (2.0 - spread)).abs() < 1e-12);
2254 assert!((radii[1] - (2.0 + spread)).abs() < 1e-12);
2255 for section in &curves {
2256 assert!(section.exact);
2257 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
2258 assert_same_parameter(section, &cut, section.on_b.as_ref().unwrap(), 48);
2259 }
2260 }
2261
2262 #[test]
2263 fn the_plane_a_ball_rolls_on_touches_its_torus_along_the_circle_it_rolled() {
2264 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2269 let cut = plane(Point::new(0.0, 0.0, 0.5), Vector::Z);
2270 let SurfaceIntersection::Along(curves) =
2271 intersect_surfaces(&ring, &cut, IntersectOptions::default(), T).unwrap()
2272 else {
2273 panic!("the rolling plane touches along a circle, not at points");
2274 };
2275 assert_eq!(curves.len(), 1);
2276 let Curve::Circle(c) = &curves[0].curve else {
2277 panic!("the tangency is a circle");
2278 };
2279 assert!((c.circle().radius() - 2.0).abs() < 1e-12);
2280 assert_same_parameter(&curves[0], &ring, curves[0].on_a.as_ref().unwrap(), 48);
2281 assert_same_parameter(&curves[0], &cut, curves[0].on_b.as_ref().unwrap(), 48);
2282 }
2283
2284 #[test]
2285 fn a_coaxial_cylinder_meets_a_torus_in_two_parallels_and_touches_in_one() {
2286 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2287 let drum = cylinder(Vector::Z, 2.2);
2288 let SurfaceIntersection::Along(curves) =
2289 intersect_surfaces(&drum, &ring, IntersectOptions::default(), T).unwrap()
2290 else {
2291 panic!("a coaxial cylinder through the tube meets it along curves");
2292 };
2293 assert_eq!(curves.len(), 2);
2294 for section in &curves {
2295 assert!(section.exact);
2296 let Curve::Circle(c) = §ion.curve else {
2297 panic!("a parallel is a circle");
2298 };
2299 assert!((c.circle().radius() - 2.2).abs() < 1e-12);
2300 assert_same_parameter(section, &drum, section.on_a.as_ref().unwrap(), 48);
2301 assert_same_parameter(section, &ring, section.on_b.as_ref().unwrap(), 48);
2302 }
2303
2304 let grazing = cylinder(Vector::Z, 2.5);
2306 let SurfaceIntersection::Along(touch) =
2307 intersect_surfaces(&grazing, &ring, IntersectOptions::default(), T).unwrap()
2308 else {
2309 panic!("the grazing cylinder touches along the equator");
2310 };
2311 assert_eq!(touch.len(), 1);
2312 assert_same_parameter(&touch[0], &grazing, touch[0].on_a.as_ref().unwrap(), 48);
2313 assert_same_parameter(&touch[0], &ring, touch[0].on_b.as_ref().unwrap(), 48);
2314 }
2315
2316 #[test]
2317 fn coaxial_tori_are_the_same_or_meet_in_parallels() {
2318 let ring = torus(Point::ORIGIN, Vector::Z, 2.0, 0.5);
2319 assert!(matches!(
2320 intersect_surfaces(&ring, &ring.clone(), IntersectOptions::default(), T).unwrap(),
2321 SurfaceIntersection::Same
2322 ));
2323
2324 let lifted = torus(Point::new(0.0, 0.0, 0.5), Vector::Z, 2.0, 0.5);
2327 let SurfaceIntersection::Along(curves) =
2328 intersect_surfaces(&ring, &lifted, IntersectOptions::default(), T).unwrap()
2329 else {
2330 panic!("lifted coaxial tori meet along curves");
2331 };
2332 assert_eq!(curves.len(), 2);
2333 for section in &curves {
2334 assert!(section.exact);
2335 assert_same_parameter(section, &ring, section.on_a.as_ref().unwrap(), 48);
2336 assert_same_parameter(section, &lifted, section.on_b.as_ref().unwrap(), 48);
2337 }
2338 }
2339
2340 #[test]
2341 fn a_pair_with_no_closed_form_comes_back_fitted_with_pcurves() {
2342 let a = cylinder(Vector::Z, 1.0);
2344 let b = cylinder(Vector::X, 1.6);
2345 let options = IntersectOptions {
2346 tolerance: 1e-5,
2347 marching: Marching {
2348 chord: 1e-5,
2349 ..Marching::default()
2350 },
2351 };
2352 let SurfaceIntersection::Along(curves) = intersect_surfaces(&a, &b, options, T).unwrap()
2353 else {
2354 panic!("crossed cylinders meet along curves");
2355 };
2356 assert_eq!(curves.len(), 2);
2357 for section in &curves {
2358 assert!(!section.exact);
2359 assert!(section.closed);
2360 assert!(
2361 section.tolerance <= 1e-5 + 1e-4,
2362 "got {}",
2363 section.tolerance
2364 );
2365 assert!(section.on_a.is_some() && section.on_b.is_some());
2366
2367 let (lo, hi) = section.curve.domain();
2369 for i in 0..=200 {
2370 #[allow(clippy::cast_precision_loss)]
2371 let t = lo + (hi - lo) * f64::from(i) / 200.0;
2372 let p = section.curve.point_at(t, T).unwrap();
2373 let (SurfaceGeometry::Cylinder(x), SurfaceGeometry::Cylinder(y)) = (&a, &b) else {
2374 unreachable!()
2375 };
2376 let off = x
2377 .cylinder()
2378 .distance_to(p)
2379 .abs()
2380 .max(y.cylinder().distance_to(p).abs());
2381 assert!(
2382 off <= section.tolerance * 2.0,
2383 "at t = {t} the fitted curve is {off:e} off, tolerance {}",
2384 section.tolerance
2385 );
2386 }
2387 }
2388 }
2389
2390 #[test]
2394 fn a_plane_all_but_along_the_axis_still_meets_a_short_drum() {
2395 let drum = cylinder(Vector::Z, 1.0);
2396 let wall: SurfaceGeometry = PlaneSurface::over(
2397 Plane::through(
2398 Point::new(0.0, 0.6, 0.0),
2399 Direction::new(Vector::new(0.0, 1.0, 1e-4), T).unwrap(),
2400 ),
2401 (-1e9, 1e9),
2402 (-1e9, 1e9),
2403 )
2404 .unwrap()
2405 .into();
2406 let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
2407 let SurfaceIntersection::Along(sections) = met else {
2408 panic!("the wall crosses the drum: {met:?}");
2409 };
2410 assert_eq!(sections.len(), 1);
2411 let curve = §ions[0].curve;
2412 let (lo, hi) = curve.domain();
2413 let inside = (0..=100_000).any(|k| {
2414 let p = curve
2415 .point_at(lo + (hi - lo) * f64::from(k) / 100_000.0, T)
2416 .unwrap();
2417 p.z.abs() <= 4.0
2418 });
2419 assert!(inside, "and the section runs through the drum's height");
2420 }
2421
2422 fn on_both(section: &SectionCurve, a: &SurfaceGeometry, b: &SurfaceGeometry) {
2425 let (lo, hi) = section.curve.domain();
2426 for k in 0..=64 {
2427 let p = section
2428 .curve
2429 .point_at(lo + (hi - lo) * f64::from(k) / 64.0, T)
2430 .unwrap();
2431 for surface in [a, b] {
2432 let off = match surface {
2433 SurfaceGeometry::Plane(plane) => plane.plane().signed_distance_to(p).abs(),
2434 SurfaceGeometry::Cylinder(drum) => {
2435 let axis = drum.cylinder().axis();
2436 let rel = p - axis.location;
2437 let d = axis.direction.vector();
2438 ((rel - d * rel.dot(d)).magnitude() - drum.cylinder().radius()).abs()
2439 }
2440 _ => unreachable!("planes and drums only"),
2441 };
2442 assert!(
2443 off <= section.tolerance + 1e-9,
2444 "{p:?} is {off:e} off, stated {:e}",
2445 section.tolerance
2446 );
2447 }
2448 }
2449 }
2450
2451 #[test]
2457 fn a_plane_all_but_along_a_drums_axis_meets_it_in_two_near_lines() {
2458 let drum = cylinder(Vector::Z, 1.0);
2459 let wall: SurfaceGeometry = PlaneSurface::over(
2460 Plane::through(
2461 Point::new(0.0, 0.99, 0.0),
2462 Direction::new(Vector::new(0.0, 1.0, 2e-5), T).unwrap(),
2463 ),
2464 (-1e9, 1e9),
2465 (-1e9, 1e9),
2466 )
2467 .unwrap()
2468 .into();
2469 let met = intersect_surfaces(&wall, &drum, IntersectOptions::default(), T).unwrap();
2470 let SurfaceIntersection::Along(sections) = met else {
2471 panic!("the wall crosses the drum: {met:?}");
2472 };
2473 assert_eq!(sections.len(), 2);
2474 for section in §ions {
2475 assert!(section.tolerance > 0.0 && section.tolerance <= 1e-5);
2476 on_both(section, &wall, &drum);
2477 }
2478 }
2479
2480 #[test]
2484 fn drums_all_but_parallel_meet_in_two_near_lines() {
2485 let drill = cylinder(Vector::Z, 1.0);
2486 let frame = Frame::new(
2487 Point::new(1.5, 0.0, 0.0),
2488 Direction::new(Vector::new(5e-5, 0.0, 1.0), T).unwrap(),
2489 Direction::X,
2490 T,
2491 )
2492 .unwrap();
2493 let bore: SurfaceGeometry =
2494 CylinderSurface::new(Cylinder::new(frame, 1.0, T).unwrap(), (-3.0, 3.0))
2495 .unwrap()
2496 .into();
2497 let met = intersect_surfaces(&drill, &bore, IntersectOptions::default(), T).unwrap();
2498 let SurfaceIntersection::Along(sections) = met else {
2499 panic!("the drums cross: {met:?}");
2500 };
2501 assert_eq!(sections.len(), 2);
2502 for section in §ions {
2503 assert!(!section.exact && section.tolerance <= 1e-5);
2504 let (lo, hi) = section.curve.domain();
2505 let (p, q) = (
2506 section.curve.point_at(lo, T).unwrap(),
2507 section.curve.point_at(hi, T).unwrap(),
2508 );
2509 assert!(
2510 (p.z - q.z).abs() > 5.9,
2511 "over the shared height: {p:?} {q:?}"
2512 );
2513 on_both(section, &drill, &bore);
2514 }
2515 }
2516
2517 #[test]
2518 fn exact_lines_are_clipped_to_the_surfaces_extents() {
2519 let drum = cylinder(Vector::Z, 2.0);
2524 let cut = plane(Point::ORIGIN, Vector::X);
2525 let SurfaceIntersection::Along(curves) =
2526 intersect_surfaces(&drum, &cut, IntersectOptions::default(), T).unwrap()
2527 else {
2528 panic!("expected curves");
2529 };
2530 for section in &curves {
2531 let (lo, hi) = section.curve.domain();
2532 assert!(
2534 hi - lo <= 8.0 + 1e-9,
2535 "the line was not clipped: [{lo}, {hi}]"
2536 );
2537 let start = section.curve.point_at(lo, T).unwrap();
2538 let end = section.curve.point_at(hi, T).unwrap();
2539 assert!(start.z >= -4.0 - 1e-9 && end.z <= 4.0 + 1e-9);
2540 }
2541
2542 let high = plane(Point::new(0.0, 0.0, 10.0), Vector::Z);
2546 assert_eq!(
2547 intersect_surfaces(&drum, &high, IntersectOptions::default(), T).unwrap(),
2548 SurfaceIntersection::Apart
2549 );
2550 }
2551
2552 #[test]
2553 fn the_degenerate_answers_pass_through() {
2554 assert_eq!(
2555 intersect_surfaces(
2556 &sphere(Point::ORIGIN, 1.0),
2557 &sphere(Point::new(5.0, 0.0, 0.0), 1.0),
2558 IntersectOptions::default(),
2559 T
2560 )
2561 .unwrap(),
2562 SurfaceIntersection::Apart
2563 );
2564 assert_eq!(
2565 intersect_surfaces(
2566 &sphere(Point::ORIGIN, 1.0),
2567 &sphere(Point::ORIGIN, 1.0),
2568 IntersectOptions::default(),
2569 T
2570 )
2571 .unwrap(),
2572 SurfaceIntersection::Same
2573 );
2574 assert!(matches!(
2575 intersect_surfaces(
2576 &plane(Point::ORIGIN, Vector::Z),
2577 &sphere(Point::new(0.0, 0.0, 2.0), 2.0),
2578 IntersectOptions::default(),
2579 T
2580 )
2581 .unwrap(),
2582 SurfaceIntersection::Touching(ref p) if p.len() == 1
2583 ));
2584 }
2585
2586 #[test]
2587 fn unusable_options_are_refused() {
2588 let a = sphere(Point::ORIGIN, 1.0);
2589 let b = plane(Point::ORIGIN, Vector::Z);
2590 for tolerance in [0.0, -1.0, f64::NAN] {
2591 let options = IntersectOptions {
2592 tolerance,
2593 ..IntersectOptions::default()
2594 };
2595 assert!(intersect_surfaces(&a, &b, options, T).is_err());
2596 }
2597 }
2598
2599 #[test]
2600 fn a_circle_wound_against_the_axis_keeps_its_pcurve_same_parameter() {
2601 let drum: SurfaceGeometry = CylinderSurface::new(
2608 Cylinder::new(
2609 Frame::new(Point::new(2.0, 2.0, -1.0), Direction::Z, Direction::X, T).unwrap(),
2610 0.5,
2611 T,
2612 )
2613 .unwrap(),
2614 (0.0, 3.0),
2615 )
2616 .unwrap()
2617 .into();
2618 for normal in [Direction::Z, -Direction::Z] {
2619 let frame = Frame::new(Point::ORIGIN, normal, Direction::X, T).unwrap();
2620 let ground: SurfaceGeometry =
2621 PlaneSurface::over(Plane::new(frame), (-4.0, 4.0), (-4.0, 4.0))
2622 .unwrap()
2623 .into();
2624 let met = intersect_surfaces(&ground, &drum, IntersectOptions::default(), T).unwrap();
2625 let SurfaceIntersection::Along(curves) = met else {
2626 panic!("a plane through a cylinder sections it");
2627 };
2628 for sc in &curves {
2629 let pcurve = sc
2630 .on_b
2631 .as_ref()
2632 .expect("a circle on its cylinder has a pcurve");
2633 let (lo, hi) = sc.curve.domain();
2634 for i in 0..8 {
2635 let t = lo + (hi - lo) * f64::from(i) / 8.0;
2636 let p3 = sc.curve.point_at(t, T).unwrap();
2637 let uv = pcurve.point_at(t, T).unwrap();
2638 let lifted = drum
2639 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2640 .unwrap();
2641 assert!(
2642 p3.distance(lifted) < 1e-9,
2643 "normal {normal:?}, t {t}: pcurve lifts {lifted:?} against {p3:?}"
2644 );
2645 }
2646 }
2647 }
2648 }
2649
2650 #[test]
2656 fn a_meridian_half_has_an_exact_line_for_a_pcurve() {
2657 use ogeom_geom::Surface as _;
2658 let half = core::f64::consts::PI;
2659 for (centre, radius) in [(Point::ORIGIN, 4.0), (Point::new(1.0, -2.0, 0.5), 1.25)] {
2660 let ball = sphere(centre, radius);
2661 let SurfaceGeometry::Sphere(s) = &ball else {
2662 panic!("a sphere surface");
2663 };
2664 for azimuth in [0.0_f64, 0.7, 2.4] {
2667 let normal = Vector::new(-azimuth.sin(), azimuth.cos(), 0.0);
2668 let cut = plane(centre, normal);
2669 let SurfaceIntersection::Along(curves) =
2670 intersect_surfaces(&ball, &cut, IntersectOptions::default(), T).unwrap()
2671 else {
2672 panic!("a plane through the centre meets the ball along a circle");
2673 };
2674 assert_eq!(curves.len(), 1, "one great circle");
2675 let circle = &curves[0].curve;
2676 assert!(curves[0].exact);
2677 assert!(
2679 exact_pcurve_over(circle, circle.domain(), &ball, T).is_none(),
2680 "the whole meridian has no single chart image"
2681 );
2682 for (lo, hi) in [(0.0, half), (half, 2.0 * half), (0.3, half - 0.1)] {
2683 let pcurve = exact_pcurve_over(circle, (lo, hi), &ball, T)
2684 .expect("half a meridian has an exact pcurve");
2685 assert!(
2686 matches!(pcurve, PlanarCurve::Line(_)),
2687 "and it is a straight line in the chart"
2688 );
2689 for i in 0..=16 {
2690 let t = (hi - lo).mul_add(f64::from(i) / 16.0, lo);
2691 let want = circle.point_at(t, T).unwrap();
2692 let uv = pcurve.point_at(t, T).unwrap();
2693 assert!(
2694 uv.y >= -half.mul_add(0.5, 1e-12) && uv.y <= half.mul_add(0.5, 1e-12),
2695 "the latitude stays inside the chart: {}",
2696 uv.y
2697 );
2698 let lifted = ball
2699 .point_at(uv.x.rem_euclid(core::f64::consts::TAU), uv.y, T)
2700 .unwrap();
2701 assert!(
2702 want.distance(lifted) < 1e-9,
2703 "azimuth {azimuth}, t {t}: {lifted:?} against {want:?}"
2704 );
2705 }
2706 }
2707 assert!(
2710 exact_pcurve_over(circle, (half - 0.2, half + 0.2), &ball, T).is_none(),
2711 "a range across a pole has no one line"
2712 );
2713 let _ = s;
2714 }
2715 }
2716 }
2717
2718 #[test]
2727 fn a_trimmed_curve_carries_its_basis_pcurve_trimmed_the_same_way() {
2728 use ogeom_geom::TrimmedCurve;
2729 let drum = cylinder(Vector::Z, 2.0);
2730 let ground = plane(Point::new(0.0, 0.0, 1.0), Vector::Z);
2731 let SurfaceIntersection::Along(curves) =
2733 intersect_surfaces(&drum, &ground, IntersectOptions::default(), T).unwrap()
2734 else {
2735 panic!("a plane across a cylinder meets it in a circle");
2736 };
2737 let whole = curves[0].curve.clone();
2738 let (lo, hi) = whole.domain();
2739 let quarter: Curve = TrimmedCurve::new(whole.clone(), lo + 0.3, lo + (hi - lo) / 4.0, T)
2740 .unwrap()
2741 .into();
2742
2743 for surface in [&drum, &ground] {
2744 let full = exact_pcurve_of(&whole, surface, T).expect("the whole circle has one");
2745 let part = exact_pcurve_of(&quarter, surface, T).expect("and so does a quarter of it");
2746 let (a, b) = quarter.domain();
2749 for i in 0..=8 {
2750 let t = (b - a).mul_add(f64::from(i) / 8.0, a);
2751 let (whole_at, part_at) =
2752 (full.point_at(t, T).unwrap(), part.point_at(t, T).unwrap());
2753 assert!(
2754 whole_at.distance(part_at) < 1e-12,
2755 "the trim carries the basis: {whole_at:?} against {part_at:?}"
2756 );
2757 let lifted = surface
2759 .point_at(part_at.x.rem_euclid(core::f64::consts::TAU), part_at.y, T)
2760 .or_else(|_| surface.point_at(part_at.x, part_at.y, T))
2761 .unwrap();
2762 assert!(
2763 lifted.distance(quarter.point_at(t, T).unwrap()) < 1e-9,
2764 "same-parameter, still"
2765 );
2766 }
2767 }
2768 }
2769 #[test]
2773 fn a_plane_through_a_cones_apex_holds_its_rulings() {
2774 use ogeom_geom::ConeSurface;
2775 let frame = Frame::new(
2776 Point::new(100.0, 200.0, 300.0),
2777 Direction::Z,
2778 Direction::X,
2779 T,
2780 )
2781 .unwrap();
2782 let cone = ogeom_math::Cone::new(frame, 10.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2783 let surface: SurfaceGeometry = ConeSurface::new(cone, (-5.0, 50.0)).unwrap().into();
2784 let apex = Point::new(100.0, 200.0, 290.0);
2785 let plane = |normal: Vector| -> SurfaceGeometry {
2786 PlaneSurface::new(Plane::through(apex, Direction::new(normal, T).unwrap())).into()
2787 };
2788 let cases = [
2789 (Vector::new(1.0, 0.0, -1.0), 1, true),
2790 (Vector::new(1.0, 0.0, 0.0), 2, false),
2791 ];
2792 for (normal, count, tangent) in cases {
2793 let cut = plane(normal);
2794 let SurfaceIntersection::Along(sections) =
2795 intersect_surfaces(&surface, &cut, IntersectOptions::default(), T).unwrap()
2796 else {
2797 panic!("{normal:?}: rulings");
2798 };
2799 assert_eq!(sections.len(), count, "{normal:?}");
2800 for section in §ions {
2801 assert_eq!(section.tangential, tangent, "{normal:?}");
2802 let (lo, hi) = section.curve.domain();
2803 for k in 0..=4 {
2804 let p = section
2805 .curve
2806 .point_at(lo + (hi - lo) * f64::from(k) / 4.0, T)
2807 .unwrap();
2808 assert!(cone.distance_to(p) < 1e-9, "{p:?} on the cone");
2809 let height = p.z - 300.0;
2810 assert!(
2811 (-5.0 - 1e-9..=50.0 + 1e-9).contains(&height),
2812 "{p:?} in the window"
2813 );
2814 }
2815 }
2816 }
2817 let shallow = plane(Vector::new(0.2, 0.0, 1.0));
2818 assert!(matches!(
2819 intersect_surfaces(&surface, &shallow, IntersectOptions::default(), T).unwrap(),
2820 SurfaceIntersection::Touching(_) | SurfaceIntersection::Apart
2821 ));
2822 }
2823
2824 #[test]
2825 fn a_far_stated_ruling_reads_its_angle_on_the_used_nappe() {
2826 use ogeom_geom::ConeSurface;
2827 let cone =
2835 ogeom_math::Cone::new(Frame::WORLD, 24.0, core::f64::consts::FRAC_PI_4, T).unwrap();
2836 let surface: SurfaceGeometry = ConeSurface::new(cone, (-1e5, 1e5)).unwrap().into();
2837 let u_true = 0.01_f64;
2838 let radial = Vector::new(u_true.cos(), u_true.sin(), 0.0);
2839 let direction =
2842 Direction::new((radial + Vector::new(0.0, 0.0, 1.0)) / 2f64.sqrt(), T).unwrap();
2843 let far = -7.0e5;
2844 let origin = Point::ORIGIN + radial * 24.0 + direction.vector() * far;
2845 let line = ogeom_geom::LineCurve::over(
2846 ogeom_math::Axis::new(origin, direction),
2847 far.abs() - 1.0,
2848 far.abs() + 1.0,
2849 )
2850 .unwrap();
2851 let curve: Curve = line.into();
2852 let range = ogeom_geom::Curve3d::domain(&curve);
2853 let pcurve = exact_pcurve_over(&curve, range, &surface, T).expect("a ruling inverts");
2854 let at = pcurve.point_at(range.0, T).unwrap();
2855 let tau = core::f64::consts::TAU;
2856 let gap = (at.x - u_true)
2857 .rem_euclid(tau)
2858 .min(tau - (at.x - u_true).rem_euclid(tau));
2859 assert!(
2860 gap < 1e-6,
2861 "the ruling's chart angle must be the used side's: got u {} against {u_true}",
2862 at.x
2863 );
2864 }
2865}