Skip to main content

ogeom_fillet/
law.rs

1//! Radius laws: how a variable blend's radius runs along its chain.
2//!
3//! A law is read by the fraction of the chain's arc length from its start.
4//! Between given radii it is a cubic Hermite interpolant whose tangents
5//! follow Fritsch and Butland (a weighted harmonic mean of the neighbouring
6//! secants, zero where they change sign), so each piece stays between the
7//! radii at its two ends: the law never overshoots its data, and a law of
8//! positive radii is positive everywhere.
9
10use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
11use ogeom_geom::{Curve, Curve3d as _};
12
13/// How a variable-radius blend's radius runs along its chain of edges, by
14/// the fraction of the chain's arc length from the chain's start.
15#[derive(Debug, Clone, PartialEq)]
16pub enum RadiusLaw {
17    /// From the chain's start to its end, linearly in arc length.
18    Linear {
19        /// The radius at the chain's start.
20        start: f64,
21        /// The radius at its end.
22        end: f64,
23    },
24    /// Radii at fractions of the chain's arc length (`0..=1`, increasing),
25    /// interpolated smoothly (C1) between them without overshooting them;
26    /// held level before the first and after the last on an open chain,
27    /// and wrapped round on a closed one.
28    Points(Vec<(f64, f64)>),
29}
30
31/// A validated law, as cubic Hermite pieces over the fractions.
32#[derive(Debug, Clone)]
33pub(crate) struct Profile {
34    /// The fractions the radii stand at, increasing.
35    at: Vec<f64>,
36    /// The radii.
37    radius: Vec<f64>,
38    /// The law's slope at each, per unit fraction.
39    slope: Vec<f64>,
40    /// Whether the chain closes: the law runs on past the last fraction
41    /// round to the first.
42    closed: bool,
43}
44
45impl Profile {
46    /// The law on a chain that is `closed` or not, refused where it states
47    /// no usable radius.
48    pub(crate) fn new(law: &RadiusLaw, closed: bool, tol: Tolerances) -> OgeomResult<Self> {
49        let usable = |r: f64| r.is_finite() && r > tol.confusion();
50        let mut points: Vec<(f64, f64)> = match law {
51            RadiusLaw::Linear { start, end } => {
52                if closed && (start - end).abs() > tol.confusion() {
53                    ogeom_bail!(
54                        Construction,
55                        "a linear law from {start} to {end} jumps where a closed \
56                         chain meets its start; give its radii as points"
57                    );
58                }
59                vec![(0.0, *start), (1.0, *end)]
60            }
61            RadiusLaw::Points(points) => points.clone(),
62        };
63        if points.is_empty() {
64            ogeom_bail!(Construction, "a radius law of no points states no radius");
65        }
66        for &(s, r) in &points {
67            if !s.is_finite() || !(0.0..=1.0).contains(&s) {
68                ogeom_bail!(
69                    Construction,
70                    "a radius law's fraction {s} lies off its chain (0 to 1)"
71                );
72            }
73            if !usable(r) {
74                ogeom_bail!(Construction, "a fillet of radius {r} rounds nothing");
75            }
76        }
77        if points.windows(2).any(|w| w[1].0 <= w[0].0) {
78            ogeom_bail!(
79                Construction,
80                "a radius law's fractions must increase along the chain"
81            );
82        }
83        // On a closed chain the end is the start: a radius given at both
84        // is one radius, given twice.
85        if closed && points.len() > 1 {
86            let (first, last) = (points[0], points[points.len() - 1]);
87            if first.0 <= tol.parametric() && last.0 >= 1.0 - tol.parametric() {
88                if (first.1 - last.1).abs() > tol.confusion() {
89                    ogeom_bail!(
90                        Construction,
91                        "a closed chain's law gives {} at its start and {} at \
92                         its end, which are one point",
93                        first.1,
94                        last.1
95                    );
96                }
97                points.pop();
98            }
99        }
100        let at: Vec<f64> = points.iter().map(|p| p.0).collect();
101        let radius: Vec<f64> = points.iter().map(|p| p.1).collect();
102        let n = at.len();
103        let mut slope = vec![0.0; n];
104        if n > 1 {
105            // The secant and span from point `i` to the next, wrapping
106            // round a closed chain.
107            let span = |i: usize| -> f64 {
108                if i + 1 < n {
109                    at[i + 1] - at[i]
110                } else {
111                    at[0] + 1.0 - at[i]
112                }
113            };
114            let secant = |i: usize| -> f64 { (radius[(i + 1) % n] - radius[i]) / span(i) };
115            for (i, m) in slope.iter_mut().enumerate() {
116                let inner = closed || (i > 0 && i + 1 < n);
117                if inner {
118                    let before = if i == 0 { n - 1 } else { i - 1 };
119                    let (h0, h1) = (span(before), span(i));
120                    let (d0, d1) = (secant(before), secant(i));
121                    *m = if d0 * d1 > 0.0 {
122                        let (w0, w1) = (2.0f64.mul_add(h1, h0), 2.0f64.mul_add(h0, h1));
123                        (w0 + w1) / (w0 / d0 + w1 / d1)
124                    } else {
125                        0.0
126                    };
127                } else {
128                    // An open chain's first or last point: the secant to
129                    // its neighbour where the point is the chain's end, and
130                    // level where the law is held level beyond it.
131                    let (end, d) = if i == 0 {
132                        (at[0] <= tol.parametric(), secant(0))
133                    } else {
134                        (at[i] >= 1.0 - tol.parametric(), secant(i - 1))
135                    };
136                    *m = if end { d } else { 0.0 };
137                }
138            }
139        }
140        Ok(Self {
141            at,
142            radius,
143            slope,
144            closed,
145        })
146    }
147
148    /// The radius and its slope per unit fraction at fraction `s`.
149    pub(crate) fn at(&self, s: f64) -> (f64, f64) {
150        let n = self.at.len();
151        if n == 1 {
152            return (self.radius[0], 0.0);
153        }
154        let hermite = |i: usize, j: usize, x0: f64, h: f64, s: f64| -> (f64, f64) {
155            let t = (s - x0) / h;
156            let (y0, y1) = (self.radius[i], self.radius[j]);
157            let (m0, m1) = (self.slope[i] * h, self.slope[j] * h);
158            let t2 = t * t;
159            let t3 = t2 * t;
160            let value = (2.0 * t3 - 3.0 * t2 + 1.0) * y0
161                + (t3 - 2.0 * t2 + t) * m0
162                + (3.0 * t2 - 2.0 * t3) * y1
163                + (t3 - t2) * m1;
164            let rate = (6.0 * t2 - 6.0 * t) * (y0 - y1)
165                + (3.0 * t2 - 4.0 * t + 1.0) * m0
166                + (3.0 * t2 - 2.0 * t) * m1;
167            (value, rate / h)
168        };
169        let s = if self.closed { s.rem_euclid(1.0) } else { s };
170        if self.closed {
171            let (first, last) = (self.at[0], self.at[n - 1]);
172            if s < first || s >= last {
173                let x0 = last;
174                let h = first + 1.0 - last;
175                let s = if s < first { s + 1.0 } else { s };
176                return hermite(n - 1, 0, x0, h, s);
177            }
178        } else {
179            if s < self.at[0] {
180                return (self.radius[0], 0.0);
181            }
182            if s > self.at[n - 1] {
183                return (self.radius[n - 1], 0.0);
184            }
185        }
186        let i = self
187            .at
188            .partition_point(|&x| x <= s)
189            .saturating_sub(1)
190            .min(n - 2);
191        hermite(i, i + 1, self.at[i], self.at[i + 1] - self.at[i], s)
192    }
193
194    /// The law over one stretch of its chain, `from` to `to` (the
195    /// stretch's start and end by fraction, `to` below `from` where the
196    /// stretch runs against the chain), read by the fraction of the
197    /// stretch: an open law of the same radii. Each Hermite piece is
198    /// restricted with its value and slope at the stretch's ends, so the
199    /// window is the law itself there, not a fresh interpolation.
200    pub(crate) fn window(&self, from: f64, to: f64) -> Self {
201        let span = to - from;
202        let (lo, hi) = (from.min(to), from.max(to));
203        let mut nodes: Vec<(f64, f64, f64)> = Vec::new();
204        let mut push = |s: f64| {
205            let (r, m) = self.at(s);
206            nodes.push(((s - from) / span, r, m * span));
207        };
208        push(from);
209        push(to);
210        for &s in &self.at {
211            if s > lo && s < hi {
212                push(s);
213            }
214        }
215        nodes.sort_by(|a, b| a.0.total_cmp(&b.0));
216        Self {
217            at: nodes.iter().map(|n| n.0).collect(),
218            radius: nodes.iter().map(|n| n.1).collect(),
219            slope: nodes.iter().map(|n| n.2).collect(),
220            closed: false,
221        }
222    }
223
224    /// The law over a stretch reaching `before` past its start and `after`
225    /// past its end, as fractions of its own length, held level over each:
226    /// the law past an edge's end. An open law only.
227    pub(crate) fn stretched(&self, before: f64, after: f64) -> Self {
228        let whole = 1.0 + before + after;
229        Self {
230            at: self.at.iter().map(|&s| (s + before) / whole).collect(),
231            radius: self.radius.clone(),
232            slope: self.slope.iter().map(|&m| m * whole).collect(),
233            closed: false,
234        }
235    }
236
237    /// The largest radius the law reaches: at a given point, since no
238    /// piece overshoots its ends.
239    pub(crate) fn largest(&self) -> f64 {
240        self.radius
241            .iter()
242            .copied()
243            .fold(f64::NEG_INFINITY, f64::max)
244    }
245
246    /// The smallest.
247    pub(crate) fn least(&self) -> f64 {
248        self.radius.iter().copied().fold(f64::INFINITY, f64::min)
249    }
250
251    /// Whether the law was laid on a chain that closes.
252    pub(crate) const fn closed(&self) -> bool {
253        self.closed
254    }
255
256    /// Whether the law holds one radius all along.
257    pub(crate) fn level(&self, tol: Tolerances) -> bool {
258        self.largest() - self.least() <= tol.confusion()
259    }
260
261    /// The open law's cubic pieces over the whole fraction, each its
262    /// Hermite cubic's Bezier form: from where to where, and the four
263    /// control values. The level runs before the first point and after
264    /// the last.
265    pub(crate) fn pieces(&self, tol: Tolerances) -> Vec<(f64, f64, [f64; 4])> {
266        let mut pieces: Vec<(f64, f64, [f64; 4])> = Vec::new();
267        let n = self.at.len();
268        let level = |x0: f64, x1: f64, r: f64| (x0, x1, [r, r, r, r]);
269        if self.at[0] > tol.parametric() {
270            pieces.push(level(0.0, self.at[0], self.radius[0]));
271        }
272        for i in 0..n.saturating_sub(1) {
273            let (x0, x1) = (self.at[i], self.at[i + 1]);
274            let h = x1 - x0;
275            let (y0, y1) = (self.radius[i], self.radius[i + 1]);
276            pieces.push((
277                x0,
278                x1,
279                [
280                    y0,
281                    self.slope[i].mul_add(h / 3.0, y0),
282                    (-self.slope[i + 1]).mul_add(h / 3.0, y1),
283                    y1,
284                ],
285            ));
286        }
287        if self.at[n - 1] < 1.0 - tol.parametric() {
288            pieces.push(level(self.at[n - 1], 1.0, self.radius[n - 1]));
289        }
290        if pieces.is_empty() {
291            pieces.push(level(0.0, 1.0, self.radius[0]));
292        }
293        // The chain's own ends stand at 0 and 1 exactly.
294        let last = pieces.len() - 1;
295        pieces[0].0 = 0.0;
296        pieces[last].1 = 1.0;
297        pieces
298    }
299
300    /// The open law as a cubic B-spline over the fraction: the knots,
301    /// doubled inside (C1) or tripled where the slope steps (C0), and the
302    /// radii's control values. Each piece is
303    /// its Hermite cubic's own Bezier form, and the C1 join is the point
304    /// knot removal drops.
305    pub(crate) fn spline(&self, tol: Tolerances) -> (Vec<f64>, Vec<f64>) {
306        let pieces = self.pieces(tol);
307        let last = pieces.len() - 1;
308        let mut knots = vec![0.0; 4];
309        let mut values = vec![pieces[0].2[0]];
310        for (k, &(x0, x1, bezier)) in pieces.iter().enumerate() {
311            values.push(bezier[1]);
312            values.push(bezier[2]);
313            if k == last {
314                values.push(bezier[3]);
315                knots.extend([1.0; 4]);
316                continue;
317            }
318            // A join where the slope steps (a law held level past an
319            // edge's end beside a piece that is not) keeps its own value
320            // under a triple knot; elsewhere the doubled knot drops it.
321            let (y0, y1, next) = (x0, x1, pieces[k + 1]);
322            let before = (bezier[3] - bezier[2]) / (y1 - y0);
323            let after = (next.2[1] - next.2[0]) / (next.1 - next.0);
324            if (before - after).abs() > tol.parametric() {
325                values.push(bezier[3]);
326                knots.extend([x1; 3]);
327            } else {
328                knots.extend([x1, x1]);
329            }
330        }
331        (knots, values)
332    }
333}
334
335/// How many pieces a curve's arc length is tabulated over.
336const RULER_PIECES: u32 = 256;
337
338/// Gauss-Legendre nodes and weights on `[-1, 1]`, five points: exact for
339/// polynomials to degree nine, so a piece of a smooth curve's speed is
340/// integrated to rounding.
341const GAUSS: [(f64, f64); 5] = [
342    (0.0, 0.568_888_888_888_888_9),
343    (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
344    (0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
345    (-0.906_179_845_938_664, 0.236_926_885_056_189_1),
346    (0.906_179_845_938_664, 0.236_926_885_056_189_1),
347];
348
349/// A curve's arc length from the start of a range, tabulated, read back
350/// as the fraction of the whole run.
351#[derive(Debug, Clone)]
352pub(crate) struct Ruler {
353    curve: Curve,
354    range: (f64, f64),
355    /// The length up to each piece's start, and the whole at the end.
356    cumulative: Vec<f64>,
357    closed: bool,
358}
359
360impl Ruler {
361    pub(crate) fn new(
362        curve: Curve,
363        range: (f64, f64),
364        closed: bool,
365        tol: Tolerances,
366    ) -> OgeomResult<Self> {
367        let mut cumulative: Vec<f64> = Vec::with_capacity(RULER_PIECES as usize + 1);
368        cumulative.push(0.0);
369        let step = (range.1 - range.0) / f64::from(RULER_PIECES);
370        let mut whole = 0.0;
371        for k in 0..RULER_PIECES {
372            let a = f64::from(k).mul_add(step, range.0);
373            whole += speed_integral(&curve, a, a + step, tol)?;
374            cumulative.push(whole);
375        }
376        if whole <= tol.confusion() {
377            ogeom_bail!(Construction, "a chain of no length carries no radius law");
378        }
379        Ok(Self {
380            curve,
381            range,
382            cumulative,
383            closed,
384        })
385    }
386
387    /// The whole run's length.
388    pub(crate) fn length(&self) -> f64 {
389        self.cumulative[self.cumulative.len() - 1]
390    }
391
392    /// The fraction of the run's length up to parameter `t`, and its rate
393    /// per unit parameter.
394    pub(crate) fn fraction(&self, t: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
395        let (lo, hi) = self.range;
396        let span = hi - lo;
397        let (t, turns) = if self.closed {
398            let turns = ((t - lo) / span).floor();
399            (turns.mul_add(-span, t), turns)
400        } else {
401            (t.clamp(lo, hi), 0.0)
402        };
403        let step = span / f64::from(RULER_PIECES);
404        #[allow(
405            clippy::cast_possible_truncation,
406            clippy::cast_sign_loss,
407            reason = "a clamped piece index"
408        )]
409        let k = (((t - lo) / step).floor().max(0.0) as u32).min(RULER_PIECES - 1);
410        let a = f64::from(k).mul_add(step, lo);
411        let length = self.cumulative[k as usize] + speed_integral(&self.curve, a, t, tol)?;
412        let speed = self.curve.d1_at(t, tol)?.magnitude();
413        let whole = self.length();
414        Ok((length / whole + turns, speed / whole))
415    }
416}
417
418/// The curve's length from `a` to `b`, by five-point Gauss-Legendre.
419fn speed_integral(curve: &Curve, a: f64, b: f64, tol: Tolerances) -> OgeomResult<f64> {
420    let (mid, half) = (f64::midpoint(a, b), (b - a) / 2.0);
421    let mut sum = 0.0;
422    for (x, w) in GAUSS {
423        sum += w * curve.d1_at(half.mul_add(x, mid), tol)?.magnitude();
424    }
425    Ok(sum * half)
426}
427
428/// A law laid along the curve a march walks: the radius at the curve's
429/// parameter, and its rate per unit parameter, for the marcher's
430/// Jacobian.
431#[derive(Debug, Clone)]
432pub(crate) struct RadiusAlong {
433    profile: Profile,
434    ruler: Ruler,
435    /// The fraction of the curve's run at the chain's start.
436    origin: f64,
437    /// Whether the chain runs against the curve's parameter.
438    reversed: bool,
439}
440
441impl RadiusAlong {
442    pub(crate) const fn new(profile: Profile, ruler: Ruler, origin: f64, reversed: bool) -> Self {
443        Self {
444            profile,
445            ruler,
446            origin,
447            reversed,
448        }
449    }
450
451    /// The radius at the curve's parameter `t`, and its rate per unit `t`.
452    pub(crate) fn at(&self, t: f64, tol: Tolerances) -> OgeomResult<(f64, f64)> {
453        let (s, ds) = self.ruler.fraction(t, tol)?;
454        let s = s - self.origin;
455        let (s, ds) = if self.reversed { (-s, -ds) } else { (s, ds) };
456        let (r, dr) = self.profile.at(s);
457        Ok((r, dr * ds))
458    }
459
460    /// The law's own radii, smallest and largest.
461    pub(crate) fn least(&self) -> f64 {
462        self.profile.least()
463    }
464
465    pub(crate) fn largest(&self) -> f64 {
466        self.profile.largest()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    #![allow(clippy::unwrap_used, reason = "test code")]
473    use super::{Profile, RadiusLaw};
474    use ogeom_core::Tolerances;
475
476    /// A window is the law itself over its stretch, read by the stretch's
477    /// own fraction: forward, against the chain, and up to a closed chain's
478    /// end, where the law wraps.
479    #[test]
480    fn a_window_is_the_law_over_its_stretch() {
481        let tol = Tolerances::millimetres();
482        let law = RadiusLaw::Points(vec![(0.1, 0.5), (0.4, 1.5), (0.7, 0.8)]);
483        let closed = Profile::new(&law, true, tol).unwrap();
484        for (from, to) in [(0.05, 0.55), (0.9, 0.3), (0.65, 1.0), (0.2, 0.25)] {
485            let window = closed.window(from, to);
486            for i in 0..=100 {
487                let u = f64::from(i) / 100.0;
488                let (r, m) = window.at(u);
489                let (want, rate) = closed.at(from + (to - from) * u);
490                assert!(
491                    (r - want).abs() < 1e-12,
492                    "{from}..{to} at {u}: {r} against {want}"
493                );
494                assert!(
495                    (m - rate * (to - from)).abs() < 1e-9,
496                    "{from}..{to} at {u}: slope {m} against {}",
497                    rate * (to - from)
498                );
499            }
500        }
501    }
502}