Skip to main content

ogeom_algo/
traced.rs

1//! Curves fitted through a trace at its own parameters and held to their
2//! tolerance between the samples.
3//!
4//! A fit's own error is measured at the points it was fitted through. A
5//! trace with more detail than the samples (a many-span spline, a polyline
6//! with more corners than samples) leaves a fit that meets its target at
7//! every sample and misses the trace between them. These fits sample a
8//! source curve at its own breaks, check each interval between samples
9//! at its eighths, split the intervals that miss, and report the worst
10//! distance measured anywhere, not the worst at the samples. The fits are
11//! [`ogeom_geom::fit::fit_curve_sampled`] and its planar twin; this module
12//! chooses where they start.
13
14use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
15use ogeom_geom::fit::Fitted;
16use ogeom_geom::{BSpline2d, BSplineCurve, Curve, Curve2d as _, Curve3d as _, PlanarCurve};
17use ogeom_math::{KnotVector, Point, Point2};
18
19/// The most intervals a trace is sampled at, as many as a sampled fit
20/// refines to.
21const MOST: usize = ogeom_geom::fit::SAMPLED_SPANS;
22
23/// The parameters to start a fit of `curve` over `range` from: each span of
24/// the curve (between its distinct knots) cut in `degree + 1` pieces, and
25/// at least `at_least` pieces overall.
26#[must_use]
27pub fn stations(curve: &Curve, range: (f64, f64), at_least: usize) -> Vec<f64> {
28    let (breaks, degree) = breaks_3d(curve);
29    spread(range, &breaks, degree, at_least)
30}
31
32/// As [`stations`], for a planar curve.
33#[must_use]
34pub fn stations_2d(curve: &PlanarCurve, range: (f64, f64), at_least: usize) -> Vec<f64> {
35    let (breaks, degree) = breaks_2d(curve);
36    spread(range, &breaks, degree, at_least)
37}
38
39/// As [`stations`], for a range cut at `breaks` into pieces of `degree`:
40/// a patch's direction between its distinct knots, say.
41#[must_use]
42pub fn stations_between(
43    range: (f64, f64),
44    breaks: &[f64],
45    degree: usize,
46    at_least: usize,
47) -> Vec<f64> {
48    spread(range, breaks, degree, at_least)
49}
50
51/// Where a space curve's pieces join, and the degree of its pieces.
52fn breaks_3d(curve: &Curve) -> (Vec<f64>, usize) {
53    match curve {
54        Curve::BSpline(spline) => (
55            spline
56                .knots()
57                .distinct()
58                .into_iter()
59                .map(|(value, _)| value)
60                .collect(),
61            spline.knots().degree(),
62        ),
63        Curve::Trimmed(trim) => {
64            let (lo, hi) = trim.domain();
65            let (inner, degree) = breaks_3d(trim.basis());
66            if trim.is_reversed() {
67                (inner.into_iter().map(|b| lo + hi - b).collect(), degree)
68            } else {
69                (inner, degree)
70            }
71        }
72        Curve::Offset(offset) => breaks_3d(offset.basis()),
73        Curve::OnSurface(on) => breaks_2d(on.pcurve()),
74        _ => (Vec::new(), 3),
75    }
76}
77
78/// Where a planar curve's pieces join, and the degree of its pieces.
79fn breaks_2d(curve: &PlanarCurve) -> (Vec<f64>, usize) {
80    match curve {
81        PlanarCurve::BSpline(spline) => (
82            spline
83                .knots()
84                .distinct()
85                .into_iter()
86                .map(|(value, _)| value)
87                .collect(),
88            spline.knots().degree(),
89        ),
90        PlanarCurve::Trimmed(trim) => {
91            let (lo, hi) = trim.domain();
92            let (inner, degree) = breaks_2d(trim.basis());
93            if trim.is_reversed() {
94                (inner.into_iter().map(|b| lo + hi - b).collect(), degree)
95            } else {
96                (inner, degree)
97            }
98        }
99        PlanarCurve::Offset(offset) => breaks_2d(offset.basis()),
100        _ => (Vec::new(), 3),
101    }
102}
103
104/// `range` cut at the `breaks` inside it, each piece in `degree + 1`, and
105/// evened out to at least `at_least` pieces; never more than [`MOST`].
106fn spread(range: (f64, f64), breaks: &[f64], degree: usize, at_least: usize) -> Vec<f64> {
107    let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
108    let uniform = |n: usize| -> Vec<f64> {
109        (0..=n)
110            .map(|i| {
111                #[allow(clippy::cast_precision_loss, reason = "a sample index")]
112                let f = i as f64 / n as f64;
113                if i == n { hi } else { lo + (hi - lo) * f }
114            })
115            .collect()
116    };
117    let at_least = at_least.clamp(1, MOST);
118    if hi - lo <= 0.0 {
119        return vec![lo, hi];
120    }
121    let mut cuts = vec![lo];
122    cuts.extend(
123        breaks
124            .iter()
125            .copied()
126            .filter(|&b| b > lo + (hi - lo) * 1e-9 && b < hi - (hi - lo) * 1e-9),
127    );
128    cuts.push(hi);
129    let pieces = (degree + 1).max(2);
130    if (cuts.len() - 1) * pieces > MOST {
131        return uniform(MOST);
132    }
133    let mut out = Vec::with_capacity((cuts.len() - 1) * pieces + 1);
134    for pair in cuts.windows(2) {
135        for k in 0..pieces {
136            #[allow(clippy::cast_precision_loss, reason = "a small count")]
137            let f = k as f64 / pieces as f64;
138            out.push(pair[0] + (pair[1] - pair[0]) * f);
139        }
140    }
141    out.push(hi);
142    if out.len() > at_least {
143        return out;
144    }
145    // Too few: every piece cut evenly until there are enough.
146    let per = (at_least).div_ceil(out.len() - 1);
147    let mut finer = Vec::with_capacity((out.len() - 1) * per + 1);
148    for pair in out.windows(2) {
149        for k in 0..per {
150            #[allow(clippy::cast_precision_loss, reason = "a small count")]
151            let f = k as f64 / per as f64;
152            finer.push(pair[0] + (pair[1] - pair[0]) * f);
153        }
154    }
155    finer.push(hi);
156    finer
157}
158
159/// Where a planar spline over `range` turns a corner: its interior knots
160/// of multiplicity at least its degree, inside `range`.
161#[must_use]
162pub fn corners_2d(curve: &BSpline2d, range: (f64, f64)) -> Vec<f64> {
163    let knots = curve.knots();
164    let (lo, hi) = (range.0.min(range.1), range.0.max(range.1));
165    knots
166        .distinct()
167        .into_iter()
168        .filter(|&(value, mult)| mult >= knots.degree() && value > lo && value < hi)
169        .map(|(value, _)| value)
170        .collect()
171}
172
173/// Polynomial splines of one degree, each clamped and each starting where
174/// the one before ends in parameter, joined end to end into one spline
175/// that is continuous at the joins and no smoother. Each join takes the
176/// middle of the two ends that meet there; the second value is how far
177/// that moved either end.
178///
179/// # Errors
180///
181/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
182/// there are no pieces, they differ in degree, one is rational, or their
183/// parameter ranges do not abut.
184pub fn joined(pieces: &[BSplineCurve], tol: Tolerances) -> OgeomResult<(BSplineCurve, f64)> {
185    let Some(first) = pieces.first() else {
186        ogeom_bail!(Construction, "nothing to join");
187    };
188    let degree = first.degree();
189    let mut knots: Vec<f64> = Vec::new();
190    let mut control: Vec<Point> = Vec::new();
191    let mut moved = 0.0_f64;
192    for (i, piece) in pieces.iter().enumerate() {
193        if piece.degree() != degree || piece.is_rational() {
194            ogeom_bail!(Construction, "only polynomial pieces of one degree join");
195        }
196        let own = piece.knots().knots();
197        let points: Vec<Point> = piece.control_points().iter().map(|c| c.point()).collect();
198        if own.len() < 2 * (degree + 1) || points.is_empty() {
199            ogeom_bail!(Construction, "a piece too short to join");
200        }
201        if i == 0 {
202            knots.extend_from_slice(&own[..own.len() - 1]);
203            control.extend(points);
204            continue;
205        }
206        let at = own[0];
207        let held = knots[knots.len() - 1];
208        if (at - held).abs() > tol.parametric().max(held.abs() * 1e-12) {
209            ogeom_bail!(Construction, "pieces to join must abut in parameter");
210        }
211        let last = control.len() - 1;
212        let gap = control[last].distance(points[0]);
213        moved = moved.max(gap * 0.5);
214        control[last] = control[last].midpoint(points[0]);
215        // The join keeps `degree` copies of its knot; the piece's first
216        // knot copies past the clamp's are dropped with its first point.
217        knots.extend_from_slice(&own[degree + 1..own.len() - 1]);
218        control.extend(points.into_iter().skip(1));
219    }
220    let end = pieces[pieces.len() - 1].knots().knots();
221    knots.push(end[end.len() - 1]);
222    let curve = BSplineCurve::new(KnotVector::new(knots, degree)?, control, tol)?;
223    Ok((curve, moved))
224}
225
226/// A space curve fitted to `trace` at its own parameters, starting from the
227/// samples at `stations` (strictly increasing): the open case of
228/// [`ogeom_geom::fit::fit_curve_sampled`]. Each interval is measured at its
229/// eighths as well as at the samples, and the intervals that miss
230/// `tolerance` are split until it holds everywhere measured or the samples
231/// reach [`ogeom_geom::fit::SAMPLED_SPANS`] intervals. The best fit either
232/// way, its `error` the worst distance measured and `met` whether that is
233/// within `tolerance`.
234///
235/// # Errors
236///
237/// As `trace`, and as [`ogeom_geom::fit::fit_curve_sampled`].
238pub fn fit_traced(
239    trace: impl FnMut(f64) -> OgeomResult<Point>,
240    stations: &[f64],
241    degree: usize,
242    tolerance: f64,
243    tol: Tolerances,
244) -> OgeomResult<Fitted<BSplineCurve>> {
245    ogeom_geom::fit::fit_curve_sampled(trace, stations, false, degree, tolerance, tol)
246}
247
248/// As [`fit_traced`], in the plane: a chart image fitted at the parameters
249/// of the curve it annotates. The distances are in the trace's own units.
250///
251/// # Errors
252///
253/// As `trace`, and as [`ogeom_geom::fit::fit_curve_2d_sampled`].
254pub fn fit_traced_2d(
255    trace: impl FnMut(f64) -> OgeomResult<Point2>,
256    stations: &[f64],
257    degree: usize,
258    tolerance: f64,
259    tol: Tolerances,
260) -> OgeomResult<Fitted<BSpline2d>> {
261    ogeom_geom::fit::fit_curve_2d_sampled(trace, stations, false, degree, tolerance, tol)
262}
263
264#[cfg(test)]
265mod tests {
266    #![allow(clippy::unwrap_used, reason = "test code")]
267    use super::*;
268
269    const T: Tolerances = Tolerances::millimetres();
270
271    /// A trace weaving faster than the starting samples is fitted to its
272    /// tolerance everywhere, and a fit that cannot reach it says by how
273    /// much at the worst point measured, not at its samples.
274    #[test]
275    fn a_traced_fit_holds_between_its_samples() {
276        let trace =
277            |t: f64| -> OgeomResult<Point> { Ok(Point::new(t, 0.05 * (t * 173.0).sin(), 0.0)) };
278        let start: Vec<f64> = (0..=8).map(|i| f64::from(i) / 8.0).collect();
279        let fitted = fit_traced(trace, &start, 3, 1e-4, T).unwrap();
280        assert!(fitted.met, "{}", fitted.error);
281        let mut worst = 0.0_f64;
282        for i in 0..=5000 {
283            let t = f64::from(i) / 5000.0;
284            let p = fitted.curve.point_at(t, T).unwrap();
285            worst = worst.max(p.distance(trace(t).unwrap()));
286        }
287        assert!(worst <= 1e-4, "{worst}");
288    }
289
290    /// A many-span spline is sampled at every span, not at a fixed count.
291    #[test]
292    fn stations_follow_the_spans() {
293        let control: Vec<Point> = (0..67)
294            .map(|i| Point::new(f64::from(i), f64::from(i % 2), 0.0))
295            .collect();
296        let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
297        let curve = Curve::BSpline(BSplineCurve::new(knots, control, T).unwrap());
298        let s = stations(&curve, (0.0, 1.0), 33);
299        assert_eq!(s.len(), 64 * 4 + 1);
300        assert!(s.windows(2).all(|w| w[1] > w[0]));
301        let few = stations(&curve, (0.25, 0.5), 33);
302        assert!(few.len() >= 34 && few[0] == 0.25 && few[few.len() - 1] == 0.5);
303    }
304}