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, and, where the surfaces meet at a shallow angle,
52    /// how far along them their crossing may sit from the curve. The
53    /// distance to the true intersection adds the trace's own chord
54    /// tolerance on top; both are stated so an edge built on this knows
55    /// what to carry.
56    pub fit_error: f64,
57    /// Whether every fit met the tolerance it was asked for.
58    pub met: bool,
59    /// Whether the branch is a closed loop.
60    pub closed: bool,
61}
62
63/// Fit one traced branch to curves, within `tolerance`.
64///
65/// # Errors
66///
67/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the branch has
68/// fewer than two points or the tolerance is not a positive distance.
69pub 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    // Marching correction can leave consecutive samples closer than the
85    // rounding it converged within, and two samples at one chord-length
86    // parameter are a knot span with no data in it: the fitting system
87    // reports itself singular where the real defect is the duplicate. Thin
88    // them here, where the trace's own step says what "too close" means.
89    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    // A sample is one point seen three ways, and where the three disagree
93    // it is not data: through a point where the surfaces touch, the tracer
94    // can report a step's position with its neighbour's parameters, and
95    // the joint fit, asked to pass through both descriptions at once,
96    // stalls a thousand times above its budget at that one sample.
97    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    // One fit in seven dimensions: the curve and both parameter images
126    // together. Fitted separately, each fit's parameter correction drifts
127    // its parameterization independently and the three results silently stop
128    // being same-parameter: a pcurve claiming 1e-7 can evaluate millimetres
129    // from its own curve. Jointly, one
130    // parameterization and one knot vector serve all three, and the reported
131    // error bounds every coordinate.
132    let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
133    let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
134    // A closed branch takes the loop-smoothing fit: the join's tangents are
135    // constrained to agree in all seven coordinates, so the section curve and
136    // both pcurves cross their own seam without a crease. A loop winding
137    // once round a periodic surface ends its image there a period from where
138    // it began, and that fit, closing only where every image does, takes it
139    // as an open trace whose ends lie close together, which can stall far
140    // from the trace. Where it does, the loop is fitted closed in space with
141    // the images' join C1 across the seam, right where the chart runs at one
142    // speed across it, as a periodic surface's does (a patch that merely
143    // meets itself at its seam need not), and the closer of the two stands.
144    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    // The walk steps by how far its chord sags, which for a cubic is far
188    // finer than the fit needs: between samples a step `h` apart on a curve
189    // turning at `k`, a cubic misses by about `h^4 k^3 / 384` where the
190    // chord sags by `h^2 k / 8`. So the knots start where that estimate
191    // places a cubic's spans, every sample measures the fit, and only a
192    // fit that misses one is made the free way as well, the closer of the
193    // two standing.
194    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    // Measured where it is promised, in millimetres. The joint fit's own
243    // residual mixes space and chart coordinates and says little about
244    // either alone, so it serves only as a bound on what is measured here.
245    let lifted = lift_error(a, b, &on_a, &on_b, &space.curve, tol);
246    // Where the surfaces meet at a shallow angle, a curve microns off both
247    // can still sit far along them from where they cross. That distance is
248    // the surfaces' gap over the sine of their angle, and it is no more than
249    // the fit's chart residual carried through each surface's stretch, which
250    // bounds how far the lifted pcurves stand from the trace whatever the
251    // angle. The smaller of the two stands.
252    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    // Each sample's distance from the curve, which the fit's residual
258    // already bounds: read only where that bound would decide the error.
259    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
274/// A joint fit: the curve, and its images on the two surfaces.
275type Joint = (ogeom_geom::fit::Fitted<BSplineCurve>, BSpline2d, BSpline2d);
276
277/// The samples a cubic fit's knots start at, by index, the first and last
278/// among them: each stretch between two holds a cubic's estimated miss,
279/// `L Θ³ / 384` for a stretch `L` long turning through `Θ`, to an eighth
280/// of `tolerance`, in space and in either chart, the turn read off the
281/// trace's own polylines.
282fn 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    // Per trace: each segment's length, and the turn at each sample
296    // between two segments.
297    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
347/// What lifting the pcurves onto their surfaces shows.
348struct Lifted {
349    /// How far either pcurve, lifted, stands from the curve at the same
350    /// parameter.
351    along: f64,
352    /// That gap over the sine of the surfaces' angle there: how far the
353    /// surfaces' true crossing may sit from the curve.
354    across: f64,
355}
356
357/// Each pcurve lifted through its surface against the curve, at four
358/// stations in every span of the curve's knots and at least two hundred
359/// along it, between the trace's samples as well as at them. Near a cone's
360/// apex or a sphere's pole the chart turns fast, and a fit that holds at
361/// every sample can wander between them.
362fn 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
407/// How far the curve stands from the trace it was fitted to: each sample's
408/// distance to its nearest point on the curve.
409///
410/// The samples run in order along the curve, so each one's foot is found by
411/// Newton steps from the last one's. Where that does not settle within
412/// `bound`, the fit's own residual, which already bounds each sample's
413/// distance from the curve at the fit's parameter, the nearest of the
414/// curve's stations seeds the search instead, and the result never exceeds
415/// `bound`.
416fn 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                // Scanned finely over the stations either side, then
473                // narrowed by golden section: where the curve all but stops
474                // in space (its chart image swinging round a pole) or kinks
475                // between two samples, Newton's steps are blind and the
476                // distance has more than one dip between stations.
477                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
514/// The fit's chart residual carried into space through the surface's
515/// stretch along the pcurve: a bound on how far the lifted pcurve stands
516/// from the trace, loose by the stretch where the residual is mostly in the
517/// other coordinates, and so used only to cap an estimate, never stated.
518fn space_error(
519    surface: &SurfaceGeometry,
520    pcurve: &BSpline2d,
521    parameter_error: f64,
522    tol: Tolerances,
523) -> f64 {
524    use ogeom_geom::Curve2d;
525    // Convert the parameter-space error back through the surface's local
526    // stretch at a few places; take the worst.
527    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
544/// Unfold parameter samples across a periodic surface's seam.
545///
546/// Each step is folded to the nearest image of the next sample, so a branch
547/// crossing `u = 0` continues to `-0.1` rather than tearing to `2π - 0.1`. The
548/// result may leave the surface's stated domain, which is what a pcurve
549/// crossing a seam *is*.
550fn unwrap_periodic(
551    surface: &SurfaceGeometry,
552    samples: &[(f64, f64)],
553    tol: Tolerances,
554) -> Vec<Point2> {
555    let ((ua, ub), (va, vb)) = surface.domain();
556    // Closure as well as periodicity: a converted drum is a clamped patch
557    // that meets itself at its seam, and a loop walked round it lands on
558    // either side of that seam by the walk's own rounding. Folded by the
559    // chart's span like a period, the trace is the continuous curve it is.
560    // Left as sampled, it jumps a whole span at the seam and the closed
561    // fit chases the jump far from the trace.
562    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    /// The distance of a fitted curve from both surfaces, sampled densely.
637    ///
638    /// This is the measure the whole stage exists for: the *fit* (not the
639    /// polyline it came from) is what downstream code holds, so the fit is
640    /// what must lie on both surfaces.
641    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        // The tolerance story end to end: trace within 1e-5, fit within 1e-4,
663        // so the fitted curve is within the sum of the two of the true
664        // intersection, measured against the surfaces, not the polyline.
665        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            // And it is compact: a curve, not a decorated polyline.
680            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        // A pcurve is only worth having if evaluating it and lifting through
692        // its surface lands on the intersection. Checked through both
693        // surfaces at matched ends and sampled interiors.
694        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                // The lifted point is on its own surface by construction; what
708                // matters is that it is on the *other* one too, i.e. on the
709                // intersection.
710                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        // A plane through a cylinder's axis at an angle produces an ellipse
731        // whose pcurve crosses the cylinder's u = 0 seam. Folded naively the
732        // pcurve tears by 2π; unwrapped it runs smoothly and leaves the stated
733        // domain, which is what crossing a seam means.
734        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        // Continuity: no two adjacent samples of the fitted pcurve jump by
741        // anything near a period.
742        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    /// A loop walked round a converted drum is closed, seam or no seam.
759    ///
760    /// A cylinder converted to a patch is clamped, not periodic: it meets
761    /// itself at its seam. A plane across it cuts a circle the walk reaches
762    /// the seam on from both sides, each half stopping a fraction of a step
763    /// short of it, and the joined branch has coincident ends. Left flagged
764    /// as having left the domain, the arrangement downstream would hold a
765    /// circle with two ends at one point. It is closed, and fitted as a loop
766    /// whose chart image runs continuously across the seam.
767    #[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    /// A bore across a converted drum states the error its curves have.
798    ///
799    /// The joint fit's residual mixes millimetres with the drum chart's
800    /// coordinates, and carried through the drum's stretch it reads hundreds
801    /// of times larger than the distance the fitted curves stand from the
802    /// two surfaces. The surfaces cross steeply here, so that distance is
803    /// what is stated, within a small multiple.
804    #[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            // Honest both ways: no less than the curve stands off, and not
847            // hundreds of times more.
848            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}