1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
34use ogeom_geom::{BSpline2d, BSplineCurve, Surface, SurfaceGeometry};
35use ogeom_math::Point2;
36
37use crate::march::Traced;
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct IntersectionCurve {
42 pub curve: BSplineCurve,
44 pub on_a: BSpline2d,
46 pub on_b: BSpline2d,
48 pub fit_error: f64,
58 pub met: bool,
60 pub closed: bool,
62}
63
64pub fn approximate_branch(
71 a: &SurfaceGeometry,
72 b: &SurfaceGeometry,
73 branch: &Traced,
74 tolerance: f64,
75 tol: Tolerances,
76) -> OgeomResult<IntersectionCurve> {
77 if branch.points.len() < 2 {
78 ogeom_bail!(
79 Construction,
80 "a branch of {} points is not a curve",
81 branch.points.len()
82 );
83 }
84
85 let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
91 let mut kept_a = Vec::with_capacity(branch.on_a.len());
92 let mut kept_b = Vec::with_capacity(branch.on_b.len());
93 let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
99 let limit = tolerance.max(tol.confusion());
100 let (ua, va) = branch.on_a[i];
101 let (ub, vb) = branch.on_b[i];
102 a.point_at(ua, va, tol)
103 .is_ok_and(|q| q.distance(*p) <= limit)
104 && b.point_at(ub, vb, tol)
105 .is_ok_and(|q| q.distance(*p) <= limit)
106 };
107 for (i, p) in branch.points.iter().enumerate() {
108 let end = i == 0 || i + 1 == branch.points.len();
109 if let Some(last) = points.last()
110 && last.distance(*p) <= tol.confusion() * 10.0
111 && i + 1 != branch.points.len()
112 {
113 continue;
114 }
115 if !end && !agrees(i, p) {
116 continue;
117 }
118 points.push(*p);
119 kept_a.push(branch.on_a[i]);
120 kept_b.push(branch.on_b[i]);
121 }
122 if points.len() < 2 {
123 ogeom_bail!(Construction, "a branch of coincident points is not a curve");
124 }
125
126 let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
134 let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
135 let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
146 let (first, last) = (image[0], image[image.len() - 1]);
147 ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
148 && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
149 };
150 let free = || -> OgeomResult<Joint> {
151 if branch.closed() {
152 let closed = ogeom_geom::fit::fit_points_joint_closed(
153 &points,
154 &unwrapped_a,
155 &unwrapped_b,
156 3,
157 tolerance,
158 tol,
159 )?;
160 if !closed.0.met
161 && winds_periodically(a, &unwrapped_a)
162 && winds_periodically(b, &unwrapped_b)
163 {
164 let winding = ogeom_geom::fit::fit_points_joint_winding(
165 &points,
166 &unwrapped_a,
167 &unwrapped_b,
168 3,
169 tolerance,
170 tol,
171 )?;
172 if winding.0.error < closed.0.error {
173 return Ok(winding);
174 }
175 }
176 Ok(closed)
177 } else {
178 ogeom_geom::fit::fit_points_joint(
179 &points,
180 &unwrapped_a,
181 &unwrapped_b,
182 3,
183 tolerance,
184 tol,
185 )
186 }
187 };
188 let stations = cubic_stations(&points, &unwrapped_a, &unwrapped_b, tolerance);
196 let seeded = |closed: bool| {
197 ogeom_geom::fit::fit_points_joint_from(
198 &points,
199 &unwrapped_a,
200 &unwrapped_b,
201 &stations,
202 closed,
203 3,
204 tolerance,
205 tol,
206 )
207 };
208 let meets_itself = {
209 let n = points.len();
210 let (pa, pb) = (
211 unwrapped_a[n - 1] - unwrapped_a[0],
212 unwrapped_b[n - 1] - unwrapped_b[0],
213 );
214 let gap = (points[n - 1] - points[0]).square_magnitude()
215 + pa.square_magnitude()
216 + pb.square_magnitude();
217 gap.sqrt() <= tol.confusion()
218 };
219 let mut fitted = seeded(branch.closed() && meets_itself).ok();
220 if branch.closed()
221 && !meets_itself
222 && fitted.as_ref().is_none_or(|f| f.0.error > tolerance)
223 && winds_periodically(a, &unwrapped_a)
224 && winds_periodically(b, &unwrapped_b)
225 && let Ok(winding) = seeded(true)
226 && fitted.as_ref().is_none_or(|f| winding.0.error < f.0.error)
227 {
228 fitted = Some(winding);
229 }
230 let (space, on_a, on_b) = match fitted {
231 Some(fitted) if fitted.0.error <= tolerance => fitted,
232 Some(fitted) => {
233 let other = free()?;
234 if other.0.error < fitted.0.error {
235 other
236 } else {
237 fitted
238 }
239 }
240 None => free()?,
241 };
242
243 let lifted = lift_error(a, b, &on_a, &on_b, &space.curve, tol);
247 let crossing = if lifted.unsettled {
255 let charted =
256 space_error(a, &on_a, space.error, tol).max(space_error(b, &on_b, space.error, tol));
257 lifted.lifted_off.max(charted.max(space.error))
258 } else {
259 lifted.lifted_off
260 };
261 let lifted = lifted
262 .along
263 .max(lifted.curve_off)
264 .max(lifted.across.min(crossing));
265 let fit_error = if space.error <= lifted {
268 lifted
269 } else {
270 lifted.max(trace_error(&space.curve, &points, space.error, tol))
271 };
272 Ok(IntersectionCurve {
273 fit_error,
274 met: space.error <= tolerance,
275 curve: space.curve,
276 on_a,
277 on_b,
278 closed: branch.closed(),
279 })
280}
281
282type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
284
285fn cubic_stations(
291 points: &[ogeom_math::Point],
292 image_a: &[Point2],
293 image_b: &[Point2],
294 tolerance: f64,
295) -> Vec<usize> {
296 let n = points.len();
297 let target = tolerance / 8.0;
298 let traces: [Vec<[f64; 3]>; 3] = [
299 points.iter().map(|p| [p.x, p.y, p.z]).collect(),
300 image_a.iter().map(|p| [p.x, p.y, 0.0]).collect(),
301 image_b.iter().map(|p| [p.x, p.y, 0.0]).collect(),
302 ];
303 let shape: Vec<(Vec<f64>, Vec<f64>)> = traces
306 .iter()
307 .map(|trace| {
308 let segment =
309 |k: usize| -> [f64; 3] { core::array::from_fn(|d| trace[k + 1][d] - trace[k][d]) };
310 let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
311 let lengths: Vec<f64> = (0..n - 1)
312 .map(|k| dot(segment(k), segment(k)).sqrt())
313 .collect();
314 let mut turns = vec![0.0; n];
315 for k in 1..n - 1 {
316 let scale = lengths[k - 1] * lengths[k];
317 if scale > 0.0 {
318 turns[k] = (dot(segment(k - 1), segment(k)) / scale)
319 .clamp(-1.0, 1.0)
320 .acos();
321 }
322 }
323 (lengths, turns)
324 })
325 .collect();
326 let mut out = vec![0];
327 let mut from = 0;
328 while from + 1 < n {
329 let mut sums: Vec<(f64, f64)> = shape
330 .iter()
331 .map(|(lengths, _)| (lengths[from], 0.0))
332 .collect();
333 let mut to = from + 1;
334 while to + 1 < n {
335 let grown: Vec<(f64, f64)> = sums
336 .iter()
337 .zip(&shape)
338 .map(|(&(length, turn), (lengths, turns))| (length + lengths[to], turn + turns[to]))
339 .collect();
340 if grown
341 .iter()
342 .any(|&(length, turn)| length * turn.powi(3) / 384.0 > target)
343 {
344 break;
345 }
346 sums = grown;
347 to += 1;
348 }
349 out.push(to);
350 from = to;
351 }
352 out
353}
354
355struct Lifted {
357 along: f64,
360 across: f64,
363 curve_off: f64,
366 lifted_off: f64,
368 unsettled: bool,
370}
371
372fn lift_error(
386 a: &SurfaceGeometry,
387 b: &SurfaceGeometry,
388 on_a: &BSpline2d,
389 on_b: &BSpline2d,
390 curve: &BSplineCurve,
391 tol: Tolerances,
392) -> Lifted {
393 const NEAR_PEAK: f64 = 0.8;
397 const PEAKS: usize = 16;
398 const CLIMB: usize = 16;
399 let (lo, hi) = curve.knots().domain();
400 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
401 let stations = (4 * spans).max(200);
402 #[allow(clippy::cast_precision_loss)]
403 let step = (hi - lo) / stations as f64;
404 let mut out = Lifted {
405 along: 0.0,
406 across: 0.0,
407 curve_off: 0.0,
408 lifted_off: 0.0,
409 unsettled: false,
410 };
411 #[allow(clippy::cast_precision_loss)]
412 let at = |k: usize| lo + (hi - lo) * k as f64 / stations as f64;
413 let read = |t: f64| station(a, b, on_a, on_b, curve, t, step, tol);
414 let mut misses = Vec::with_capacity(stations + 1);
416 for k in 0..=stations {
417 let Some(here) = read(at(k)) else {
418 misses.push(0.0);
419 continue;
420 };
421 out.along = out.along.max(here.gap);
422 out.across = out.across.max(here.across);
423 if let Some((curve_off, lifted_off)) = here.off {
424 out.curve_off = out.curve_off.max(curve_off);
425 out.lifted_off = out.lifted_off.max(lifted_off);
426 misses.push(curve_off.max(lifted_off));
427 } else {
428 out.unsettled = true;
429 misses.push(0.0);
430 }
431 }
432 let widest = misses.iter().fold(0.0_f64, |m, &x| m.max(x));
433 let mut peaks: Vec<usize> = (0..=stations)
434 .filter(|&k| {
435 misses[k] > 0.0
436 && misses[k] >= widest * NEAR_PEAK
437 && (k == 0 || misses[k] >= misses[k - 1])
438 && (k == stations || misses[k] >= misses[k + 1])
439 })
440 .collect();
441 peaks.sort_by(|&i, &j| misses[j].total_cmp(&misses[i]));
442 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
443 let mut climb = |t: f64| {
444 read(t)
445 .and_then(|s| s.off)
446 .map_or(0.0, |(curve_off, lifted_off)| {
447 out.curve_off = out.curve_off.max(curve_off);
448 out.lifted_off = out.lifted_off.max(lifted_off);
449 curve_off.max(lifted_off)
450 })
451 };
452 for k in peaks.into_iter().take(PEAKS) {
453 let (mut left, mut right) = (at(k.saturating_sub(1)), at((k + 1).min(stations)));
454 let mut c = right - (right - left) * ratio;
455 let mut d = left + (right - left) * ratio;
456 let (mut rc, mut rd) = (climb(c), climb(d));
457 for _ in 0..CLIMB {
458 if rc > rd {
459 right = d;
460 (d, rd) = (c, rc);
461 c = right - (right - left) * ratio;
462 rc = climb(c);
463 } else {
464 left = c;
465 (c, rc) = (d, rd);
466 d = left + (right - left) * ratio;
467 rd = climb(d);
468 }
469 }
470 }
471 out
472}
473
474struct Station {
476 gap: f64,
478 across: f64,
481 off: Option<(f64, f64)>,
484}
485
486type Jet = (
488 Point2,
489 ogeom_math::Point,
490 ogeom_math::Vector,
491 ogeom_math::Vector,
492);
493
494#[allow(clippy::too_many_arguments)]
500fn station(
501 a: &SurfaceGeometry,
502 b: &SurfaceGeometry,
503 on_a: &BSpline2d,
504 on_b: &BSpline2d,
505 curve: &BSplineCurve,
506 t: f64,
507 step: f64,
508 tol: Tolerances,
509) -> Option<Station> {
510 use ogeom_geom::{Curve2d as _, Curve3d as _};
511 let on = curve.point_at(t, tol).ok()?;
512 let mut gap = 0.0_f64;
513 let mut normals = [None; 2];
514 let mut jets: [Option<Jet>; 2] = [None; 2];
515 for (k, (surface, pcurve)) in [(a, on_a), (b, on_b)].into_iter().enumerate() {
516 let Ok(chart) = pcurve.point_at(t, tol) else {
517 continue;
518 };
519 let Ok((lifted, du, dv)) = surface.point_d1_at(chart.x, chart.y, tol) else {
520 continue;
521 };
522 gap = gap.max(lifted.distance(on));
523 jets[k] = Some((chart, lifted, du, dv));
524 let scale = du.magnitude().max(dv.magnitude());
527 let cross = du.cross(dv);
528 let length = cross.magnitude();
529 if length > tol.angular() * scale * scale {
530 normals[k] = Some(cross * (1.0 / length));
531 }
532 }
533 let across = match normals {
534 [Some(na), Some(nb)] => gap / na.cross(nb).magnitude().max(tol.angular()),
535 _ => 0.0,
536 };
537 let off = match (jets, curve.d1_at(t, tol)) {
538 ([Some(ja), Some(jb)], Ok(tangent)) => {
539 let reach = tangent.magnitude() * step;
540 crossing(a, b, ja, jb, on, tangent, reach, tol)
541 .filter(|found| found.distance(on) <= reach)
542 .map(|found| {
543 (
544 on.distance(found),
545 ja.1.distance(found).max(jb.1.distance(found)),
546 )
547 })
548 }
549 _ => None,
550 };
551 Some(Station { gap, across, off })
552}
553
554#[allow(clippy::too_many_arguments)]
562fn crossing(
563 a: &SurfaceGeometry,
564 b: &SurfaceGeometry,
565 on_a: Jet,
566 on_b: Jet,
567 anchor: ogeom_math::Point,
568 along: ogeom_math::Vector,
569 reach: f64,
570 tol: Tolerances,
571) -> Option<ogeom_math::Point> {
572 const MOST: usize = 8;
573 const HALVINGS: usize = 8;
574 let settled = tol.confusion() * 0.01;
575 let residual_of = |pa: ogeom_math::Point, pb: ogeom_math::Point| {
576 let gap = pa - pb;
577 [gap.x, gap.y, gap.z, (pa - anchor).dot(along)]
578 };
579 let jacobian_of = |(au, av): (ogeom_math::Vector, ogeom_math::Vector),
580 (bu, bv): (ogeom_math::Vector, ogeom_math::Vector)| {
581 [
582 [au.x, av.x, -bu.x, -bv.x],
583 [au.y, av.y, -bu.y, -bv.y],
584 [au.z, av.z, -bu.z, -bv.z],
585 [au.dot(along), av.dot(along), 0.0, 0.0],
586 ]
587 };
588 let norm = |r: &[f64; 4]| r.iter().map(|v| v * v).sum::<f64>().sqrt();
589 let mut x = [on_a.0.x, on_a.0.y, on_b.0.x, on_b.0.y];
590 let mut point = on_a.1;
591 let mut residual = residual_of(on_a.1, on_b.1);
592 let mut jacobian = jacobian_of((on_a.2, on_a.3), (on_b.2, on_b.3));
593 let mut size = norm(&residual);
594 for _ in 0..MOST {
595 if size <= settled {
596 break;
597 }
598 let delta = solve4(jacobian, residual)?;
599 let carried = (0..3)
603 .map(|row| {
604 let on_a = jacobian[row][0] * delta[0] + jacobian[row][1] * delta[1];
605 let on_b = jacobian[row][2] * delta[2] + jacobian[row][3] * delta[3];
606 (on_a * on_a, on_b * on_b)
607 })
608 .fold((0.0, 0.0), |(sa, sb), (a, b)| (sa + a, sb + b));
609 let carried = carried.0.max(carried.1).sqrt();
610 let mut scale = 1.0;
611 while scale * carried > reach {
612 scale *= 0.5;
613 }
614 let mut accepted = None;
615 for _ in 0..HALVINGS {
616 let trial: [f64; 4] = core::array::from_fn(|i| x[i] - delta[i] * scale);
617 let (ua, va) = crate::march::clamp(a, trial[0], trial[1]);
618 let (ub, vb) = crate::march::clamp(b, trial[2], trial[3]);
619 if let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) {
620 let r = residual_of(pa, pb);
621 let trial_size = norm(&r);
622 if (trial_size < size || trial_size <= settled)
623 && let (Ok(da), Ok(db)) = (a.d1_at(ua, va, tol), b.d1_at(ub, vb, tol))
624 {
625 accepted = Some((trial, pa, r, jacobian_of(da, db), trial_size));
626 break;
627 }
628 }
629 scale *= 0.5;
630 }
631 let Some((next, at, r, j, next_size)) = accepted else {
632 break;
633 };
634 let step = norm(&core::array::from_fn(|i| next[i] - x[i]));
635 (x, point, residual, jacobian, size) = (next, at, r, j, next_size);
636 if step <= tol.parametric() {
637 break;
638 }
639 }
640 (size <= tol.confusion()).then_some(point)
641}
642
643fn solve4(mut m: [[f64; 4]; 4], mut r: [f64; 4]) -> Option<[f64; 4]> {
646 for col in 0..4 {
647 let pivot = (col..4).max_by(|&i, &j| m[i][col].abs().total_cmp(&m[j][col].abs()))?;
648 if m[pivot][col] == 0.0 {
649 return None;
650 }
651 m.swap(col, pivot);
652 r.swap(col, pivot);
653 let head = m[col];
654 for row in col + 1..4 {
655 let factor = m[row][col] / head[col];
656 for (entry, above) in m[row].iter_mut().zip(&head).skip(col) {
657 *entry -= factor * above;
658 }
659 r[row] -= factor * r[col];
660 }
661 }
662 for col in (0..4).rev() {
663 r[col] /= m[col][col];
664 for row in 0..col {
665 r[row] -= m[row][col] * r[col];
666 }
667 }
668 Some(r)
669}
670
671fn trace_error(
681 curve: &BSplineCurve,
682 samples: &[ogeom_math::Point],
683 bound: f64,
684 tol: Tolerances,
685) -> f64 {
686 use ogeom_geom::Curve3d as _;
687 let (lo, hi) = curve.knots().domain();
688 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
689 let count = (4 * spans).max(2 * samples.len()).max(200);
690 #[allow(clippy::cast_precision_loss)]
691 let at = |k: usize| lo + (hi - lo) * k as f64 / count as f64;
692 let mut stations: Option<Vec<(f64, ogeom_math::Point)>> = None;
693 let foot = |p: ogeom_math::Point, mut t: f64| -> (f64, f64) {
694 let mut best = (t, f64::INFINITY);
695 for _ in 0..8 {
696 let (Ok(q), Ok(d)) = (curve.point_at(t, tol), curve.d1_at(t, tol)) else {
697 break;
698 };
699 let gap = q.distance(p);
700 if gap < best.1 {
701 best = (t, gap);
702 }
703 let speed = d.dot(d);
704 if speed <= f64::MIN_POSITIVE {
705 break;
706 }
707 let next = (t + (p - q).dot(d) / speed).clamp(lo, hi);
708 if (next - t).abs() <= (hi - lo) * 1e-12 {
709 break;
710 }
711 t = next;
712 }
713 if let Ok(q) = curve.point_at(t, tol)
714 && q.distance(p) < best.1
715 {
716 best = (t, q.distance(p));
717 }
718 best
719 };
720 let mut t = lo;
721 let mut worst = 0.0_f64;
722 for p in samples {
723 let mut found = foot(*p, t);
724 if found.1 > bound {
725 let stations = stations.get_or_insert_with(|| {
726 (0..=count)
727 .filter_map(|k| curve.point_at(at(k), tol).ok().map(|q| (at(k), q)))
728 .collect()
729 });
730 if let Some(k) = (0..stations.len()).min_by(|&x, &y| {
731 stations[x]
732 .1
733 .distance(*p)
734 .total_cmp(&stations[y].1.distance(*p))
735 }) {
736 let gap = |u: f64| {
742 curve
743 .point_at(u, tol)
744 .map_or(f64::INFINITY, |q| q.distance(*p))
745 };
746 let (from, to) = (
747 stations[k.saturating_sub(2)].0,
748 stations[(k + 2).min(stations.len() - 1)].0,
749 );
750 const FINE: u32 = 256;
751 let h = (to - from) / f64::from(FINE);
752 let start = (0..=FINE)
753 .map(|j| from + h * f64::from(j))
754 .min_by(|&x, &y| gap(x).total_cmp(&gap(y)))
755 .unwrap_or(from);
756 let (mut a, mut b) = ((start - h).max(lo), (start + h).min(hi));
757 let ratio = 0.5 * (5.0_f64.sqrt() - 1.0);
758 for _ in 0..60 {
759 let (x, y) = (b - ratio * (b - a), a + ratio * (b - a));
760 if gap(x) <= gap(y) {
761 b = y;
762 } else {
763 a = x;
764 }
765 }
766 let again = foot(*p, 0.5 * (a + b));
767 if again.1 < found.1 {
768 found = again;
769 }
770 }
771 }
772 t = found.0;
773 worst = worst.max(found.1.min(bound));
774 }
775 worst
776}
777
778fn space_error(
783 surface: &SurfaceGeometry,
784 pcurve: &BSpline2d,
785 parameter_error: f64,
786 tol: Tolerances,
787) -> f64 {
788 use ogeom_geom::Curve2d;
789 let (lo, hi) = pcurve.domain();
792 let mut worst = 0.0_f64;
793 for i in 0..=16 {
794 #[allow(clippy::cast_precision_loss)]
795 let u = lo + (hi - lo) * f64::from(i) / 16.0;
796 let Ok(at) = pcurve.point_at(u, tol) else {
797 continue;
798 };
799 let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
800 continue;
801 };
802 let stretch = du.magnitude().max(dv.magnitude());
803 worst = worst.max(parameter_error * stretch);
804 }
805 worst
806}
807
808fn unwrap_periodic(
815 surface: &SurfaceGeometry,
816 samples: &[(f64, f64)],
817 tol: Tolerances,
818) -> Vec<Point2> {
819 let ((ua, ub), (va, vb)) = surface.domain();
820 let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
827 Some(ub - ua)
828 } else {
829 None
830 };
831 let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
832 Some(vb - va)
833 } else {
834 None
835 };
836 let fold = |previous: f64, next: f64, period: Option<f64>| match period {
837 None => next,
838 Some(period) => {
839 let mut candidate = next;
840 while candidate - previous > period * 0.5 {
841 candidate -= period;
842 }
843 while previous - candidate > period * 0.5 {
844 candidate += period;
845 }
846 candidate
847 }
848 };
849
850 let mut out = Vec::with_capacity(samples.len());
851 let mut at = Point2::new(samples[0].0, samples[0].1);
852 out.push(at);
853 for sample in &samples[1..] {
854 at = Point2::new(
855 fold(at.x, sample.0, u_period),
856 fold(at.y, sample.1, v_period),
857 );
858 out.push(at);
859 }
860 out
861}
862
863#[cfg(test)]
864#[allow(clippy::unwrap_used)]
865mod tests {
866 use super::*;
867 use crate::march::{Marching, branches};
868 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
869 use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
870
871 const T: Tolerances = Tolerances::millimetres();
872
873 fn sphere(radius: f64) -> SurfaceGeometry {
874 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
875 }
876
877 fn cylinder(radius: f64) -> SurfaceGeometry {
878 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
879 .unwrap()
880 .into()
881 }
882
883 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
884 PlaneSurface::over(
885 Plane::through(origin, Direction::new(normal, T).unwrap()),
886 (-6.0, 6.0),
887 (-6.0, 6.0),
888 )
889 .unwrap()
890 .into()
891 }
892
893 fn options() -> Marching {
894 Marching {
895 chord: 1e-5,
896 ..Marching::default()
897 }
898 }
899
900 fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
906 let off = |surface: &SurfaceGeometry, p: Point| match surface {
907 SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
908 SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
909 SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
910 _ => 0.0,
911 };
912 let (lo, hi) = curve.knots().domain();
913 let mut worst = 0.0_f64;
914 for i in 0..=800 {
915 #[allow(clippy::cast_precision_loss)]
916 let u = lo + (hi - lo) * f64::from(i) / 800.0;
917 if let Ok(p) = curve.point_at(u, T) {
918 worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
919 }
920 }
921 worst
922 }
923
924 #[test]
933 fn a_shallow_crossing_states_the_fits_miss_between_its_samples() {
934 use crate::march::Stopped;
935 use ogeom_geom::BSplineSurface;
936 use ogeom_math::{ControlGrid, KnotVector};
937 let (radius, dip, half) = (100.0, 0.02, 3.0);
938 let a = plane(Point::ORIGIN, Vector::Z);
939 let square = [half * half, -half * half, half * half];
942 let side = [-half, 0.0, half];
943 let mut control = Vec::new();
944 for i in 0..3 {
945 for j in 0..3 {
946 let z = (square[i] + square[j]) / (2.0 * radius) - dip;
947 control.push(Point::new(side[i], side[j], z));
948 }
949 }
950 let knots = || KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
951 let b: SurfaceGeometry = BSplineSurface::new(
952 knots(),
953 knots(),
954 &ControlGrid::new(control, 3, 3).unwrap(),
955 T,
956 )
957 .unwrap()
958 .into();
959 let circle = (2.0 * radius * dip).sqrt();
960 let SurfaceGeometry::Plane(flat) = &a else {
961 unreachable!()
962 };
963 let mut branch = Traced {
964 points: Vec::new(),
965 on_a: Vec::new(),
966 on_b: Vec::new(),
967 stopped: Stopped::LeftTheDomain,
968 };
969 for k in 0..=8 {
970 let angle = core::f64::consts::PI * f64::from(k) / 8.0;
971 let p = Point::new(circle * angle.cos(), circle * angle.sin(), 0.0);
972 let local = flat.plane().frame().to_local(p);
973 let on_b = ((p.x + half) / (2.0 * half), (p.y + half) / (2.0 * half));
974 assert!(b.point_at(on_b.0, on_b.1, T).unwrap().distance(p) < 1e-9);
975 branch.points.push(p);
976 branch.on_a.push((local.x, local.y));
977 branch.on_b.push(on_b);
978 }
979 let fitted = approximate_branch(&a, &b, &branch, 1e-9, T).unwrap();
980 let (lo, hi) = fitted.curve.knots().domain();
981 let mut worst = 0.0_f64;
982 for i in 0..=4000 {
983 let p = fitted
984 .curve
985 .point_at(lo + (hi - lo) * f64::from(i) / 4000.0, T)
986 .unwrap();
987 let flat = Point::new(p.x, p.y, 0.0).to_vector().magnitude();
988 worst = worst.max((flat - circle).abs().max(p.z.abs()));
989 }
990 assert!(
991 worst > 1e-6,
992 "the fit misses the circle between samples: {worst:.3e}"
993 );
994 assert!(
995 fitted.fit_error >= 0.9 * worst,
996 "the miss is stated: {:.3e} for {worst:.3e}",
997 fitted.fit_error
998 );
999 }
1000
1001 #[test]
1002 fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
1003 let a = sphere(3.0);
1007 let b = cylinder(1.5);
1008 let found = branches(&a, &b, options(), T).unwrap();
1009 assert_eq!(found.len(), 2);
1010
1011 for branch in &found {
1012 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1013 assert!(fitted.met, "fit error {:e}", fitted.fit_error);
1014 assert!(fitted.closed);
1015 let off = fitted_deviation(&a, &b, &fitted.curve);
1016 assert!(
1017 off <= 1e-4 + 1e-5,
1018 "the fitted curve is {off:e} off the surfaces"
1019 );
1020 assert!(
1022 fitted.curve.control_points().len() * 4 < branch.points.len(),
1023 "{} control points for {} samples",
1024 fitted.curve.control_points().len(),
1025 branch.points.len()
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn the_pcurves_lift_back_onto_the_curve() {
1032 let a = sphere(3.0);
1036 let b = cylinder(1.5);
1037 let found = branches(&a, &b, options(), T).unwrap();
1038 let branch = &found[0];
1039 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1040
1041 for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
1042 let (lo, hi) = pcurve.domain();
1043 for i in 0..=200 {
1044 #[allow(clippy::cast_precision_loss)]
1045 let u = lo + (hi - lo) * f64::from(i) / 200.0;
1046 let at = pcurve.point_at(u, T).unwrap();
1047 let lifted = surface.point_at(at.x, at.y, T).unwrap();
1048 let off = match (surface as &SurfaceGeometry, &a, &b) {
1052 _ if core::ptr::eq(surface, &a) => match &b {
1053 SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
1054 _ => 0.0,
1055 },
1056 _ => match &a {
1057 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
1058 _ => 0.0,
1059 },
1060 };
1061 assert!(
1062 off.abs() < 5e-4,
1063 "a lifted pcurve point is {off:e} off the intersection"
1064 );
1065 }
1066 }
1067 }
1068
1069 #[test]
1070 fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
1071 let a = cylinder(2.0);
1076 let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
1077 let found = branches(&a, &b, options(), T).unwrap();
1078 assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
1079 let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
1080
1081 let (lo, hi) = fitted.on_a.domain();
1084 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1085 for i in 1..=400 {
1086 #[allow(clippy::cast_precision_loss)]
1087 let u = lo + (hi - lo) * f64::from(i) / 400.0;
1088 let at = fitted.on_a.point_at(u, T).unwrap();
1089 assert!(
1090 (at.x - previous.x).abs() < 1.0,
1091 "the pcurve tears at the seam: {} to {}",
1092 previous.x,
1093 at.x
1094 );
1095 previous = at;
1096 }
1097 }
1098
1099 #[test]
1109 fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
1110 let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
1111 assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
1112 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
1113 let found = branches(&drum, &cut, options(), T).unwrap();
1114 assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
1115 assert!(found[0].closed(), "the loop closes on the seam");
1116 let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
1117 assert!(fitted.closed);
1118 assert!(
1119 fitted.fit_error < 1e-3,
1120 "the loop fits as one: {}",
1121 fitted.fit_error
1122 );
1123 let (lo, hi) = fitted.on_a.domain();
1124 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1125 for i in 1..=400 {
1126 let u = lo + (hi - lo) * f64::from(i) / 400.0;
1127 let at = fitted.on_a.point_at(u, T).unwrap();
1128 assert!(
1129 (at.x - previous.x).abs() < 0.5,
1130 "the chart image tears at the seam: {} to {}",
1131 previous.x,
1132 at.x
1133 );
1134 previous = at;
1135 }
1136 }
1137
1138 #[test]
1146 fn a_section_across_a_wide_drum_states_its_measured_error() {
1147 let radius = 23.6;
1148 let wall = CylinderSurface::new(
1149 Cylinder::new(Frame::WORLD, radius, T).unwrap(),
1150 (-30.0, 30.0),
1151 )
1152 .unwrap();
1153 let drum: SurfaceGeometry = SurfaceGeometry::from(wall).to_bspline(T).unwrap().into();
1154 let bore = Cylinder::new(
1155 Frame::new(
1156 Point::new(0.0, 0.0, 3.0),
1157 Direction::new(Vector::X, T).unwrap(),
1158 Direction::new(Vector::Y, T).unwrap(),
1159 T,
1160 )
1161 .unwrap(),
1162 9.0,
1163 T,
1164 )
1165 .unwrap();
1166 let drill: SurfaceGeometry = CylinderSurface::new(bore, (-40.0, 40.0)).unwrap().into();
1167 let marching = Marching {
1168 chord: 1e-4,
1169 ..Marching::default()
1170 };
1171 let found = branches(&drum, &drill, marching, T).unwrap();
1172 assert!(!found.is_empty());
1173 for branch in &found {
1174 let fitted = approximate_branch(&drum, &drill, branch, 1e-4, T).unwrap();
1175 let (lo, hi) = fitted.curve.knots().domain();
1176 let mut off = 0.0_f64;
1177 for i in 0..=2000 {
1178 let t = lo + (hi - lo) * f64::from(i) / 2000.0;
1179 let p = fitted.curve.point_at(t, T).unwrap();
1180 off = off.max(
1181 wall.cylinder()
1182 .distance_to(p)
1183 .abs()
1184 .max(bore.distance_to(p).abs()),
1185 );
1186 }
1187 assert!(
1190 fitted.fit_error + marching.chord >= off,
1191 "states {:e} for a curve {off:e} off its surfaces",
1192 fitted.fit_error
1193 );
1194 assert!(
1195 fitted.fit_error <= 10.0 * off.max(marching.chord),
1196 "states {:e} for a curve {off:e} off its surfaces",
1197 fitted.fit_error
1198 );
1199 }
1200 }
1201
1202 #[test]
1203 fn what_cannot_be_fitted_is_refused() {
1204 let a = sphere(1.0);
1205 let b = plane(Point::ORIGIN, Vector::Z);
1206 let found = branches(&a, &b, options(), T).unwrap();
1207 assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
1208 assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
1209
1210 let empty = Traced {
1211 points: vec![],
1212 on_a: vec![],
1213 on_b: vec![],
1214 stopped: crate::march::Stopped::Stalled,
1215 };
1216 assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
1217 }
1218}