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,
57 pub met: bool,
59 pub closed: bool,
61}
62
63pub fn approximate_branch(
70 a: &SurfaceGeometry,
71 b: &SurfaceGeometry,
72 branch: &Traced,
73 tolerance: f64,
74 tol: Tolerances,
75) -> OgeomResult<IntersectionCurve> {
76 if branch.points.len() < 2 {
77 ogeom_bail!(
78 Construction,
79 "a branch of {} points is not a curve",
80 branch.points.len()
81 );
82 }
83
84 let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
90 let mut kept_a = Vec::with_capacity(branch.on_a.len());
91 let mut kept_b = Vec::with_capacity(branch.on_b.len());
92 let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
98 let limit = tolerance.max(tol.confusion());
99 let (ua, va) = branch.on_a[i];
100 let (ub, vb) = branch.on_b[i];
101 a.point_at(ua, va, tol)
102 .is_ok_and(|q| q.distance(*p) <= limit)
103 && b.point_at(ub, vb, tol)
104 .is_ok_and(|q| q.distance(*p) <= limit)
105 };
106 for (i, p) in branch.points.iter().enumerate() {
107 let end = i == 0 || i + 1 == branch.points.len();
108 if let Some(last) = points.last()
109 && last.distance(*p) <= tol.confusion() * 10.0
110 && i + 1 != branch.points.len()
111 {
112 continue;
113 }
114 if !end && !agrees(i, p) {
115 continue;
116 }
117 points.push(*p);
118 kept_a.push(branch.on_a[i]);
119 kept_b.push(branch.on_b[i]);
120 }
121 if points.len() < 2 {
122 ogeom_bail!(Construction, "a branch of coincident points is not a curve");
123 }
124
125 let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
133 let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
134 let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
145 let (first, last) = (image[0], image[image.len() - 1]);
146 ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
147 && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
148 };
149 let free = || -> OgeomResult<Joint> {
150 if branch.closed() {
151 let closed = ogeom_geom::fit::fit_points_joint_closed(
152 &points,
153 &unwrapped_a,
154 &unwrapped_b,
155 3,
156 tolerance,
157 tol,
158 )?;
159 if !closed.0.met
160 && winds_periodically(a, &unwrapped_a)
161 && winds_periodically(b, &unwrapped_b)
162 {
163 let winding = ogeom_geom::fit::fit_points_joint_winding(
164 &points,
165 &unwrapped_a,
166 &unwrapped_b,
167 3,
168 tolerance,
169 tol,
170 )?;
171 if winding.0.error < closed.0.error {
172 return Ok(winding);
173 }
174 }
175 Ok(closed)
176 } else {
177 ogeom_geom::fit::fit_points_joint(
178 &points,
179 &unwrapped_a,
180 &unwrapped_b,
181 3,
182 tolerance,
183 tol,
184 )
185 }
186 };
187 let stations = cubic_stations(&points, &unwrapped_a, &unwrapped_b, tolerance);
195 let seeded = |closed: bool| {
196 ogeom_geom::fit::fit_points_joint_from(
197 &points,
198 &unwrapped_a,
199 &unwrapped_b,
200 &stations,
201 closed,
202 3,
203 tolerance,
204 tol,
205 )
206 };
207 let meets_itself = {
208 let n = points.len();
209 let (pa, pb) = (
210 unwrapped_a[n - 1] - unwrapped_a[0],
211 unwrapped_b[n - 1] - unwrapped_b[0],
212 );
213 let gap = (points[n - 1] - points[0]).square_magnitude()
214 + pa.square_magnitude()
215 + pb.square_magnitude();
216 gap.sqrt() <= tol.confusion()
217 };
218 let mut fitted = seeded(branch.closed() && meets_itself).ok();
219 if branch.closed()
220 && !meets_itself
221 && fitted.as_ref().is_none_or(|f| f.0.error > tolerance)
222 && winds_periodically(a, &unwrapped_a)
223 && winds_periodically(b, &unwrapped_b)
224 && let Ok(winding) = seeded(true)
225 && fitted.as_ref().is_none_or(|f| winding.0.error < f.0.error)
226 {
227 fitted = Some(winding);
228 }
229 let (space, on_a, on_b) = match fitted {
230 Some(fitted) if fitted.0.error <= tolerance => fitted,
231 Some(fitted) => {
232 let other = free()?;
233 if other.0.error < fitted.0.error {
234 other
235 } else {
236 fitted
237 }
238 }
239 None => free()?,
240 };
241
242 let lifted = lift_error(a, b, &on_a, &on_b, &space.curve, tol);
246 let charted =
253 space_error(a, &on_a, space.error, tol).max(space_error(b, &on_b, space.error, tol));
254 let lifted = lifted
255 .along
256 .max(lifted.across.min(charted.max(space.error)));
257 let fit_error = if space.error <= lifted {
260 lifted
261 } else {
262 lifted.max(trace_error(&space.curve, &points, space.error, tol))
263 };
264 Ok(IntersectionCurve {
265 fit_error,
266 met: space.error <= tolerance,
267 curve: space.curve,
268 on_a,
269 on_b,
270 closed: branch.closed(),
271 })
272}
273
274type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
276
277fn cubic_stations(
283 points: &[ogeom_math::Point],
284 image_a: &[Point2],
285 image_b: &[Point2],
286 tolerance: f64,
287) -> Vec<usize> {
288 let n = points.len();
289 let target = tolerance / 8.0;
290 let traces: [Vec<[f64; 3]>; 3] = [
291 points.iter().map(|p| [p.x, p.y, p.z]).collect(),
292 image_a.iter().map(|p| [p.x, p.y, 0.0]).collect(),
293 image_b.iter().map(|p| [p.x, p.y, 0.0]).collect(),
294 ];
295 let shape: Vec<(Vec<f64>, Vec<f64>)> = traces
298 .iter()
299 .map(|trace| {
300 let segment =
301 |k: usize| -> [f64; 3] { core::array::from_fn(|d| trace[k + 1][d] - trace[k][d]) };
302 let dot = |u: [f64; 3], v: [f64; 3]| u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
303 let lengths: Vec<f64> = (0..n - 1)
304 .map(|k| dot(segment(k), segment(k)).sqrt())
305 .collect();
306 let mut turns = vec![0.0; n];
307 for k in 1..n - 1 {
308 let scale = lengths[k - 1] * lengths[k];
309 if scale > 0.0 {
310 turns[k] = (dot(segment(k - 1), segment(k)) / scale)
311 .clamp(-1.0, 1.0)
312 .acos();
313 }
314 }
315 (lengths, turns)
316 })
317 .collect();
318 let mut out = vec![0];
319 let mut from = 0;
320 while from + 1 < n {
321 let mut sums: Vec<(f64, f64)> = shape
322 .iter()
323 .map(|(lengths, _)| (lengths[from], 0.0))
324 .collect();
325 let mut to = from + 1;
326 while to + 1 < n {
327 let grown: Vec<(f64, f64)> = sums
328 .iter()
329 .zip(&shape)
330 .map(|(&(length, turn), (lengths, turns))| (length + lengths[to], turn + turns[to]))
331 .collect();
332 if grown
333 .iter()
334 .any(|&(length, turn)| length * turn.powi(3) / 384.0 > target)
335 {
336 break;
337 }
338 sums = grown;
339 to += 1;
340 }
341 out.push(to);
342 from = to;
343 }
344 out
345}
346
347struct Lifted {
349 along: f64,
352 across: f64,
355}
356
357fn lift_error(
363 a: &SurfaceGeometry,
364 b: &SurfaceGeometry,
365 on_a: &BSpline2d,
366 on_b: &BSpline2d,
367 curve: &BSplineCurve,
368 tol: Tolerances,
369) -> Lifted {
370 use ogeom_geom::{Curve2d as _, Curve3d as _};
371 let (lo, hi) = curve.knots().domain();
372 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
373 let stations = (4 * spans).max(200);
374 let mut out = Lifted {
375 along: 0.0,
376 across: 0.0,
377 };
378 for k in 0..=stations {
379 #[allow(clippy::cast_precision_loss)]
380 let t = lo + (hi - lo) * k as f64 / stations as f64;
381 let Ok(on) = curve.point_at(t, tol) else {
382 continue;
383 };
384 let mut gap = 0.0_f64;
385 let mut normals = Vec::with_capacity(2);
386 for (surface, pcurve) in [(a, on_a), (b, on_b)] {
387 let Ok(at) = pcurve.point_at(t, tol) else {
388 continue;
389 };
390 let Ok(lifted) = surface.point_at(at.x, at.y, tol) else {
391 continue;
392 };
393 gap = gap.max(lifted.distance(on));
394 if let Ok(normal) = surface.normal_at(at.x, at.y, tol) {
395 normals.push(normal.vector());
396 }
397 }
398 out.along = out.along.max(gap);
399 if let [na, nb] = normals[..] {
400 let sine = na.cross(nb).magnitude().max(tol.angular());
401 out.across = out.across.max(gap / sine);
402 }
403 }
404 out
405}
406
407fn trace_error(
417 curve: &BSplineCurve,
418 samples: &[ogeom_math::Point],
419 bound: f64,
420 tol: Tolerances,
421) -> f64 {
422 use ogeom_geom::Curve3d as _;
423 let (lo, hi) = curve.knots().domain();
424 let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
425 let count = (4 * spans).max(2 * samples.len()).max(200);
426 #[allow(clippy::cast_precision_loss)]
427 let at = |k: usize| lo + (hi - lo) * k as f64 / count as f64;
428 let mut stations: Option<Vec<(f64, ogeom_math::Point)>> = None;
429 let foot = |p: ogeom_math::Point, mut t: f64| -> (f64, f64) {
430 let mut best = (t, f64::INFINITY);
431 for _ in 0..8 {
432 let (Ok(q), Ok(d)) = (curve.point_at(t, tol), curve.d1_at(t, tol)) else {
433 break;
434 };
435 let gap = q.distance(p);
436 if gap < best.1 {
437 best = (t, gap);
438 }
439 let speed = d.dot(d);
440 if speed <= f64::MIN_POSITIVE {
441 break;
442 }
443 let next = (t + (p - q).dot(d) / speed).clamp(lo, hi);
444 if (next - t).abs() <= (hi - lo) * 1e-12 {
445 break;
446 }
447 t = next;
448 }
449 if let Ok(q) = curve.point_at(t, tol)
450 && q.distance(p) < best.1
451 {
452 best = (t, q.distance(p));
453 }
454 best
455 };
456 let mut t = lo;
457 let mut worst = 0.0_f64;
458 for p in samples {
459 let mut found = foot(*p, t);
460 if found.1 > bound {
461 let stations = stations.get_or_insert_with(|| {
462 (0..=count)
463 .filter_map(|k| curve.point_at(at(k), tol).ok().map(|q| (at(k), q)))
464 .collect()
465 });
466 if let Some(k) = (0..stations.len()).min_by(|&x, &y| {
467 stations[x]
468 .1
469 .distance(*p)
470 .total_cmp(&stations[y].1.distance(*p))
471 }) {
472 let gap = |u: f64| {
478 curve
479 .point_at(u, tol)
480 .map_or(f64::INFINITY, |q| q.distance(*p))
481 };
482 let (from, to) = (
483 stations[k.saturating_sub(2)].0,
484 stations[(k + 2).min(stations.len() - 1)].0,
485 );
486 const FINE: u32 = 256;
487 let h = (to - from) / f64::from(FINE);
488 let start = (0..=FINE)
489 .map(|j| from + h * f64::from(j))
490 .min_by(|&x, &y| gap(x).total_cmp(&gap(y)))
491 .unwrap_or(from);
492 let (mut a, mut b) = ((start - h).max(lo), (start + h).min(hi));
493 let ratio = 0.5 * (5.0_f64.sqrt() - 1.0);
494 for _ in 0..60 {
495 let (x, y) = (b - ratio * (b - a), a + ratio * (b - a));
496 if gap(x) <= gap(y) {
497 b = y;
498 } else {
499 a = x;
500 }
501 }
502 let again = foot(*p, 0.5 * (a + b));
503 if again.1 < found.1 {
504 found = again;
505 }
506 }
507 }
508 t = found.0;
509 worst = worst.max(found.1.min(bound));
510 }
511 worst
512}
513
514fn space_error(
519 surface: &SurfaceGeometry,
520 pcurve: &BSpline2d,
521 parameter_error: f64,
522 tol: Tolerances,
523) -> f64 {
524 use ogeom_geom::Curve2d;
525 let (lo, hi) = pcurve.domain();
528 let mut worst = 0.0_f64;
529 for i in 0..=16 {
530 #[allow(clippy::cast_precision_loss)]
531 let u = lo + (hi - lo) * f64::from(i) / 16.0;
532 let Ok(at) = pcurve.point_at(u, tol) else {
533 continue;
534 };
535 let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
536 continue;
537 };
538 let stretch = du.magnitude().max(dv.magnitude());
539 worst = worst.max(parameter_error * stretch);
540 }
541 worst
542}
543
544fn unwrap_periodic(
551 surface: &SurfaceGeometry,
552 samples: &[(f64, f64)],
553 tol: Tolerances,
554) -> Vec<Point2> {
555 let ((ua, ub), (va, vb)) = surface.domain();
556 let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
563 Some(ub - ua)
564 } else {
565 None
566 };
567 let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
568 Some(vb - va)
569 } else {
570 None
571 };
572 let fold = |previous: f64, next: f64, period: Option<f64>| match period {
573 None => next,
574 Some(period) => {
575 let mut candidate = next;
576 while candidate - previous > period * 0.5 {
577 candidate -= period;
578 }
579 while previous - candidate > period * 0.5 {
580 candidate += period;
581 }
582 candidate
583 }
584 };
585
586 let mut out = Vec::with_capacity(samples.len());
587 let mut at = Point2::new(samples[0].0, samples[0].1);
588 out.push(at);
589 for sample in &samples[1..] {
590 at = Point2::new(
591 fold(at.x, sample.0, u_period),
592 fold(at.y, sample.1, v_period),
593 );
594 out.push(at);
595 }
596 out
597}
598
599#[cfg(test)]
600#[allow(clippy::unwrap_used)]
601mod tests {
602 use super::*;
603 use crate::march::{Marching, branches};
604 use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
605 use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
606
607 const T: Tolerances = Tolerances::millimetres();
608
609 fn sphere(radius: f64) -> SurfaceGeometry {
610 SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
611 }
612
613 fn cylinder(radius: f64) -> SurfaceGeometry {
614 CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
615 .unwrap()
616 .into()
617 }
618
619 fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
620 PlaneSurface::over(
621 Plane::through(origin, Direction::new(normal, T).unwrap()),
622 (-6.0, 6.0),
623 (-6.0, 6.0),
624 )
625 .unwrap()
626 .into()
627 }
628
629 fn options() -> Marching {
630 Marching {
631 chord: 1e-5,
632 ..Marching::default()
633 }
634 }
635
636 fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
642 let off = |surface: &SurfaceGeometry, p: Point| match surface {
643 SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
644 SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
645 SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
646 _ => 0.0,
647 };
648 let (lo, hi) = curve.knots().domain();
649 let mut worst = 0.0_f64;
650 for i in 0..=800 {
651 #[allow(clippy::cast_precision_loss)]
652 let u = lo + (hi - lo) * f64::from(i) / 800.0;
653 if let Ok(p) = curve.point_at(u, T) {
654 worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
655 }
656 }
657 worst
658 }
659
660 #[test]
661 fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
662 let a = sphere(3.0);
666 let b = cylinder(1.5);
667 let found = branches(&a, &b, options(), T).unwrap();
668 assert_eq!(found.len(), 2);
669
670 for branch in &found {
671 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
672 assert!(fitted.met, "fit error {:e}", fitted.fit_error);
673 assert!(fitted.closed);
674 let off = fitted_deviation(&a, &b, &fitted.curve);
675 assert!(
676 off <= 1e-4 + 1e-5,
677 "the fitted curve is {off:e} off the surfaces"
678 );
679 assert!(
681 fitted.curve.control_points().len() * 4 < branch.points.len(),
682 "{} control points for {} samples",
683 fitted.curve.control_points().len(),
684 branch.points.len()
685 );
686 }
687 }
688
689 #[test]
690 fn the_pcurves_lift_back_onto_the_curve() {
691 let a = sphere(3.0);
695 let b = cylinder(1.5);
696 let found = branches(&a, &b, options(), T).unwrap();
697 let branch = &found[0];
698 let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
699
700 for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
701 let (lo, hi) = pcurve.domain();
702 for i in 0..=200 {
703 #[allow(clippy::cast_precision_loss)]
704 let u = lo + (hi - lo) * f64::from(i) / 200.0;
705 let at = pcurve.point_at(u, T).unwrap();
706 let lifted = surface.point_at(at.x, at.y, T).unwrap();
707 let off = match (surface as &SurfaceGeometry, &a, &b) {
711 _ if core::ptr::eq(surface, &a) => match &b {
712 SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
713 _ => 0.0,
714 },
715 _ => match &a {
716 SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
717 _ => 0.0,
718 },
719 };
720 assert!(
721 off.abs() < 5e-4,
722 "a lifted pcurve point is {off:e} off the intersection"
723 );
724 }
725 }
726 }
727
728 #[test]
729 fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
730 let a = cylinder(2.0);
735 let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
736 let found = branches(&a, &b, options(), T).unwrap();
737 assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
738 let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
739
740 let (lo, hi) = fitted.on_a.domain();
743 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
744 for i in 1..=400 {
745 #[allow(clippy::cast_precision_loss)]
746 let u = lo + (hi - lo) * f64::from(i) / 400.0;
747 let at = fitted.on_a.point_at(u, T).unwrap();
748 assert!(
749 (at.x - previous.x).abs() < 1.0,
750 "the pcurve tears at the seam: {} to {}",
751 previous.x,
752 at.x
753 );
754 previous = at;
755 }
756 }
757
758 #[test]
768 fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
769 let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
770 assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
771 let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
772 let found = branches(&drum, &cut, options(), T).unwrap();
773 assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
774 assert!(found[0].closed(), "the loop closes on the seam");
775 let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
776 assert!(fitted.closed);
777 assert!(
778 fitted.fit_error < 1e-3,
779 "the loop fits as one: {}",
780 fitted.fit_error
781 );
782 let (lo, hi) = fitted.on_a.domain();
783 let mut previous = fitted.on_a.point_at(lo, T).unwrap();
784 for i in 1..=400 {
785 let u = lo + (hi - lo) * f64::from(i) / 400.0;
786 let at = fitted.on_a.point_at(u, T).unwrap();
787 assert!(
788 (at.x - previous.x).abs() < 0.5,
789 "the chart image tears at the seam: {} to {}",
790 previous.x,
791 at.x
792 );
793 previous = at;
794 }
795 }
796
797 #[test]
805 fn a_section_across_a_wide_drum_states_its_measured_error() {
806 let radius = 23.6;
807 let wall = CylinderSurface::new(
808 Cylinder::new(Frame::WORLD, radius, T).unwrap(),
809 (-30.0, 30.0),
810 )
811 .unwrap();
812 let drum: SurfaceGeometry = SurfaceGeometry::from(wall).to_bspline(T).unwrap().into();
813 let bore = Cylinder::new(
814 Frame::new(
815 Point::new(0.0, 0.0, 3.0),
816 Direction::new(Vector::X, T).unwrap(),
817 Direction::new(Vector::Y, T).unwrap(),
818 T,
819 )
820 .unwrap(),
821 9.0,
822 T,
823 )
824 .unwrap();
825 let drill: SurfaceGeometry = CylinderSurface::new(bore, (-40.0, 40.0)).unwrap().into();
826 let marching = Marching {
827 chord: 1e-4,
828 ..Marching::default()
829 };
830 let found = branches(&drum, &drill, marching, T).unwrap();
831 assert!(!found.is_empty());
832 for branch in &found {
833 let fitted = approximate_branch(&drum, &drill, branch, 1e-4, T).unwrap();
834 let (lo, hi) = fitted.curve.knots().domain();
835 let mut off = 0.0_f64;
836 for i in 0..=2000 {
837 let t = lo + (hi - lo) * f64::from(i) / 2000.0;
838 let p = fitted.curve.point_at(t, T).unwrap();
839 off = off.max(
840 wall.cylinder()
841 .distance_to(p)
842 .abs()
843 .max(bore.distance_to(p).abs()),
844 );
845 }
846 assert!(
849 fitted.fit_error + marching.chord >= off,
850 "states {:e} for a curve {off:e} off its surfaces",
851 fitted.fit_error
852 );
853 assert!(
854 fitted.fit_error <= 10.0 * off.max(marching.chord),
855 "states {:e} for a curve {off:e} off its surfaces",
856 fitted.fit_error
857 );
858 }
859 }
860
861 #[test]
862 fn what_cannot_be_fitted_is_refused() {
863 let a = sphere(1.0);
864 let b = plane(Point::ORIGIN, Vector::Z);
865 let found = branches(&a, &b, options(), T).unwrap();
866 assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
867 assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
868
869 let empty = Traced {
870 points: vec![],
871 on_a: vec![],
872 on_b: vec![],
873 stopped: crate::march::Stopped::Stalled,
874 };
875 assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
876 }
877}