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 held = |closed: bool| {
209 ogeom_geom::fit::fit_points_joint_from_held(
210 &points,
211 &unwrapped_a,
212 &unwrapped_b,
213 &stations,
214 closed,
215 3,
216 tolerance,
217 tol,
218 )
219 };
220 let meets_itself = {
221 let n = points.len();
222 let (pa, pb) = (
223 unwrapped_a[n - 1] - unwrapped_a[0],
224 unwrapped_b[n - 1] - unwrapped_b[0],
225 );
226 let gap = (points[n - 1] - points[0]).square_magnitude()
227 + pa.square_magnitude()
228 + pb.square_magnitude();
229 gap.sqrt() <= tol.confusion()
230 };
231 let mut fitted = seeded(branch.closed() && meets_itself).ok();
232 if branch.closed()
233 && !meets_itself
234 && fitted.as_ref().is_none_or(|f| f.0.error > tolerance)
235 && winds_periodically(a, &unwrapped_a)
236 && winds_periodically(b, &unwrapped_b)
237 && let Ok(winding) = seeded(true)
238 && fitted.as_ref().is_none_or(|f| winding.0.error < f.0.error)
239 {
240 fitted = Some(winding);
241 }
242 let mut tried_free = true;
243 let mut chosen = match fitted {
244 Some(fitted) if fitted.0.error <= tolerance => {
245 tried_free = false;
246 fitted
247 }
248 Some(fitted) => {
249 let other = free()?;
250 if other.0.error < fitted.0.error {
251 other
252 } else {
253 fitted
254 }
255 }
256 None => free()?,
257 };
258
259 let measured =
260 |(space, on_a, on_b): &Joint| measured_error(a, b, (space, on_a, on_b), &points, tol);
261 let mut fit_error = measured(&chosen);
262 if fit_error > tolerance
268 && let Ok(held) = held(branch.closed() && meets_itself)
269 {
270 let again = measured(&held);
271 if again < fit_error {
272 (chosen, fit_error) = (held, again);
273 }
274 }
275 if fit_error > tolerance * 1e2
281 && !tried_free
282 && let Ok(other) = free()
283 {
284 let again = measured(&other);
285 if again < fit_error {
286 (chosen, fit_error) = (other, again);
287 }
288 }
289 if fit_error > tolerance * 1e2
296 && (!crate::march::chart_poles(a, tol).is_empty()
297 || !crate::march::chart_poles(b, tol).is_empty())
298 && let Some(projected) = projected(
299 (a, b),
300 &points,
301 (&unwrapped_a, &unwrapped_b),
302 branch.closed(),
303 tolerance,
304 tol,
305 )
306 {
307 let again = measured(&projected.0).max(projected.1);
308 if again < fit_error {
309 (chosen, fit_error) = (projected.0, again);
310 }
311 }
312 if let (Some(on_a), Some(on_b)) = (
317 held_off_poles(a, &chosen.1, tol),
318 held_off_poles(b, &chosen.2, tol),
319 ) && (on_a != chosen.1 || on_b != chosen.2)
320 {
321 chosen = (chosen.0, on_a, on_b);
322 fit_error = measured(&chosen);
323 }
324 let (space, on_a, on_b) = chosen;
325 Ok(IntersectionCurve {
326 fit_error,
327 met: space.error <= tolerance,
328 curve: space.curve,
329 on_a,
330 on_b,
331 closed: branch.closed(),
332 })
333}
334
335fn projected(
355 (a, b): (&SurfaceGeometry, &SurfaceGeometry),
356 points: &[ogeom_math::Point],
357 (image_a, image_b): (&[Point2], &[Point2]),
358 closed: bool,
359 tolerance: f64,
360 tol: Tolerances,
361) -> Option<(Joint, f64)> {
362 use ogeom_geom::{Curve2d as _, Curve3d as _};
363 const HALVINGS: u32 = f64::MANTISSA_DIGITS;
365 const PEAKS: usize = 16;
367 const CLIMB: usize = 16;
368 let mut along = Vec::with_capacity(points.len());
369 let mut length = 0.0;
370 along.push(length);
371 for pair in points.windows(2) {
372 let step = pair[0].distance(pair[1]);
373 if step <= 0.0 {
374 return None;
375 }
376 length += step;
377 along.push(length);
378 }
379 let space = if closed {
380 ogeom_geom::fit::fit_points_at_closed(&along, points, 3, tolerance, tol)
381 } else {
382 ogeom_geom::fit::fit_points_at(&along, points, 3, tolerance, tol)
383 }
384 .ok()?;
385 let image = |surface: &SurfaceGeometry, seeds: &[Point2]| -> Option<(BSpline2d, f64)> {
386 let ((ua, ub), (va, vb)) = surface.domain();
387 let periods = (
388 (surface.is_periodic_u() || surface.is_closed_u(tol)).then_some(ub - ua),
389 (surface.is_periodic_v() || surface.is_closed_v(tol)).then_some(vb - va),
390 );
391 let fold = |near: f64, x: f64, period: Option<f64>| {
392 period.map_or(x, |period| x - ((x - near) / period).round() * period)
393 };
394 let speed = seeds
395 .iter()
396 .filter_map(|uv| surface.d1_at(uv.x, uv.y, tol).ok())
397 .map(|(du, dv)| du.magnitude() + dv.magnitude())
398 .fold(0.0_f64, f64::max);
399 if !(speed.is_finite() && speed > 0.0) {
400 return None;
401 }
402 let mut read: std::collections::BTreeMap<u64, Point2> = along
405 .iter()
406 .map(|t| t.to_bits())
407 .zip(seeds.iter().copied())
408 .collect();
409 let limit = tolerance.max(space.error);
414 let foot_from = |t: f64, near: (f64, Point2)| -> OgeomResult<Point2> {
415 let mut from = near;
416 let mut to = t;
417 let mut halvings = 0;
418 loop {
419 let p = space.curve.point_at(to, tol)?;
420 let foot =
421 ogeom_geom::project_on_surface_from(surface, p, (from.1.x, from.1.y), tol)?;
422 if foot.distance <= limit {
423 let uv = Point2::new(
424 fold(from.1.x, foot.parameters.0, periods.0),
425 fold(from.1.y, foot.parameters.1, periods.1),
426 );
427 if to == t {
428 return Ok(uv);
429 }
430 from = (to, uv);
431 to = t;
432 continue;
433 }
434 halvings += 1;
435 if halvings > HALVINGS {
436 ogeom_bail!(NotDone, "a point of the curve inverts on no foot near it");
437 }
438 to = f64::midpoint(from.0, to);
439 }
440 };
441 let invert = |t: f64| -> OgeomResult<Point2> {
442 let key = t.to_bits();
443 let before = read
444 .range(..key)
445 .next_back()
446 .map(|(s, uv)| (f64::from_bits(*s), *uv));
447 let after = read
448 .range(key..)
449 .next()
450 .map(|(s, uv)| (f64::from_bits(*s), *uv));
451 let near = match (before, after) {
452 (Some(before), Some(after)) => {
453 if t - before.0 < after.0 - t {
454 before
455 } else {
456 after
457 }
458 }
459 (Some(near), None) | (None, Some(near)) => near,
460 (None, None) => ogeom_bail!(Construction, "no image read to start from"),
461 };
462 let uv = foot_from(t, near)?;
463 read.entry(key).or_insert(uv);
464 Ok(uv)
465 };
466 let fitted =
467 ogeom_geom::fit::fit_curve_2d_sampled(invert, &along, false, 3, tolerance / speed, tol)
468 .ok()?;
469 let gap = |t: f64| -> Option<f64> {
473 let uv = fitted.curve.point_at(t, tol).ok()?;
474 let lifted = surface.point_at(uv.x, uv.y, tol).ok()?;
475 Some(lifted.distance(space.curve.point_at(t, tol).ok()?))
476 };
477 let reads: Vec<f64> = read.keys().map(|&s| f64::from_bits(s)).collect();
478 let mut stations = Vec::with_capacity(2 * reads.len());
479 for pair in reads.windows(2) {
480 stations.push(pair[0]);
481 stations.push(f64::midpoint(pair[0], pair[1]));
482 }
483 stations.extend(reads.last());
484 let misses = stations
485 .iter()
486 .map(|&t| gap(t))
487 .collect::<Option<Vec<f64>>>()?;
488 let mut off = misses.iter().fold(0.0_f64, |m, &x| m.max(x));
489 let last = stations.len() - 1;
490 let mut peaks: Vec<usize> = (0..=last)
491 .filter(|&k| {
492 (k == 0 || misses[k] >= misses[k - 1]) && (k == last || misses[k] >= misses[k + 1])
493 })
494 .collect();
495 peaks.sort_by(|&i, &j| misses[j].total_cmp(&misses[i]));
496 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
497 for k in peaks.into_iter().take(PEAKS) {
498 let (mut left, mut right) =
499 (stations[k.saturating_sub(1)], stations[(k + 1).min(last)]);
500 for _ in 0..CLIMB {
501 let (c, d) = (
502 right - (right - left) * ratio,
503 left + (right - left) * ratio,
504 );
505 let (rc, rd) = (gap(c)?, gap(d)?);
506 off = off.max(rc).max(rd);
507 if rc > rd {
508 right = d;
509 } else {
510 left = c;
511 }
512 }
513 }
514 Some((fitted.curve, off))
515 };
516 let (on_a, off_a) = image(a, image_a)?;
517 let (on_b, off_b) = image(b, image_b)?;
518 Some(((space, on_a, on_b), off_a.max(off_b)))
519}
520
521fn held_off_poles(
525 surface: &SurfaceGeometry,
526 image: &BSpline2d,
527 tol: Tolerances,
528) -> Option<BSpline2d> {
529 let poles = crate::march::chart_poles(surface, tol);
530 if poles.is_empty() {
531 return Some(image.clone());
532 }
533 let ((ua, ub), (va, vb)) = surface.domain();
534 let mut changed = false;
535 let mut control = Vec::with_capacity(image.control_points().len());
536 for held in image.control_points() {
537 let mut point = held.point();
538 for &(k, bound, _) in &poles {
539 let (coordinate, lo, hi) = if k == 0 {
540 (&mut point.x, ua, ub)
541 } else {
542 (&mut point.y, va, vb)
543 };
544 let past = if (bound - hi).abs() <= (bound - lo).abs() {
545 *coordinate > bound
546 } else {
547 *coordinate < bound
548 };
549 if past {
550 *coordinate = bound;
551 changed = true;
552 }
553 }
554 control.push(ogeom_math::Weighted::new(point, held.weight, tol).ok()?);
555 }
556 if !changed {
557 return Some(image.clone());
558 }
559 if image.is_rational() {
560 BSpline2d::rational(image.knots().clone(), control).ok()
561 } else {
562 let points = control
563 .into_iter()
564 .map(ogeom_math::Weighted::point)
565 .collect();
566 BSpline2d::new(image.knots().clone(), points, tol).ok()
567 }
568}
569
570fn measured_error(
577 a: &SurfaceGeometry,
578 b: &SurfaceGeometry,
579 (space, on_a, on_b): (
580 &ogeom_geom::fit::Fitted<BSplineCurve>,
581 &BSpline2d,
582 &BSpline2d,
583 ),
584 points: &[ogeom_math::Point],
585 tol: Tolerances,
586) -> f64 {
587 let lifted = lift_error(a, b, on_a, on_b, &space.curve, tol);
588 let crossing = if lifted.unsettled {
596 let charted =
597 space_error(a, on_a, space.error, tol).max(space_error(b, on_b, space.error, tol));
598 lifted.lifted_off.max(charted.max(space.error))
599 } else {
600 lifted.lifted_off
601 };
602 let lifted = lifted
603 .along
604 .max(lifted.curve_off)
605 .max(lifted.across.min(crossing));
606 if space.error <= lifted {
609 lifted
610 } else {
611 lifted.max(trace_error(&space.curve, points, space.error, tol))
612 }
613}
614
615type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
617
618fn cubic_stations(
624 points: &[ogeom_math::Point],
625 image_a: &[Point2],
626 image_b: &[Point2],
627 tolerance: f64,
628) -> Vec<usize> {
629 let n = points.len();
630 let target = tolerance / 8.0;
631 let traces: [Vec<[f64; 3]>; 3] = [
632 points.iter().map(|p| [p.x, p.y, p.z]).collect(),
633 image_a.iter().map(|p| [p.x, p.y, 0.0]).collect(),
634 image_b.iter().map(|p| [p.x, p.y, 0.0]).collect(),
635 ];
636 let shape: Vec<(Vec<f64>, Vec<f64>)> = traces
639 .iter()
640 .map(|trace| {
641 let segment =
642 |k: usize| -> [f64; 3] { core::array::from_fn(|d| trace[k + 1][d] - trace[k][d]) };
643 let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
644 let lengths: Vec<f64> = (0..n - 1)
645 .map(|k| dot(segment(k), segment(k)).sqrt())
646 .collect();
647 let mut turns = vec![0.0; n];
648 for k in 1..n - 1 {
649 let scale = lengths[k - 1] * lengths[k];
650 if scale > 0.0 {
651 turns[k] = (dot(segment(k - 1), segment(k)) / scale)
652 .clamp(-1.0, 1.0)
653 .acos();
654 }
655 }
656 (lengths, turns)
657 })
658 .collect();
659 let mut out = vec![0];
660 let mut from = 0;
661 while from + 1 < n {
662 let mut sums: Vec<(f64, f64)> = shape
663 .iter()
664 .map(|(lengths, _)| (lengths[from], 0.0))
665 .collect();
666 let mut to = from + 1;
667 while to + 1 < n {
668 let grown: Vec<(f64, f64)> = sums
669 .iter()
670 .zip(&shape)
671 .map(|(&(length, turn), (lengths, turns))| (length + lengths[to], turn + turns[to]))
672 .collect();
673 if grown
674 .iter()
675 .any(|&(length, turn)| length * turn.powi(3) / 384.0 > target)
676 {
677 break;
678 }
679 sums = grown;
680 to += 1;
681 }
682 out.push(to);
683 from = to;
684 }
685 out
686}
687
688struct Lifted {
690 along: f64,
693 across: f64,
696 curve_off: f64,
699 lifted_off: f64,
701 unsettled: bool,
703}
704
705fn lift_error(
719 a: &SurfaceGeometry,
720 b: &SurfaceGeometry,
721 on_a: &BSpline2d,
722 on_b: &BSpline2d,
723 curve: &BSplineCurve,
724 tol: Tolerances,
725) -> Lifted {
726 const NEAR_PEAK: f64 = 0.8;
730 const PEAKS: usize = 16;
731 const CLIMB: usize = 16;
732 let (lo, hi) = curve.knots().domain();
733 let spans = [curve.knots(), on_a.knots(), on_b.knots()]
734 .into_iter()
735 .map(|knots| knots.distinct().len().saturating_sub(1))
736 .max()
737 .unwrap_or(0)
738 .max(1);
739 let stations = (4 * spans).max(200);
740 #[allow(clippy::cast_precision_loss)]
741 let step = (hi - lo) / stations as f64;
742 let mut out = Lifted {
743 along: 0.0,
744 across: 0.0,
745 curve_off: 0.0,
746 lifted_off: 0.0,
747 unsettled: false,
748 };
749 #[allow(clippy::cast_precision_loss)]
750 let at = |k: usize| lo + (hi - lo) * k as f64 / stations as f64;
751 let read = |t: f64| station(a, b, on_a, on_b, curve, t, step, tol);
752 let mut misses = Vec::with_capacity(stations + 1);
754 for k in 0..=stations {
755 let Some(here) = read(at(k)) else {
756 misses.push(0.0);
757 continue;
758 };
759 out.along = out.along.max(here.gap);
760 out.across = out.across.max(here.across);
761 if let Some((curve_off, lifted_off)) = here.off {
762 out.curve_off = out.curve_off.max(curve_off);
763 out.lifted_off = out.lifted_off.max(lifted_off);
764 misses.push(curve_off.max(lifted_off));
765 } else {
766 out.unsettled = true;
767 misses.push(0.0);
768 }
769 }
770 let widest = misses.iter().fold(0.0_f64, |m, &x| m.max(x));
771 let mut peaks: Vec<usize> = (0..=stations)
772 .filter(|&k| {
773 misses[k] > 0.0
774 && misses[k] >= widest * NEAR_PEAK
775 && (k == 0 || misses[k] >= misses[k - 1])
776 && (k == stations || misses[k] >= misses[k + 1])
777 })
778 .collect();
779 peaks.sort_by(|&i, &j| misses[j].total_cmp(&misses[i]));
780 let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
781 let mut climb = |t: f64| {
782 read(t)
783 .and_then(|s| s.off)
784 .map_or(0.0, |(curve_off, lifted_off)| {
785 out.curve_off = out.curve_off.max(curve_off);
786 out.lifted_off = out.lifted_off.max(lifted_off);
787 curve_off.max(lifted_off)
788 })
789 };
790 for k in peaks.into_iter().take(PEAKS) {
791 let (mut left, mut right) = (at(k.saturating_sub(1)), at((k + 1).min(stations)));
792 let mut c = right - (right - left) * ratio;
793 let mut d = left + (right - left) * ratio;
794 let (mut rc, mut rd) = (climb(c), climb(d));
795 for _ in 0..CLIMB {
796 if rc > rd {
797 right = d;
798 (d, rd) = (c, rc);
799 c = right - (right - left) * ratio;
800 rc = climb(c);
801 } else {
802 left = c;
803 (c, rc) = (d, rd);
804 d = left + (right - left) * ratio;
805 rd = climb(d);
806 }
807 }
808 }
809 out
810}
811
812struct Station {
814 gap: f64,
816 across: f64,
819 off: Option<(f64, f64)>,
822}
823
824type Jet = (
826 Point2,
827 ogeom_math::Point,
828 ogeom_math::Vector,
829 ogeom_math::Vector,
830);
831
832#[allow(clippy::too_many_arguments)]
838fn station(
839 a: &SurfaceGeometry,
840 b: &SurfaceGeometry,
841 on_a: &BSpline2d,
842 on_b: &BSpline2d,
843 curve: &BSplineCurve,
844 t: f64,
845 step: f64,
846 tol: Tolerances,
847) -> Option<Station> {
848 use ogeom_geom::{Curve2d as _, Curve3d as _};
849 let on = curve.point_at(t, tol).ok()?;
850 let mut gap = 0.0_f64;
851 let mut normals = [None; 2];
852 let mut jets: [Option<Jet>; 2] = [None; 2];
853 for (k, (surface, pcurve)) in [(a, on_a), (b, on_b)].into_iter().enumerate() {
854 let Ok(chart) = pcurve.point_at(t, tol) else {
855 continue;
856 };
857 let Ok((lifted, du, dv)) = surface.point_d1_at(chart.x, chart.y, tol) else {
858 continue;
859 };
860 gap = gap.max(lifted.distance(on));
861 jets[k] = Some((chart, lifted, du, dv));
862 let scale = du.magnitude().max(dv.magnitude());
865 let cross = du.cross(dv);
866 let length = cross.magnitude();
867 if length > tol.angular() * scale * scale {
868 normals[k] = Some(cross * (1.0 / length));
869 }
870 }
871 let across = match normals {
872 [Some(na), Some(nb)] => gap / na.cross(nb).magnitude().max(tol.angular()),
873 _ => 0.0,
874 };
875 let off = match (jets, curve.d1_at(t, tol)) {
876 ([Some(ja), Some(jb)], Ok(tangent)) => {
877 let reach = tangent.magnitude() * step;
878 crossing(a, b, ja, jb, on, tangent, reach, tol)
879 .filter(|found| found.distance(on) <= reach)
880 .map(|found| {
881 (
882 on.distance(found),
883 ja.1.distance(found).max(jb.1.distance(found)),
884 )
885 })
886 }
887 _ => None,
888 };
889 Some(Station { gap, across, off })
890}
891
892#[allow(clippy::too_many_arguments)]
900fn crossing(
901 a: &SurfaceGeometry,
902 b: &SurfaceGeometry,
903 on_a: Jet,
904 on_b: Jet,
905 anchor: ogeom_math::Point,
906 along: ogeom_math::Vector,
907 reach: f64,
908 tol: Tolerances,
909) -> Option<ogeom_math::Point> {
910 const MOST: usize = 8;
911 const HALVINGS: usize = 8;
912 let settled = tol.confusion() * 0.01;
913 let residual_of = |pa: ogeom_math::Point, pb: ogeom_math::Point| {
914 let gap = pa - pb;
915 [gap.x, gap.y, gap.z, (pa - anchor).dot(along)]
916 };
917 let jacobian_of = |(au, av): (ogeom_math::Vector, ogeom_math::Vector),
918 (bu, bv): (ogeom_math::Vector, ogeom_math::Vector)| {
919 [
920 [au.x, av.x, -bu.x, -bv.x],
921 [au.y, av.y, -bu.y, -bv.y],
922 [au.z, av.z, -bu.z, -bv.z],
923 [au.dot(along), av.dot(along), 0.0, 0.0],
924 ]
925 };
926 let norm = |r: &[f64; 4]| r.iter().map(|v| v * v).sum::<f64>().sqrt();
927 let mut x = [on_a.0.x, on_a.0.y, on_b.0.x, on_b.0.y];
928 let mut point = on_a.1;
929 let mut residual = residual_of(on_a.1, on_b.1);
930 let mut jacobian = jacobian_of((on_a.2, on_a.3), (on_b.2, on_b.3));
931 let mut size = norm(&residual);
932 for _ in 0..MOST {
933 if size <= settled {
934 break;
935 }
936 let delta = solve4(jacobian, residual)?;
937 let carried = (0..3)
941 .map(|row| {
942 let on_a = jacobian[row][0] * delta[0] + jacobian[row][1] * delta[1];
943 let on_b = jacobian[row][2] * delta[2] + jacobian[row][3] * delta[3];
944 (on_a * on_a, on_b * on_b)
945 })
946 .fold((0.0, 0.0), |(sa, sb), (a, b)| (sa + a, sb + b));
947 let carried = carried.0.max(carried.1).sqrt();
948 let mut scale = 1.0;
949 while scale * carried > reach {
950 scale *= 0.5;
951 }
952 let mut accepted = None;
953 for _ in 0..HALVINGS {
954 let trial: [f64; 4] = core::array::from_fn(|i| x[i] - delta[i] * scale);
955 let (ua, va) = crate::march::clamp(a, trial[0], trial[1]);
956 let (ub, vb) = crate::march::clamp(b, trial[2], trial[3]);
957 if let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) {
958 let r = residual_of(pa, pb);
959 let trial_size = norm(&r);
960 if (trial_size < size || trial_size <= settled)
961 && let (Ok(da), Ok(db)) = (a.d1_at(ua, va, tol), b.d1_at(ub, vb, tol))
962 {
963 accepted = Some((trial, pa, r, jacobian_of(da, db), trial_size));
964 break;
965 }
966 }
967 scale *= 0.5;
968 }
969 let Some((next, at, r, j, next_size)) = accepted else {
970 break;
971 };
972 let step = norm(&core::array::from_fn(|i| next[i] - x[i]));
973 (x, point, residual, jacobian, size) = (next, at, r, j, next_size);
974 if step <= tol.parametric() {
975 break;
976 }
977 }
978 (size <= tol.confusion()).then_some(point)
979}
980
981fn solve4(mut m: [[f64; 4]; 4], mut r: [f64; 4]) -> Option<[f64; 4]> {
984 for col in 0..4 {
985 let pivot = (col..4).max_by(|&i, &j| m[i][col].abs().total_cmp(&m[j][col].abs()))?;
986 if m[pivot][col] == 0.0 {
987 return None;
988 }
989 m.swap(col, pivot);
990 r.swap(col, pivot);
991 let head = m[col];
992 for row in col + 1..4 {
993 let factor = m[row][col] / head[col];
994 for (entry, above) in m[row].iter_mut().zip(&head).skip(col) {
995 *entry -= factor * above;
996 }
997 r[row] -= factor * r[col];
998 }
999 }
1000 for col in (0..4).rev() {
1001 r[col] /= m[col][col];
1002 for row in 0..col {
1003 r[row] -= m[row][col] * r[col];
1004 }
1005 }
1006 Some(r)
1007}
1008
1009fn trace_error(
1019 curve: &BSplineCurve,
1020 samples: &[ogeom_math::Point],
1021 bound: f64,
1022 tol: Tolerances,
1023) -> f64 {
1024 use ogeom_geom::Curve3d as _;
1025 let (lo, hi) = curve.knots().domain();
1026 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
1027 let count = (4 * spans).max(2 * samples.len()).max(200);
1028 #[allow(clippy::cast_precision_loss)]
1029 let at = |k: usize| lo + (hi - lo) * k as f64 / count as f64;
1030 let mut stations: Option<Vec<(f64, ogeom_math::Point)>> = None;
1031 let foot = |p: ogeom_math::Point, mut t: f64| -> (f64, f64) {
1032 let mut best = (t, f64::INFINITY);
1033 for _ in 0..8 {
1034 let (Ok(q), Ok(d)) = (curve.point_at(t, tol), curve.d1_at(t, tol)) else {
1035 break;
1036 };
1037 let gap = q.distance(p);
1038 if gap < best.1 {
1039 best = (t, gap);
1040 }
1041 let speed = d.dot(d);
1042 if speed <= f64::MIN_POSITIVE {
1043 break;
1044 }
1045 let next = (t + (p - q).dot(d) / speed).clamp(lo, hi);
1046 if (next - t).abs() <= (hi - lo) * 1e-12 {
1047 break;
1048 }
1049 t = next;
1050 }
1051 if let Ok(q) = curve.point_at(t, tol)
1052 && q.distance(p) < best.1
1053 {
1054 best = (t, q.distance(p));
1055 }
1056 best
1057 };
1058 let mut t = lo;
1059 let mut worst = 0.0_f64;
1060 for p in samples {
1061 let mut found = foot(*p, t);
1062 if found.1 > bound {
1063 let stations = stations.get_or_insert_with(|| {
1064 (0..=count)
1065 .filter_map(|k| curve.point_at(at(k), tol).ok().map(|q| (at(k), q)))
1066 .collect()
1067 });
1068 if let Some(k) = (0..stations.len()).min_by(|&x, &y| {
1069 stations[x]
1070 .1
1071 .distance(*p)
1072 .total_cmp(&stations[y].1.distance(*p))
1073 }) {
1074 let gap = |u: f64| {
1080 curve
1081 .point_at(u, tol)
1082 .map_or(f64::INFINITY, |q| q.distance(*p))
1083 };
1084 let (from, to) = (
1085 stations[k.saturating_sub(2)].0,
1086 stations[(k + 2).min(stations.len() - 1)].0,
1087 );
1088 const FINE: u32 = 256;
1089 let h = (to - from) / f64::from(FINE);
1090 let start = (0..=FINE)
1091 .map(|j| from + h * f64::from(j))
1092 .min_by(|&x, &y| gap(x).total_cmp(&gap(y)))
1093 .unwrap_or(from);
1094 let (mut a, mut b) = ((start - h).max(lo), (start + h).min(hi));
1095 let ratio = 0.5 * (5.0_f64.sqrt() - 1.0);
1096 for _ in 0..60 {
1097 let (x, y) = (b - ratio * (b - a), a + ratio * (b - a));
1098 if gap(x) <= gap(y) {
1099 b = y;
1100 } else {
1101 a = x;
1102 }
1103 }
1104 let again = foot(*p, 0.5 * (a + b));
1105 if again.1 < found.1 {
1106 found = again;
1107 }
1108 }
1109 }
1110 t = found.0;
1111 worst = worst.max(found.1.min(bound));
1112 }
1113 worst
1114}
1115
1116fn space_error(
1121 surface: &SurfaceGeometry,
1122 pcurve: &BSpline2d,
1123 parameter_error: f64,
1124 tol: Tolerances,
1125) -> f64 {
1126 use ogeom_geom::Curve2d;
1127 let (lo, hi) = pcurve.domain();
1130 let mut worst = 0.0_f64;
1131 for i in 0..=16 {
1132 #[allow(clippy::cast_precision_loss)]
1133 let u = lo + (hi - lo) * f64::from(i) / 16.0;
1134 let Ok(at) = pcurve.point_at(u, tol) else {
1135 continue;
1136 };
1137 let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
1138 continue;
1139 };
1140 let stretch = du.magnitude().max(dv.magnitude());
1141 worst = worst.max(parameter_error * stretch);
1142 }
1143 worst
1144}
1145
1146fn unwrap_periodic(
1153 surface: &SurfaceGeometry,
1154 samples: &[(f64, f64)],
1155 tol: Tolerances,
1156) -> Vec<Point2> {
1157 let ((ua, ub), (va, vb)) = surface.domain();
1158 let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
1165 Some(ub - ua)
1166 } else {
1167 None
1168 };
1169 let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
1170 Some(vb - va)
1171 } else {
1172 None
1173 };
1174 let fold = |previous: f64, next: f64, period: Option<f64>| match period {
1175 None => next,
1176 Some(period) => {
1177 let mut candidate = next;
1178 while candidate - previous > period * 0.5 {
1179 candidate -= period;
1180 }
1181 while previous - candidate > period * 0.5 {
1182 candidate += period;
1183 }
1184 candidate
1185 }
1186 };
1187
1188 let mut out = Vec::with_capacity(samples.len());
1189 let mut at = Point2::new(samples[0].0, samples[0].1);
1190 out.push(at);
1191 for sample in &samples[1..] {
1192 at = Point2::new(
1193 fold(at.x, sample.0, u_period),
1194 fold(at.y, sample.1, v_period),
1195 );
1196 out.push(at);
1197 }
1198 out
1199}
1200
1201#[cfg(test)]
1202#[allow(clippy::unwrap_used)]
1203mod tests {
1204 use super::*;
1205 use crate::march::{Marching, branches};
1206 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
1207 use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
1208
1209 const T: Tolerances = Tolerances::millimetres();
1210
1211 fn sphere(radius: f64) -> SurfaceGeometry {
1212 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
1213 }
1214
1215 fn cylinder(radius: f64) -> SurfaceGeometry {
1216 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
1217 .unwrap()
1218 .into()
1219 }
1220
1221 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
1222 PlaneSurface::over(
1223 Plane::through(origin, Direction::new(normal, T).unwrap()),
1224 (-6.0, 6.0),
1225 (-6.0, 6.0),
1226 )
1227 .unwrap()
1228 .into()
1229 }
1230
1231 fn options() -> Marching {
1232 Marching {
1233 chord: 1e-5,
1234 ..Marching::default()
1235 }
1236 }
1237
1238 fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
1244 let off = |surface: &SurfaceGeometry, p: Point| match surface {
1245 SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
1246 SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
1247 SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
1248 _ => 0.0,
1249 };
1250 let (lo, hi) = curve.knots().domain();
1251 let mut worst = 0.0_f64;
1252 for i in 0..=800 {
1253 #[allow(clippy::cast_precision_loss)]
1254 let u = lo + (hi - lo) * f64::from(i) / 800.0;
1255 if let Ok(p) = curve.point_at(u, T) {
1256 worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
1257 }
1258 }
1259 worst
1260 }
1261
1262 #[test]
1271 fn a_shallow_crossing_states_the_fits_miss_between_its_samples() {
1272 use crate::march::Stopped;
1273 use ogeom_geom::BSplineSurface;
1274 use ogeom_math::{ControlGrid, KnotVector};
1275 let (radius, dip, half) = (100.0, 0.02, 3.0);
1276 let a = plane(Point::ORIGIN, Vector::Z);
1277 let square = [half * half, -half * half, half * half];
1280 let side = [-half, 0.0, half];
1281 let mut control = Vec::new();
1282 for i in 0..3 {
1283 for j in 0..3 {
1284 let z = (square[i] + square[j]) / (2.0 * radius) - dip;
1285 control.push(Point::new(side[i], side[j], z));
1286 }
1287 }
1288 let knots = || KnotVector::new(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], 2).unwrap();
1289 let b: SurfaceGeometry = BSplineSurface::new(
1290 knots(),
1291 knots(),
1292 &ControlGrid::new(control, 3, 3).unwrap(),
1293 T,
1294 )
1295 .unwrap()
1296 .into();
1297 let circle = (2.0 * radius * dip).sqrt();
1298 let SurfaceGeometry::Plane(flat) = &a else {
1299 unreachable!()
1300 };
1301 let mut branch = Traced {
1302 points: Vec::new(),
1303 on_a: Vec::new(),
1304 on_b: Vec::new(),
1305 stopped: Stopped::LeftTheDomain,
1306 };
1307 for k in 0..=8 {
1308 let angle = core::f64::consts::PI * f64::from(k) / 8.0;
1309 let p = Point::new(circle * angle.cos(), circle * angle.sin(), 0.0);
1310 let local = flat.plane().frame().to_local(p);
1311 let on_b = ((p.x + half) / (2.0 * half), (p.y + half) / (2.0 * half));
1312 assert!(b.point_at(on_b.0, on_b.1, T).unwrap().distance(p) < 1e-9);
1313 branch.points.push(p);
1314 branch.on_a.push((local.x, local.y));
1315 branch.on_b.push(on_b);
1316 }
1317 let fitted = approximate_branch(&a, &b, &branch, 1e-9, T).unwrap();
1318 let (lo, hi) = fitted.curve.knots().domain();
1319 let mut worst = 0.0_f64;
1320 for i in 0..=4000 {
1321 let p = fitted
1322 .curve
1323 .point_at(lo + (hi - lo) * f64::from(i) / 4000.0, T)
1324 .unwrap();
1325 let flat = Point::new(p.x, p.y, 0.0).to_vector().magnitude();
1326 worst = worst.max((flat - circle).abs().max(p.z.abs()));
1327 }
1328 assert!(
1329 worst > 1e-6,
1330 "the fit misses the circle between samples: {worst:.3e}"
1331 );
1332 assert!(
1333 fitted.fit_error >= 0.9 * worst,
1334 "the miss is stated: {:.3e} for {worst:.3e}",
1335 fitted.fit_error
1336 );
1337 }
1338
1339 #[test]
1340 fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
1341 let a = sphere(3.0);
1345 let b = cylinder(1.5);
1346 let found = branches(&a, &b, options(), T).unwrap();
1347 assert_eq!(found.len(), 2);
1348
1349 for branch in &found {
1350 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1351 assert!(fitted.met, "fit error {:e}", fitted.fit_error);
1352 assert!(fitted.closed);
1353 let off = fitted_deviation(&a, &b, &fitted.curve);
1354 assert!(
1355 off <= 1e-4 + 1e-5,
1356 "the fitted curve is {off:e} off the surfaces"
1357 );
1358 assert!(
1360 fitted.curve.control_points().len() * 4 < branch.points.len(),
1361 "{} control points for {} samples",
1362 fitted.curve.control_points().len(),
1363 branch.points.len()
1364 );
1365 }
1366 }
1367
1368 #[test]
1369 fn the_pcurves_lift_back_onto_the_curve() {
1370 let a = sphere(3.0);
1374 let b = cylinder(1.5);
1375 let found = branches(&a, &b, options(), T).unwrap();
1376 let branch = &found[0];
1377 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
1378
1379 for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
1380 let (lo, hi) = pcurve.domain();
1381 for i in 0..=200 {
1382 #[allow(clippy::cast_precision_loss)]
1383 let u = lo + (hi - lo) * f64::from(i) / 200.0;
1384 let at = pcurve.point_at(u, T).unwrap();
1385 let lifted = surface.point_at(at.x, at.y, T).unwrap();
1386 let off = match (surface as &SurfaceGeometry, &a, &b) {
1390 _ if core::ptr::eq(surface, &a) => match &b {
1391 SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
1392 _ => 0.0,
1393 },
1394 _ => match &a {
1395 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
1396 _ => 0.0,
1397 },
1398 };
1399 assert!(
1400 off.abs() < 5e-4,
1401 "a lifted pcurve point is {off:e} off the intersection"
1402 );
1403 }
1404 }
1405 }
1406
1407 #[test]
1408 fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
1409 let a = cylinder(2.0);
1414 let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
1415 let found = branches(&a, &b, options(), T).unwrap();
1416 assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
1417 let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
1418
1419 let (lo, hi) = fitted.on_a.domain();
1422 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1423 for i in 1..=400 {
1424 #[allow(clippy::cast_precision_loss)]
1425 let u = lo + (hi - lo) * f64::from(i) / 400.0;
1426 let at = fitted.on_a.point_at(u, T).unwrap();
1427 assert!(
1428 (at.x - previous.x).abs() < 1.0,
1429 "the pcurve tears at the seam: {} to {}",
1430 previous.x,
1431 at.x
1432 );
1433 previous = at;
1434 }
1435 }
1436
1437 #[test]
1447 fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
1448 let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
1449 assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
1450 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
1451 let found = branches(&drum, &cut, options(), T).unwrap();
1452 assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
1453 assert!(found[0].closed(), "the loop closes on the seam");
1454 let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
1455 assert!(fitted.closed);
1456 assert!(
1457 fitted.fit_error < 1e-3,
1458 "the loop fits as one: {}",
1459 fitted.fit_error
1460 );
1461 let (lo, hi) = fitted.on_a.domain();
1462 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
1463 for i in 1..=400 {
1464 let u = lo + (hi - lo) * f64::from(i) / 400.0;
1465 let at = fitted.on_a.point_at(u, T).unwrap();
1466 assert!(
1467 (at.x - previous.x).abs() < 0.5,
1468 "the chart image tears at the seam: {} to {}",
1469 previous.x,
1470 at.x
1471 );
1472 previous = at;
1473 }
1474 }
1475
1476 #[test]
1484 fn a_section_across_a_wide_drum_states_its_measured_error() {
1485 let radius = 23.6;
1486 let wall = CylinderSurface::new(
1487 Cylinder::new(Frame::WORLD, radius, T).unwrap(),
1488 (-30.0, 30.0),
1489 )
1490 .unwrap();
1491 let drum: SurfaceGeometry = SurfaceGeometry::from(wall).to_bspline(T).unwrap().into();
1492 let bore = Cylinder::new(
1493 Frame::new(
1494 Point::new(0.0, 0.0, 3.0),
1495 Direction::new(Vector::X, T).unwrap(),
1496 Direction::new(Vector::Y, T).unwrap(),
1497 T,
1498 )
1499 .unwrap(),
1500 9.0,
1501 T,
1502 )
1503 .unwrap();
1504 let drill: SurfaceGeometry = CylinderSurface::new(bore, (-40.0, 40.0)).unwrap().into();
1505 let marching = Marching {
1506 chord: 1e-4,
1507 ..Marching::default()
1508 };
1509 let found = branches(&drum, &drill, marching, T).unwrap();
1510 assert!(!found.is_empty());
1511 for branch in &found {
1512 let fitted = approximate_branch(&drum, &drill, branch, 1e-4, T).unwrap();
1513 let (lo, hi) = fitted.curve.knots().domain();
1514 let mut off = 0.0_f64;
1515 for i in 0..=2000 {
1516 let t = lo + (hi - lo) * f64::from(i) / 2000.0;
1517 let p = fitted.curve.point_at(t, T).unwrap();
1518 off = off.max(
1519 wall.cylinder()
1520 .distance_to(p)
1521 .abs()
1522 .max(bore.distance_to(p).abs()),
1523 );
1524 }
1525 assert!(
1528 fitted.fit_error + marching.chord >= off,
1529 "states {:e} for a curve {off:e} off its surfaces",
1530 fitted.fit_error
1531 );
1532 assert!(
1533 fitted.fit_error <= 10.0 * off.max(marching.chord),
1534 "states {:e} for a curve {off:e} off its surfaces",
1535 fitted.fit_error
1536 );
1537 }
1538 }
1539
1540 #[test]
1541 fn what_cannot_be_fitted_is_refused() {
1542 let a = sphere(1.0);
1543 let b = plane(Point::ORIGIN, Vector::Z);
1544 let found = branches(&a, &b, options(), T).unwrap();
1545 assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
1546 assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
1547
1548 let empty = Traced {
1549 points: vec![],
1550 on_a: vec![],
1551 on_b: vec![],
1552 stopped: crate::march::Stopped::Stalled,
1553 };
1554 assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
1555 }
1556
1557 #[test]
1566 fn loops_through_a_balls_poles_are_cut_there() {
1567 use core::f64::consts::FRAC_PI_2;
1568 let ball = sphere(10.0);
1569 for (cx, cy, r) in [(0.0, 1.0, 1.0), (0.2, 0.0, 0.2), (0.0, -0.3, 0.3)] {
1570 let frame =
1571 Frame::new(Point::new(cx, cy, -15.0), Direction::Z, Direction::Y, T).unwrap();
1572 let drum: SurfaceGeometry =
1573 CylinderSurface::new(Cylinder::new(frame, r, T).unwrap(), (0.0, 30.0))
1574 .unwrap()
1575 .into();
1576 let traced = branches(&ball, &drum, options(), T).unwrap();
1577 assert_eq!(traced.len(), 2, "drum {r} at ({cx}, {cy}): two loops");
1578 for branch in &traced {
1579 assert!(
1580 !branch.closed(),
1581 "drum {r} at ({cx}, {cy}): cut at its pole"
1582 );
1583 let fitted = approximate_branch(&ball, &drum, branch, 1e-6, T).unwrap();
1584 let (lo, hi) = fitted.curve.knots().domain();
1585 for t in [lo, hi] {
1586 let p = fitted.curve.point_at(t, T).unwrap();
1587 assert!(
1588 (p.z.abs() - 10.0).abs() <= 1e-9,
1589 "an end {p:?} off the pole"
1590 );
1591 let uv = fitted.on_a.point_at(t, T).unwrap();
1592 assert!(
1593 (uv.y.abs() - FRAC_PI_2).abs() <= 1e-12,
1594 "an image end off the row"
1595 );
1596 }
1597 let budget = options().chord + fitted.fit_error;
1598 assert!(budget < 1e-4, "drum {r} at ({cx}, {cy}): stated {budget}");
1599 for k in 0..=2000 {
1600 let t = lo + (hi - lo) * f64::from(k) / 2000.0;
1601 let p = fitted.curve.point_at(t, T).unwrap();
1602 let on_ball = (p - Point::ORIGIN).magnitude() - 10.0;
1603 let on_drum = (p.x - cx).hypot(p.y - cy) - r;
1604 assert!(
1605 on_ball.abs().max(on_drum.abs()) <= budget,
1606 "drum {r} at ({cx}, {cy}): {p:?} off the surfaces"
1607 );
1608 let uv = fitted.on_a.point_at(t, T).unwrap();
1609 assert!(uv.y.abs() <= FRAC_PI_2, "the image leaves the chart");
1610 let lifted = ball.point_at(uv.x, uv.y, T).unwrap();
1611 assert!(
1612 lifted.distance(p) <= budget,
1613 "the image stands off the curve"
1614 );
1615 }
1616 }
1617 }
1618 }
1619
1620 #[test]
1627 fn loops_beside_a_balls_poles_fit_within_their_tolerance() {
1628 let ball = sphere(10.0);
1629 for (r, off) in [(1.0, 1e-3), (1.0, 1e-4), (2.5, 1e-3), (4.0, 1e-2)] {
1630 let wall = r * (1.0 + off);
1631 let frame =
1632 Frame::new(Point::new(0.0, -wall, -15.0), Direction::Z, Direction::Y, T).unwrap();
1633 let drum: SurfaceGeometry =
1634 CylinderSurface::new(Cylinder::new(frame, r, T).unwrap(), (0.0, 30.0))
1635 .unwrap()
1636 .into();
1637 let marching = Marching::default();
1638 let traced = branches(&ball, &drum, marching, T).unwrap();
1639 assert_eq!(traced.len(), 2, "drum {r} {off} off: two loops");
1640 for branch in &traced {
1641 let fitted = approximate_branch(&ball, &drum, branch, 1e-6, T).unwrap();
1642 assert!(
1643 fitted.fit_error <= 2e-5,
1644 "drum {r} {off} off: fit misses by {}",
1645 fitted.fit_error
1646 );
1647 let (lo, hi) = fitted.curve.knots().domain();
1648 for k in 0..=20_000 {
1649 let t = lo + (hi - lo) * f64::from(k) / 20_000.0;
1650 let p = fitted.curve.point_at(t, T).unwrap();
1651 let on_ball = (p - Point::ORIGIN).magnitude() - 10.0;
1652 let on_drum = p.x.hypot(p.y + wall) - r;
1653 assert!(
1654 on_ball.abs().max(on_drum.abs()) <= marching.chord + fitted.fit_error,
1655 "drum {r} {off} off: {p:?} off the surfaces"
1656 );
1657 for (surface, image) in [(&ball, &fitted.on_a), (&drum, &fitted.on_b)] {
1658 let uv = image.point_at(t, T).unwrap();
1659 let lifted = surface.point_at(uv.x, uv.y, T).unwrap();
1660 assert!(
1661 lifted.distance(p) <= fitted.fit_error,
1662 "drum {r} {off} off: an image {} off the curve, stating {}",
1663 lifted.distance(p),
1664 fitted.fit_error
1665 );
1666 }
1667 }
1668 }
1669 }
1670 }
1671}