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.
49    ///
50    /// The worst of the three fits' reported errors. The distance to the true
51    /// intersection adds the trace's own chord tolerance on top; both are
52    /// stated so an edge built on this knows what to carry.
53    pub fit_error: f64,
54    /// Whether every fit met the tolerance it was asked for.
55    pub met: bool,
56    /// Whether the branch is a closed loop.
57    pub closed: bool,
58}
59
60/// Fit one traced branch to curves, within `tolerance`.
61///
62/// # Errors
63///
64/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the branch has
65/// fewer than two points or the tolerance is not a positive distance.
66pub fn approximate_branch(
67    a: &SurfaceGeometry,
68    b: &SurfaceGeometry,
69    branch: &Traced,
70    tolerance: f64,
71    tol: Tolerances,
72) -> OgeomResult<IntersectionCurve> {
73    if branch.points.len() < 2 {
74        ogeom_bail!(
75            Construction,
76            "a branch of {} points is not a curve",
77            branch.points.len()
78        );
79    }
80
81    // Marching correction can leave consecutive samples closer than the
82    // rounding it converged within, and two samples at one chord-length
83    // parameter are a knot span with no data in it: the fitting system
84    // reports itself singular where the real defect is the duplicate. Thin
85    // them here, where the trace's own step says what "too close" means.
86    let mut points: Vec<ogeom_math::Point> = Vec::with_capacity(branch.points.len());
87    let mut kept_a = Vec::with_capacity(branch.on_a.len());
88    let mut kept_b = Vec::with_capacity(branch.on_b.len());
89    // A sample is one point seen three ways, and where the three disagree
90    // it is not data: through a point where the surfaces touch, the tracer
91    // can report a step's position with its neighbour's parameters, and
92    // the joint fit, asked to pass through both descriptions at once,
93    // stalls a thousand times above its budget at that one sample.
94    let agrees = |i: usize, p: &ogeom_math::Point| -> bool {
95        let limit = tolerance.max(tol.confusion());
96        let (ua, va) = branch.on_a[i];
97        let (ub, vb) = branch.on_b[i];
98        a.point_at(ua, va, tol)
99            .is_ok_and(|q| q.distance(*p) <= limit)
100            && b.point_at(ub, vb, tol)
101                .is_ok_and(|q| q.distance(*p) <= limit)
102    };
103    for (i, p) in branch.points.iter().enumerate() {
104        let end = i == 0 || i + 1 == branch.points.len();
105        if let Some(last) = points.last()
106            && last.distance(*p) <= tol.confusion() * 10.0
107            && i + 1 != branch.points.len()
108        {
109            continue;
110        }
111        if !end && !agrees(i, p) {
112            continue;
113        }
114        points.push(*p);
115        kept_a.push(branch.on_a[i]);
116        kept_b.push(branch.on_b[i]);
117    }
118    if points.len() < 2 {
119        ogeom_bail!(Construction, "a branch of coincident points is not a curve");
120    }
121
122    // One fit in seven dimensions: the curve and both parameter images
123    // together. Fitted separately, each fit's parameter correction drifts
124    // its parameterization independently and the three results silently stop
125    // being same-parameter: a pcurve claiming 1e-7 can evaluate millimetres
126    // from its own curve. Jointly, one
127    // parameterization and one knot vector serve all three, and the reported
128    // error bounds every coordinate.
129    let unwrapped_a = unwrap_periodic(a, &kept_a, tol);
130    let unwrapped_b = unwrap_periodic(b, &kept_b, tol);
131    // A closed branch takes the loop-smoothing fit: the join's tangents are
132    // constrained to agree in all seven coordinates, so the section curve and
133    // both pcurves cross their own seam without a crease. A loop winding
134    // once round a periodic surface ends its image there a period from where
135    // it began, and that fit, closing only where every image does, takes it
136    // as an open trace whose ends lie close together, which can stall far
137    // from the trace. Where it does, the loop is fitted closed in space with
138    // the images' join C1 across the seam, right where the chart runs at one
139    // speed across it, as a periodic surface's does (a patch that merely
140    // meets itself at its seam need not), and the closer of the two stands.
141    let winds_periodically = |surface: &SurfaceGeometry, image: &[Point2]| {
142        let (first, last) = (image[0], image[image.len() - 1]);
143        ((last.x - first.x).abs() <= tol.parametric() || surface.is_periodic_u())
144            && ((last.y - first.y).abs() <= tol.parametric() || surface.is_periodic_v())
145    };
146    let (space, on_a, on_b) = if branch.closed() {
147        let closed = ogeom_geom::fit::fit_points_joint_closed(
148            &points,
149            &unwrapped_a,
150            &unwrapped_b,
151            3,
152            tolerance,
153            tol,
154        )?;
155        if !closed.0.met
156            && winds_periodically(a, &unwrapped_a)
157            && winds_periodically(b, &unwrapped_b)
158        {
159            let winding = ogeom_geom::fit::fit_points_joint_winding(
160                &points,
161                &unwrapped_a,
162                &unwrapped_b,
163                3,
164                tolerance,
165                tol,
166            )?;
167            if winding.0.error < closed.0.error {
168                winding
169            } else {
170                closed
171            }
172        } else {
173            closed
174        }
175    } else {
176        ogeom_geom::fit::fit_points_joint(&points, &unwrapped_a, &unwrapped_b, 3, tolerance, tol)?
177    };
178
179    // And measured where it is promised: each pcurve lifted through its
180    // surface against the curve, between the trace's samples as well as at
181    // them. Near a cone's apex or a sphere's pole the chart turns fast, and
182    // a fit that holds at every sample can wander between them.
183    let lifted =
184        lift_error(a, &on_a, &space.curve, tol).max(lift_error(b, &on_b, &space.curve, tol));
185    let fit_error = space
186        .error
187        .max(space_error(a, &(on_a.clone(), space.met, space.error), tol))
188        .max(space_error(b, &(on_b.clone(), space.met, space.error), tol))
189        .max(lifted);
190    Ok(IntersectionCurve {
191        fit_error,
192        met: space.met,
193        curve: space.curve,
194        on_a,
195        on_b,
196        closed: branch.closed(),
197    })
198}
199
200/// How far a pcurve, lifted through its surface, stands from the curve it
201/// images, at the same parameters: four stations in every span of the
202/// curve's knots and at least two hundred along it.
203fn lift_error(
204    surface: &SurfaceGeometry,
205    pcurve: &BSpline2d,
206    curve: &BSplineCurve,
207    tol: Tolerances,
208) -> f64 {
209    use ogeom_geom::{Curve2d as _, Curve3d as _};
210    let (lo, hi) = curve.knots().domain();
211    let spans = curve.knots().distinct().len().saturating_sub(1).max(1);
212    let stations = (4 * spans).max(200);
213    let mut worst = 0.0_f64;
214    for k in 0..=stations {
215        #[allow(clippy::cast_precision_loss)]
216        let t = lo + (hi - lo) * k as f64 / stations as f64;
217        let (Ok(on), Ok(at)) = (curve.point_at(t, tol), pcurve.point_at(t, tol)) else {
218            continue;
219        };
220        let Ok(lifted) = surface.point_at(at.x, at.y, tol) else {
221            continue;
222        };
223        worst = worst.max(lifted.distance(on));
224    }
225    worst
226}
227
228/// The fitted pcurve's error, converted back into space.
229///
230/// The pcurve was fitted in parameter units, against a scale estimated from
231/// the whole branch, but the surface's stretch varies along the curve, so an
232/// error acceptable in parameter units may be worse in millimetres where the
233/// surface stretches hardest. This converts the fit's parameter-space error
234/// through the local stretch at samples along the pcurve and reports the
235/// worst, so the number the caller reads is in the units the caller measures
236/// everything else in.
237fn space_error(surface: &SurfaceGeometry, fitted: &(BSpline2d, bool, f64), tol: Tolerances) -> f64 {
238    use ogeom_geom::Curve2d;
239    let (pcurve, _, parameter_error) = fitted;
240    // Convert the parameter-space error back through the surface's local
241    // stretch at a few places; take the worst.
242    let (lo, hi) = pcurve.domain();
243    let mut worst = 0.0_f64;
244    for i in 0..=16 {
245        #[allow(clippy::cast_precision_loss)]
246        let u = lo + (hi - lo) * f64::from(i) / 16.0;
247        let Ok(at) = pcurve.point_at(u, tol) else {
248            continue;
249        };
250        let Ok((du, dv)) = surface.d1_at(at.x, at.y, tol) else {
251            continue;
252        };
253        let stretch = du.magnitude().max(dv.magnitude());
254        worst = worst.max(parameter_error * stretch);
255    }
256    worst
257}
258
259/// Unfold parameter samples across a periodic surface's seam.
260///
261/// Each step is folded to the nearest image of the next sample, so a branch
262/// crossing `u = 0` continues to `-0.1` rather than tearing to `2π - 0.1`. The
263/// result may leave the surface's stated domain, which is what a pcurve
264/// crossing a seam *is*.
265fn unwrap_periodic(
266    surface: &SurfaceGeometry,
267    samples: &[(f64, f64)],
268    tol: Tolerances,
269) -> Vec<Point2> {
270    let ((ua, ub), (va, vb)) = surface.domain();
271    // Closure as well as periodicity: a converted drum is a clamped patch
272    // that meets itself at its seam, and a loop walked round it lands on
273    // either side of that seam by the walk's own rounding. Folded by the
274    // chart's span like a period, the trace is the continuous curve it is.
275    // Left as sampled, it jumps a whole span at the seam and the closed
276    // fit chases the jump far from the trace.
277    let u_period = if surface.is_periodic_u() || surface.is_closed_u(tol) {
278        Some(ub - ua)
279    } else {
280        None
281    };
282    let v_period = if surface.is_periodic_v() || surface.is_closed_v(tol) {
283        Some(vb - va)
284    } else {
285        None
286    };
287    let fold = |previous: f64, next: f64, period: Option<f64>| match period {
288        None => next,
289        Some(period) => {
290            let mut candidate = next;
291            while candidate - previous > period * 0.5 {
292                candidate -= period;
293            }
294            while previous - candidate > period * 0.5 {
295                candidate += period;
296            }
297            candidate
298        }
299    };
300
301    let mut out = Vec::with_capacity(samples.len());
302    let mut at = Point2::new(samples[0].0, samples[0].1);
303    out.push(at);
304    for sample in &samples[1..] {
305        at = Point2::new(
306            fold(at.x, sample.0, u_period),
307            fold(at.y, sample.1, v_period),
308        );
309        out.push(at);
310    }
311    out
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used)]
316mod tests {
317    use super::*;
318    use crate::march::{Marching, branches};
319    use ogeom_geom::{Curve2d, Curve3d, CylinderSurface, PlaneSurface, SphereSurface};
320    use ogeom_math::{Cylinder, Direction, Frame, Plane, Point, Sphere, Vector};
321
322    const T: Tolerances = Tolerances::millimetres();
323
324    fn sphere(radius: f64) -> SurfaceGeometry {
325        SphereSurface::new(Sphere::centred(Point::ORIGIN, radius, T).unwrap()).into()
326    }
327
328    fn cylinder(radius: f64) -> SurfaceGeometry {
329        CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-4.0, 4.0))
330            .unwrap()
331            .into()
332    }
333
334    fn plane(origin: Point, normal: Vector) -> SurfaceGeometry {
335        PlaneSurface::over(
336            Plane::through(origin, Direction::new(normal, T).unwrap()),
337            (-6.0, 6.0),
338            (-6.0, 6.0),
339        )
340        .unwrap()
341        .into()
342    }
343
344    fn options() -> Marching {
345        Marching {
346            chord: 1e-5,
347            ..Marching::default()
348        }
349    }
350
351    /// The distance of a fitted curve from both surfaces, sampled densely.
352    ///
353    /// This is the measure the whole stage exists for: the *fit* (not the
354    /// polyline it came from) is what downstream code holds, so the fit is
355    /// what must lie on both surfaces.
356    fn fitted_deviation(a: &SurfaceGeometry, b: &SurfaceGeometry, curve: &BSplineCurve) -> f64 {
357        let off = |surface: &SurfaceGeometry, p: Point| match surface {
358            SurfaceGeometry::Plane(x) => x.plane().distance_to(p),
359            SurfaceGeometry::Sphere(x) => x.sphere().distance_to(p),
360            SurfaceGeometry::Cylinder(x) => x.cylinder().distance_to(p),
361            _ => 0.0,
362        };
363        let (lo, hi) = curve.knots().domain();
364        let mut worst = 0.0_f64;
365        for i in 0..=800 {
366            #[allow(clippy::cast_precision_loss)]
367            let u = lo + (hi - lo) * f64::from(i) / 800.0;
368            if let Ok(p) = curve.point_at(u, T) {
369                worst = worst.max(off(a, p).abs().max(off(b, p).abs()));
370            }
371        }
372        worst
373    }
374
375    #[test]
376    fn a_fitted_branch_lies_on_both_surfaces_to_the_stated_total() {
377        // The tolerance story end to end: trace within 1e-5, fit within 1e-4,
378        // so the fitted curve is within the sum of the two of the true
379        // intersection, measured against the surfaces, not the polyline.
380        let a = sphere(3.0);
381        let b = cylinder(1.5);
382        let found = branches(&a, &b, options(), T).unwrap();
383        assert_eq!(found.len(), 2);
384
385        for branch in &found {
386            let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
387            assert!(fitted.met, "fit error {:e}", fitted.fit_error);
388            assert!(fitted.closed);
389            let off = fitted_deviation(&a, &b, &fitted.curve);
390            assert!(
391                off <= 1e-4 + 1e-5,
392                "the fitted curve is {off:e} off the surfaces"
393            );
394            // And it is compact: a curve, not a decorated polyline.
395            assert!(
396                fitted.curve.control_points().len() * 4 < branch.points.len(),
397                "{} control points for {} samples",
398                fitted.curve.control_points().len(),
399                branch.points.len()
400            );
401        }
402    }
403
404    #[test]
405    fn the_pcurves_lift_back_onto_the_curve() {
406        // A pcurve is only worth having if evaluating it and lifting through
407        // its surface lands on the intersection. Checked through both
408        // surfaces at matched ends and sampled interiors.
409        let a = sphere(3.0);
410        let b = cylinder(1.5);
411        let found = branches(&a, &b, options(), T).unwrap();
412        let branch = &found[0];
413        let fitted = approximate_branch(&a, &b, branch, 1e-4, T).unwrap();
414
415        for (surface, pcurve) in [(&a, &fitted.on_a), (&b, &fitted.on_b)] {
416            let (lo, hi) = pcurve.domain();
417            for i in 0..=200 {
418                #[allow(clippy::cast_precision_loss)]
419                let u = lo + (hi - lo) * f64::from(i) / 200.0;
420                let at = pcurve.point_at(u, T).unwrap();
421                let lifted = surface.point_at(at.x, at.y, T).unwrap();
422                // The lifted point is on its own surface by construction; what
423                // matters is that it is on the *other* one too, i.e. on the
424                // intersection.
425                let off = match (surface as &SurfaceGeometry, &a, &b) {
426                    _ if core::ptr::eq(surface, &a) => match &b {
427                        SurfaceGeometry::Cylinder(c) => c.cylinder().distance_to(lifted),
428                        _ => 0.0,
429                    },
430                    _ => match &a {
431                        SurfaceGeometry::Sphere(s) => s.sphere().distance_to(lifted),
432                        _ => 0.0,
433                    },
434                };
435                assert!(
436                    off.abs() < 5e-4,
437                    "a lifted pcurve point is {off:e} off the intersection"
438                );
439            }
440        }
441    }
442
443    #[test]
444    fn a_branch_across_the_seam_gets_a_continuous_pcurve() {
445        // A plane through a cylinder's axis at an angle produces an ellipse
446        // whose pcurve crosses the cylinder's u = 0 seam. Folded naively the
447        // pcurve tears by 2π; unwrapped it runs smoothly and leaves the stated
448        // domain, which is what crossing a seam means.
449        let a = cylinder(2.0);
450        let b = plane(Point::ORIGIN, Vector::new(0.0, 0.4, 1.0));
451        let found = branches(&a, &b, options(), T).unwrap();
452        assert_eq!(found.len(), 1, "an oblique plane cuts one ellipse");
453        let fitted = approximate_branch(&a, &b, &found[0], 1e-4, T).unwrap();
454
455        // Continuity: no two adjacent samples of the fitted pcurve jump by
456        // anything near a period.
457        let (lo, hi) = fitted.on_a.domain();
458        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
459        for i in 1..=400 {
460            #[allow(clippy::cast_precision_loss)]
461            let u = lo + (hi - lo) * f64::from(i) / 400.0;
462            let at = fitted.on_a.point_at(u, T).unwrap();
463            assert!(
464                (at.x - previous.x).abs() < 1.0,
465                "the pcurve tears at the seam: {} to {}",
466                previous.x,
467                at.x
468            );
469            previous = at;
470        }
471    }
472
473    /// A loop walked round a converted drum is closed, seam or no seam.
474    ///
475    /// A cylinder converted to a patch is clamped, not periodic: it meets
476    /// itself at its seam. A plane across it cuts a circle the walk reaches
477    /// the seam on from both sides, each half stopping a fraction of a step
478    /// short of it, and the joined branch has coincident ends. Left flagged
479    /// as having left the domain, the arrangement downstream would hold a
480    /// circle with two ends at one point. It is closed, and fitted as a loop
481    /// whose chart image runs continuously across the seam.
482    #[test]
483    fn a_loop_cut_at_a_converted_drum_s_seam_is_closed() {
484        let drum: SurfaceGeometry = cylinder(2.0).to_bspline(T).unwrap().into();
485        assert!(matches!(drum, SurfaceGeometry::BSpline(_)));
486        let cut = plane(Point::new(0.0, 0.0, 1.0), Vector::new(0.0, 0.2, 1.0));
487        let found = branches(&drum, &cut, options(), T).unwrap();
488        assert_eq!(found.len(), 1, "an oblique plane cuts one loop");
489        assert!(found[0].closed(), "the loop closes on the seam");
490        let fitted = approximate_branch(&drum, &cut, &found[0], 1e-4, T).unwrap();
491        assert!(fitted.closed);
492        assert!(
493            fitted.fit_error < 1e-3,
494            "the loop fits as one: {}",
495            fitted.fit_error
496        );
497        let (lo, hi) = fitted.on_a.domain();
498        let mut previous = fitted.on_a.point_at(lo, T).unwrap();
499        for i in 1..=400 {
500            let u = lo + (hi - lo) * f64::from(i) / 400.0;
501            let at = fitted.on_a.point_at(u, T).unwrap();
502            assert!(
503                (at.x - previous.x).abs() < 0.5,
504                "the chart image tears at the seam: {} to {}",
505                previous.x,
506                at.x
507            );
508            previous = at;
509        }
510    }
511
512    #[test]
513    fn what_cannot_be_fitted_is_refused() {
514        let a = sphere(1.0);
515        let b = plane(Point::ORIGIN, Vector::Z);
516        let found = branches(&a, &b, options(), T).unwrap();
517        assert!(approximate_branch(&a, &b, &found[0], 0.0, T).is_err());
518        assert!(approximate_branch(&a, &b, &found[0], -1.0, T).is_err());
519
520        let empty = Traced {
521            points: vec![],
522            on_a: vec![],
523            on_b: vec![],
524            stopped: crate::march::Stopped::Stalled,
525        };
526        assert!(approximate_branch(&a, &b, &empty, 1e-4, T).is_err());
527    }
528}