Skip to main content

ogeom_intersect/
approx.rs

1//! The approximation stage: a traced branch becomes curves.
2//!
3//! A traced branch is a polyline with a stated chord tolerance: honest, and
4//! not what anything downstream wants to hold. An edge wants a curve in space;
5//! a face wants that curve in its *own parameter space*, because splitting a
6//! face happens there and a curve the face cannot express is a curve it cannot
7//! be split along (`docs/DATA_MODEL.md` §6).
8//!
9//! So one branch becomes three fits sharing one tolerance: the 3D curve, and
10//! one pcurve per surface, each fitted from the samples the tracer already
11//! recorded. The tracer kept the parameters on both surfaces at every point
12//! precisely for this moment; re-deriving them here would be a projection per
13//! point, solving again what the marcher already solved.
14//!
15//! # The tolerance story, stated once
16//!
17//! The result's tolerance is a *sum of stated parts*, not a hope: the trace
18//! sits within its chord tolerance of the true intersection, and the fit sits
19//! within its own reported error of the trace. Both numbers are carried, and
20//! the total is what an edge built on this curve must widen its tolerance to.
21//! Nothing here rounds a miss up to a hit; a fit that could not reach its
22//! target says so, and the caller decides whether the looser curve is usable.
23//!
24//! # Seams
25//!
26//! A branch crossing a periodic surface's seam has parameter samples that jump
27//! by a period: the pcurve polyline tears even though the curve in space is
28//! smooth. The samples are unwrapped before fitting: each step is folded to
29//! the nearest image, so the pcurve runs continuously past the seam and may
30//! legitimately leave `[0, 2π)`. That is what a pcurve on a periodic surface
31//! is; folding it back would re-tear it.
32
33use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
34use ogeom_geom::{BSpline2d, BSplineCurve, Surface, SurfaceGeometry};
35use ogeom_math::Point2;
36
37use crate::march::Traced;
38
39/// A branch of an intersection, as curves.
40#[derive(Debug, Clone, PartialEq)]
41pub struct IntersectionCurve {
42    /// The curve in space.
43    pub curve: BSplineCurve,
44    /// The same curve in the first surface's parameter space.
45    pub on_a: BSpline2d,
46    /// And in the second's.
47    pub on_b: BSpline2d,
48    /// How far the *fits* may sit from the traced polyline, in millimetres.
49    ///
50    /// Measured: the curve against the trace's samples, each pcurve lifted
51    /// against the curve, the curve against the surfaces' crossing between
52    /// the samples, and, where the surfaces meet at a shallow angle, how far
53    /// along them their crossing may sit from the curve. The
54    /// distance to the true intersection adds the trace's own chord
55    /// tolerance on top; both are stated so an edge built on this knows
56    /// what to carry.
57    pub fit_error: f64,
58    /// Whether every fit met the tolerance it was asked for.
59    pub met: bool,
60    /// Whether the branch is a closed loop.
61    pub closed: bool,
62}
63
64/// Fit one traced branch to curves, within `tolerance`.
65///
66/// # Errors
67///
68/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the branch has
69/// fewer than two points or the tolerance is not a positive distance.
70pub 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    // Marching correction can leave consecutive samples closer than the
86    // rounding it converged within, and two samples at one chord-length
87    // parameter are a knot span with no data in it: the fitting system
88    // reports itself singular where the real defect is the duplicate. Thin
89    // them here, where the trace's own step says what "too close" means.
90    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    // A sample is one point seen three ways, and where the three disagree
94    // it is not data: through a point where the surfaces touch, the tracer
95    // can report a step's position with its neighbour's parameters, and
96    // the joint fit, asked to pass through both descriptions at once,
97    // stalls a thousand times above its budget at that one sample.
98    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    // One fit in seven dimensions: the curve and both parameter images
127    // together. Fitted separately, each fit's parameter correction drifts
128    // its parameterization independently and the three results silently stop
129    // being same-parameter: a pcurve claiming 1e-7 can evaluate millimetres
130    // from its own curve. Jointly, one
131    // parameterization and one knot vector serve all three, and the reported
132    // error bounds every coordinate.
133    let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
134    let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
135    // A closed branch takes the loop-smoothing fit: the join's tangents are
136    // constrained to agree in all seven coordinates, so the section curve and
137    // both pcurves cross their own seam without a crease. A loop winding
138    // once round a periodic surface ends its image there a period from where
139    // it began, and that fit, closing only where every image does, takes it
140    // as an open trace whose ends lie close together, which can stall far
141    // from the trace. Where it does, the loop is fitted closed in space with
142    // the images' join C1 across the seam, right where the chart runs at one
143    // speed across it, as a periodic surface's does (a patch that merely
144    // meets itself at its seam need not), and the closer of the two stands.
145    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    // The walk steps by how far its chord sags, which for a cubic is far
189    // finer than the fit needs: between samples a step `h` apart on a curve
190    // turning at `k`, a cubic misses by about `h^4 k^3 / 384` where the
191    // chord sags by `h^2 k / 8`. So the knots start where that estimate
192    // places a cubic's spans, every sample measures the fit, and only a
193    // fit that misses one is made the free way as well, the closer of the
194    // two standing.
195    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    // Measured where it is promised, in millimetres. The joint fit's own
244    // residual mixes space and chart coordinates and says little about
245    // either alone, so it serves only as a bound on what is measured here.
246    let lifted = lift_error(a, b, &on_a, &on_b, &space.curve, tol);
247    // Where the surfaces meet at a shallow angle, a curve microns off both
248    // can still sit far along them from where they cross. That distance is
249    // the surfaces' gap over the sine of their angle, and it is no more than
250    // how far the lifted pcurves stand from the crossing itself, found at
251    // the same stations. Where a station's crossing could not be found, the
252    // fit's chart residual carried through each surface's stretch bounds it
253    // at the samples. The smaller of the two stands.
254    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    // Each sample's distance from the curve, which the fit's residual
266    // already bounds: read only where that bound would decide the error.
267    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
282/// A joint fit: the curve, and its images on the two surfaces.
283type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
284
285/// The samples a cubic fit's knots start at, by index, the first and last
286/// among them: each stretch between two holds a cubic's estimated miss,
287/// `L Θ³ / 384` for a stretch `L` long turning through `Θ`, to an eighth
288/// of `tolerance`, in space and in either chart, the turn read off the
289/// trace's own polylines.
290fn 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    // Per trace: each segment's length, and the turn at each sample
304    // between two segments.
305    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
355/// What lifting the pcurves onto their surfaces shows.
356struct Lifted {
357    /// How far either pcurve, lifted, stands from the curve at the same
358    /// parameter.
359    along: f64,
360    /// That gap over the sine of the surfaces' angle there: how far the
361    /// surfaces' true crossing may sit from the curve.
362    across: f64,
363    /// How far the curve stands from the surfaces' crossing in its normal
364    /// plane.
365    curve_off: f64,
366    /// How far either pcurve, lifted, stands from that crossing.
367    lifted_off: f64,
368    /// Whether the crossing went unfound at some station.
369    unsettled: bool,
370}
371
372/// Each pcurve lifted through its surface against the curve, at four
373/// stations in every span of the curve's knots and at least two hundred
374/// along it, between the trace's samples as well as at them. Near a cone's
375/// apex or a sphere's pole the chart turns fast, and a fit that holds at
376/// every sample can wander between them.
377///
378/// At each station the surfaces' crossing in the curve's normal plane is
379/// found by Newton's method from the pcurves' points, and the curve and
380/// both lifted pcurves are measured against it: what the fit misses
381/// between the samples of the trace, read on the geometry the trace
382/// follows. That miss runs smoothly along each span of the fit, so the
383/// stations near its widest are climbed to the top of the peak beside
384/// them.
385fn 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    // A station stands up to a fifth below the peak beside it where the
394    // miss swells between two of them; each such peak, widest first, is
395    // climbed by golden sections between its neighbours.
396    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    // Each station's miss against the crossing, zero where none was read.
415    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
474/// What the curve reads at one parameter.
475struct Station {
476    /// How far either pcurve, lifted, stands from the curve.
477    gap: f64,
478    /// That gap over the sine of the surfaces' angle, zero where either
479    /// normal is not determined.
480    across: f64,
481    /// How far the curve and either lifted pcurve stand from the surfaces'
482    /// crossing in the curve's normal plane, `None` where it is not found.
483    off: Option<(f64, f64)>,
484}
485
486/// A surface's chart point, its point there and first derivatives.
487type Jet = (
488    Point2,
489    ogeom_math::Point,
490    ogeom_math::Vector,
491    ogeom_math::Vector,
492);
493
494/// The curve and both pcurves at `t`, measured against each other and
495/// against the surfaces' crossing in the curve's normal plane there.
496/// `None` where the curve cannot be evaluated. A crossing farther off
497/// than `step` along the curve is another stretch of the crossing met in
498/// this normal plane, not the one the curve follows, and is not read.
499#[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        // The normal as the surface states it: none where the tangents
525        // collapse against their own length.
526        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/// Where the surfaces cross in the plane through `anchor` normal to
555/// `along`, by damped Newton steps from the two jets given, each step
556/// first cut to carry neither surface's point past `reach` and then
557/// halved until the surfaces draw closer. From the curve's own station
558/// the crossing is a few steps off where there is one; where a step finds
559/// no closer point within `HALVINGS`, or the steps do not settle within
560/// `MOST`, no crossing is near and the answer is `None`.
561#[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        // How far the step would carry each surface's point, to first
600        // order. A crossing past `reach` is not the one sought, so the
601        // halving starts at the first scale that stays within it.
602        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
643/// `m x = r` for four unknowns, by elimination with partial pivoting;
644/// `None` at a zero pivot.
645fn 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
671/// How far the curve stands from the trace it was fitted to: each sample's
672/// distance to its nearest point on the curve.
673///
674/// The samples run in order along the curve, so each one's foot is found by
675/// Newton steps from the last one's. Where that does not settle within
676/// `bound`, the fit's own residual, which already bounds each sample's
677/// distance from the curve at the fit's parameter, the nearest of the
678/// curve's stations seeds the search instead, and the result never exceeds
679/// `bound`.
680fn 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                // Scanned finely over the stations either side, then
737                // narrowed by golden section: where the curve all but stops
738                // in space (its chart image swinging round a pole) or kinks
739                // between two samples, Newton's steps are blind and the
740                // distance has more than one dip between stations.
741                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
778/// The fit's chart residual carried into space through the surface's
779/// stretch along the pcurve: a bound on how far the lifted pcurve stands
780/// from the trace, loose by the stretch where the residual is mostly in the
781/// other coordinates, and so used only to cap an estimate, never stated.
782fn space_error(
783    surface: &SurfaceGeometry,
784    pcurve: &BSpline2d,
785    parameter_error: f64,
786    tol: Tolerances,
787) -> f64 {
788    use ogeom_geom::Curve2d;
789    // Convert the parameter-space error back through the surface's local
790    // stretch at a few places; take the worst.
791    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
808/// Unfold parameter samples across a periodic surface's seam.
809///
810/// Each step is folded to the nearest image of the next sample, so a branch
811/// crossing `u = 0` continues to `-0.1` rather than tearing to `2π - 0.1`. The
812/// result may leave the surface's stated domain, which is what a pcurve
813/// crossing a seam *is*.
814fn unwrap_periodic(
815    surface: &SurfaceGeometry,
816    samples: &[(f64, f64)],
817    tol: Tolerances,
818) -> Vec<Point2> {
819    let ((ua, ub), (va, vb)) = surface.domain();
820    // Closure as well as periodicity: a converted drum is a clamped patch
821    // that meets itself at its seam, and a loop walked round it lands on
822    // either side of that seam by the walk's own rounding. Folded by the
823    // chart's span like a period, the trace is the continuous curve it is.
824    // Left as sampled, it jumps a whole span at the seam and the closed
825    // fit chases the jump far from the trace.
826    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    /// The distance of a fitted curve from both surfaces, sampled densely.
901    ///
902    /// This is the measure the whole stage exists for: the *fit* (not the
903    /// polyline it came from) is what downstream code holds, so the fit is
904    /// what must lie on both surfaces.
905    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    /// The error stated covers the fit between the trace's samples where
925    /// the surfaces cross at a shallow angle. A bowl a hundred millimetres
926    /// round at its foot dips two hundredths below a plane, both charts
927    /// running evenly with `x` and `y`, and nine samples of half the circle
928    /// they cross in are fitted through every one. Between the samples the
929    /// fit and both pcurves leave the circle alike, within the plane, where
930    /// the bowl stands only a fiftieth of that above it; the miss is read
931    /// against the crossing itself.
932    #[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        // z = (x^2 + y^2) / (2 radius) - dip, exactly, as a quadratic patch
940        // over [-half, half] in both.
941        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        // The tolerance story end to end: trace within 1e-5, fit within 1e-4,
1004        // so the fitted curve is within the sum of the two of the true
1005        // intersection, measured against the surfaces, not the polyline.
1006        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            // And it is compact: a curve, not a decorated polyline.
1021            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        // A pcurve is only worth having if evaluating it and lifting through
1033        // its surface lands on the intersection. Checked through both
1034        // surfaces at matched ends and sampled interiors.
1035        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                // The lifted point is on its own surface by construction; what
1049                // matters is that it is on the *other* one too, i.e. on the
1050                // intersection.
1051                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        // A plane through a cylinder's axis at an angle produces an ellipse
1072        // whose pcurve crosses the cylinder's u = 0 seam. Folded naively the
1073        // pcurve tears by 2π; unwrapped it runs smoothly and leaves the stated
1074        // domain, which is what crossing a seam means.
1075        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        // Continuity: no two adjacent samples of the fitted pcurve jump by
1082        // anything near a period.
1083        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    /// A loop walked round a converted drum is closed, seam or no seam.
1100    ///
1101    /// A cylinder converted to a patch is clamped, not periodic: it meets
1102    /// itself at its seam. A plane across it cuts a circle the walk reaches
1103    /// the seam on from both sides, each half stopping a fraction of a step
1104    /// short of it, and the joined branch has coincident ends. Left flagged
1105    /// as having left the domain, the arrangement downstream would hold a
1106    /// circle with two ends at one point. It is closed, and fitted as a loop
1107    /// whose chart image runs continuously across the seam.
1108    #[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    /// A bore across a converted drum states the error its curves have.
1139    ///
1140    /// The joint fit's residual mixes millimetres with the drum chart's
1141    /// coordinates, and carried through the drum's stretch it reads hundreds
1142    /// of times larger than the distance the fitted curves stand from the
1143    /// two surfaces. The surfaces cross steeply here, so that distance is
1144    /// what is stated, within a small multiple.
1145    #[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            // Honest both ways: no less than the curve stands off, and not
1188            // hundreds of times more.
1189            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}