fj_kernel/algorithms/approx/
path.rs

1//! # Path approximation
2//!
3//! Since paths are infinite (even circles have an infinite coordinate space,
4//! even though they connect to themselves in global coordinates), a range must
5//! be provided to approximate them. The approximation then returns points
6//! within that range.
7//!
8//! The boundaries of the range are not included in the approximation. This is
9//! done, to give the caller (who knows the boundary anyway) more options on how
10//! to further process the approximation.
11//!
12//! ## Determinism
13//!
14//! Path approximation is carefully designed to produce a deterministic result
15//! for the combination of a given path and a given tolerance, regardless of
16//! what the range is. This is done to prevent invalid meshes from being
17//! generated.
18//!
19//! In specific terms, this means there is an infinite set of points that
20//! approximates a path, and that set is deterministic for a given combination
21//! of path and tolerance. The range that defines where the path is approximated
22//! only influences the result in two ways:
23//!
24//! 1. It controls which points from the infinite set are actually computed.
25//! 2. It defines the order in which the computed points are returned.
26//!
27//! As a result, path approximation is guaranteed to generate points that can
28//! fit together in a valid mesh, no matter which ranges of a path are being
29//! approximated, and how many times.
30
31use std::iter;
32
33use fj_math::{Circle, Point, Scalar, Sign};
34
35use crate::geometry::curve::{Curve, GlobalPath};
36
37use super::{Approx, Tolerance};
38
39impl Approx for (&Curve, RangeOnPath) {
40    type Approximation = Vec<(Point<1>, Point<2>)>;
41    type Cache = ();
42
43    fn approx_with_cache(
44        self,
45        tolerance: impl Into<Tolerance>,
46        (): &mut Self::Cache,
47    ) -> Self::Approximation {
48        let (path, range) = self;
49
50        match path {
51            Curve::Circle(circle) => {
52                approx_circle(circle, range, tolerance.into())
53            }
54            Curve::Line(_) => vec![],
55        }
56    }
57}
58
59impl Approx for (GlobalPath, RangeOnPath) {
60    type Approximation = Vec<(Point<1>, Point<3>)>;
61    type Cache = ();
62
63    fn approx_with_cache(
64        self,
65        tolerance: impl Into<Tolerance>,
66        (): &mut Self::Cache,
67    ) -> Self::Approximation {
68        let (path, range) = self;
69
70        match path {
71            GlobalPath::Circle(circle) => {
72                approx_circle(&circle, range, tolerance.into())
73            }
74            GlobalPath::Line(_) => vec![],
75        }
76    }
77}
78
79/// The range on which a path should be approximated
80#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
81pub struct RangeOnPath {
82    /// The boundary of the range
83    pub boundary: [Point<1>; 2],
84}
85
86impl RangeOnPath {
87    /// Reverse the direction of the range
88    pub fn reverse(self) -> Self {
89        let [a, b] = self.boundary;
90        Self { boundary: [b, a] }
91    }
92}
93
94impl<T> From<[T; 2]> for RangeOnPath
95where
96    T: Into<Point<1>>,
97{
98    fn from(boundary: [T; 2]) -> Self {
99        let boundary = boundary.map(Into::into);
100        Self { boundary }
101    }
102}
103
104/// Approximate a circle
105///
106/// `tolerance` specifies how much the approximation is allowed to deviate
107/// from the circle.
108fn approx_circle<const D: usize>(
109    circle: &Circle<D>,
110    range: impl Into<RangeOnPath>,
111    tolerance: Tolerance,
112) -> Vec<(Point<1>, Point<D>)> {
113    let range = range.into();
114
115    let params = PathApproxParams::for_circle(circle, tolerance);
116    let mut points = Vec::new();
117
118    for point_curve in params.points(range) {
119        let point_global = circle.point_from_circle_coords(point_curve);
120        points.push((point_curve, point_global));
121    }
122
123    points
124}
125
126struct PathApproxParams {
127    increment: Scalar,
128}
129
130impl PathApproxParams {
131    pub fn for_circle<const D: usize>(
132        circle: &Circle<D>,
133        tolerance: impl Into<Tolerance>,
134    ) -> Self {
135        let radius = circle.a().magnitude();
136
137        let num_vertices_to_approx_full_circle = Scalar::max(
138            Scalar::PI
139                / (Scalar::ONE - (tolerance.into().inner() / radius)).acos(),
140            3.,
141        )
142        .ceil();
143
144        let increment = Scalar::TAU / num_vertices_to_approx_full_circle;
145
146        Self { increment }
147    }
148
149    pub fn increment(&self) -> Scalar {
150        self.increment
151    }
152
153    pub fn points(
154        &self,
155        range: impl Into<RangeOnPath>,
156    ) -> impl Iterator<Item = Point<1>> + '_ {
157        let range = range.into();
158
159        let [a, b] = range.boundary.map(|point| point.t / self.increment());
160        let direction = (b - a).sign();
161        let [min, max] = if a < b { [a, b] } else { [b, a] };
162
163        // We can't generate a point exactly at the boundaries of the range as
164        // part of the approximation. Make sure we stay inside the range.
165        let min = min.floor() + 1.;
166        let max = max.ceil() - 1.;
167
168        let [start, end] = match direction {
169            Sign::Negative => [max, min],
170            Sign::Positive | Sign::Zero => [min, max],
171        };
172
173        let mut i = start;
174        iter::from_fn(move || {
175            let is_finished = match direction {
176                Sign::Negative => i < end,
177                Sign::Positive | Sign::Zero => i > end,
178            };
179
180            if is_finished {
181                return None;
182            }
183
184            let t = self.increment() * i;
185            i += direction.to_scalar();
186
187            Some(Point::from([t]))
188        })
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use std::f64::consts::TAU;
195
196    use fj_math::{Circle, Point, Scalar};
197
198    use crate::algorithms::approx::{path::RangeOnPath, Tolerance};
199
200    use super::PathApproxParams;
201
202    #[test]
203    fn increment_for_circle() {
204        test_increment(1., 0.5, 3.);
205        test_increment(1., 0.1, 7.);
206        test_increment(1., 0.01, 23.);
207
208        fn test_increment(
209            radius: impl Into<Scalar>,
210            tolerance: impl Into<Tolerance>,
211            expected_num_vertices: impl Into<Scalar>,
212        ) {
213            let circle = Circle::from_center_and_radius([0., 0.], radius);
214            let params = PathApproxParams::for_circle(&circle, tolerance);
215
216            let expected_increment = Scalar::TAU / expected_num_vertices;
217            assert_eq!(params.increment(), expected_increment);
218        }
219    }
220
221    #[test]
222    fn points_for_circle() {
223        // At the chosen values for radius and tolerance (see below), the
224        // increment is `PI / 4`, so ~1.57.
225
226        // Empty range
227        let empty: [Scalar; 0] = [];
228        test_path([[0.], [0.]], empty);
229
230        // Ranges contain all generated points. Start is before the first
231        // increment and after the last one in each case.
232        test_path([[0.], [TAU]], [1., 2., 3.]);
233        test_path([[1.], [TAU]], [1., 2., 3.]);
234        test_path([[0.], [TAU - 1.]], [1., 2., 3.]);
235
236        // Here the range is restricted to cut of the first or last increment.
237        test_path([[2.], [TAU]], [2., 3.]);
238        test_path([[0.], [TAU - 2.]], [1., 2.]);
239
240        // And everything again, but in reverse.
241        test_path([[TAU], [0.]], [3., 2., 1.]);
242        test_path([[TAU], [1.]], [3., 2., 1.]);
243        test_path([[TAU - 1.], [0.]], [3., 2., 1.]);
244        test_path([[TAU], [2.]], [3., 2.]);
245        test_path([[TAU - 2.], [0.]], [2., 1.]);
246
247        fn test_path(
248            range: impl Into<RangeOnPath>,
249            expected_coords: impl IntoIterator<Item = impl Into<Scalar>>,
250        ) {
251            // Choose radius and tolerance such, that we need 4 vertices to
252            // approximate a full circle. This is the lowest number that we can
253            // still cover all the edge cases with
254            let radius = 1.;
255            let tolerance = 0.375;
256
257            let circle = Circle::from_center_and_radius([0., 0.], radius);
258            let params = PathApproxParams::for_circle(&circle, tolerance);
259
260            let points = params.points(range).collect::<Vec<_>>();
261
262            let expected_points = expected_coords
263                .into_iter()
264                .map(|i| Point::from([params.increment() * i]))
265                .collect::<Vec<_>>();
266            assert_eq!(points, expected_points);
267        }
268    }
269}