Skip to main content

motion_core/geometry/
flatten.rs

1//! Shared bounded Bezier-to-edge geometry, independent of pixel composition.
2use crate::budget::{Budget, RasterLimits};
3use crate::{PathVerb, PixelSize, Point, Transform, ValidationError};
4use kurbo::{CubicBez, ParamCurve, QuadBez};
5use std::mem::size_of;
6
7#[derive(Clone, Copy)]
8/// Nonhorizontal directed edge stored from smaller to larger vertical coordinate.
9pub struct Edge {
10    /// Endpoint with the smaller vertical coordinate.
11    pub top: Point,
12    /// Endpoint with the larger vertical coordinate.
13    pub bottom: Point,
14    /// Original direction: positive for increasing vertical coordinates, negative otherwise.
15    pub winding: i32,
16}
17
18impl Edge {
19    /// Interpolates the horizontal coordinate, clamping queries to this edge's endpoints.
20    pub fn at(self, vertical: f64) -> f64 {
21        if self.top.x == self.bottom.x {
22            return self.top.x;
23        }
24        let progress = ((vertical - self.top.y) / (self.bottom.y - self.top.y)).clamp(0.0, 1.0);
25        self.top.x * (1.0 - progress) + self.bottom.x * progress
26    }
27
28    /// Rejects visible opposite-sign projection cancellation above the precision guard.
29    /// `start` and `end` describe the vertical band; `width` is its raster viewport.
30    pub fn check_projection(
31        self,
32        start: f64,
33        end: f64,
34        width: u32,
35        path: &str,
36    ) -> Result<(), ValidationError> {
37        let first = self.at(start);
38        let last = self.at(end);
39        let cancellation =
40            (self.top.x < 0.0 && self.bottom.x > 0.0) || (self.top.x > 0.0 && self.bottom.x < 0.0);
41        let error = self.top.x.abs().max(self.bottom.x.abs()) * (16.0 * f64::EPSILON);
42        if cancellation
43            && first.min(last) <= f64::from(width)
44            && first.max(last) >= 0.0
45            && error > 1.0e-7
46        {
47            return Err(ValidationError::invalid(
48                path,
49                "geometry",
50                "Visible edge cancellation exceeds the 1e-7 device-pixel precision guard",
51            ));
52        }
53        Ok(())
54    }
55}
56
57/// Flattens a path for geometric surface sampling and returns edges plus charged work.
58/// Uses the edge storage allowance and at most four million work units; allocates no pixels.
59pub fn surface_edges(
60    source: &crate::Path2D,
61    transform: Transform,
62    tolerance: f64,
63    max_edges: usize,
64    max_work: u64,
65) -> Result<(Vec<Edge>, u64), ValidationError> {
66    if max_work == 0 {
67        return Err(ValidationError::limit(
68            "vector_tree.surface",
69            "max_work",
70            "Surface preparation work exhausted",
71        ));
72    }
73    let mut budget = Budget::new(
74        PixelSize::new(1, 1),
75        RasterLimits {
76            max_pixels: 1,
77            max_bytes: (max_edges as u64 + 1) * size_of::<Edge>() as u64,
78            max_work: max_work.min(4_000_000),
79        },
80    )?;
81    let edges = path_edges(
82        source.verbs().iter().copied(),
83        transform,
84        tolerance,
85        &mut budget,
86        "vector_tree.surface",
87    )?;
88    Ok((edges, budget.work_done()))
89}
90
91/// Transforms verbs into directed edges, implicitly closing each contour.
92/// Curve precision, generated storage and flattening work share the caller's budget.
93pub fn path_edges(
94    verbs: impl Iterator<Item = PathVerb>,
95    transform: Transform,
96    tolerance: f64,
97    budget: &mut Budget,
98    path: &str,
99) -> Result<Vec<Edge>, ValidationError> {
100    let mut edges = Vec::new();
101    let mut current = Point::ORIGIN;
102    let mut start = Point::ORIGIN;
103    for verb in verbs {
104        budget.work(1, path)?;
105        match verb {
106            PathVerb::MoveTo(point) => {
107                add_edge(&mut edges, current, start, budget, path)?;
108                start = transform.apply(point);
109                current = start;
110            }
111            PathVerb::LineTo(point) => {
112                let end = transform.apply(point);
113                add_edge(&mut edges, current, end, budget, path)?;
114                current = end;
115            }
116            PathVerb::Close => {
117                add_edge(&mut edges, current, start, budget, path)?;
118                current = start;
119            }
120            PathVerb::QuadraticTo(control, end) => {
121                let control = transform.apply(control);
122                let end = transform.apply(end);
123                let curve = QuadBez::new(
124                    (current.x, current.y),
125                    (control.x, control.y),
126                    (end.x, end.y),
127                )
128                .raise();
129                flatten_curve(&mut edges, curve, tolerance, budget, path)?;
130                current = end;
131            }
132            PathVerb::CubicTo(first, second, end) => {
133                let first = transform.apply(first);
134                let second = transform.apply(second);
135                let end = transform.apply(end);
136                let curve = CubicBez::new(
137                    (current.x, current.y),
138                    (first.x, first.y),
139                    (second.x, second.y),
140                    (end.x, end.y),
141                );
142                flatten_curve(&mut edges, curve, tolerance, budget, path)?;
143                current = end;
144            }
145        }
146    }
147    add_edge(&mut edges, current, start, budget, path)?;
148    Ok(edges)
149}
150
151/// Appends bounded curve edges, charging generated storage and flattening work.
152/// Rejects coordinates that cannot resolve the requested device-space tolerance.
153/// Wang's uniform subdivision bounds chord error by `3 * curvature / (4 * count^2)`.
154/// Curved counts remain even so symmetric reversals do not collapse into omitted horizontal edges.
155pub fn flatten_curve(
156    edges: &mut Vec<Edge>,
157    curve: CubicBez,
158    tolerance: f64,
159    budget: &mut Budget,
160    path: &str,
161) -> Result<(), ValidationError> {
162    budget.work(1, path)?;
163    if curve.p0 == curve.p1 && curve.p0 == curve.p2 && curve.p0 == curve.p3 {
164        return Ok(());
165    }
166    let magnitude = [curve.p0, curve.p1, curve.p2, curve.p3]
167        .iter()
168        .fold(0.0f64, |scale, point| {
169            scale.max(point.x.abs()).max(point.y.abs())
170        });
171    if magnitude * (128.0 * f64::EPSILON) > tolerance * 0.25 {
172        return Err(ValidationError::invalid(
173            path,
174            "geometry",
175            "Device-space curve arithmetic cannot resolve the requested tolerance",
176        ));
177    }
178    let first = (curve.p2 - curve.p1) - (curve.p1 - curve.p0);
179    let second = (curve.p3 - curve.p2) - (curve.p2 - curve.p1);
180    let curvature = libm::hypot(first.x, first.y).max(libm::hypot(second.x, second.y));
181    let count = if curvature == 0.0 {
182        1.0
183    } else {
184        (libm::ceil(libm::sqrt(0.75 * curvature / tolerance) * 0.5) * 2.0).max(2.0)
185    };
186    if !count.is_finite() || count > u64::MAX as f64 {
187        return Err(ValidationError::limit(
188            path,
189            "max_work",
190            "Wang subdivision count overflow",
191        ));
192    }
193    budget.work(count as u64, path)?;
194    let mut previous = Point::new(curve.p0.x, curve.p0.y);
195    for index in 1..=count as u64 {
196        let point = if index == count as u64 {
197            curve.p3
198        } else {
199            curve.eval(index as f64 / count)
200        };
201        let current = Point::new(point.x, point.y);
202        add_edge(edges, previous, current, budget, path)?;
203        previous = current;
204    }
205    Ok(())
206}
207
208/// Appends a directed, nonhorizontal edge and charges its retained storage.
209pub fn add_edge(
210    edges: &mut Vec<Edge>,
211    start: Point,
212    end: Point,
213    budget: &mut Budget,
214    path: &str,
215) -> Result<(), ValidationError> {
216    if start.y != end.y {
217        let edge = if start.y < end.y {
218            Edge {
219                top: start,
220                bottom: end,
221                winding: 1,
222            }
223        } else {
224            Edge {
225                top: end,
226                bottom: start,
227                winding: -1,
228            }
229        };
230        budget.push(edges, edge, path)?;
231    }
232    Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use kurbo::ParamCurveDeriv;
239
240    #[test]
241    fn wang_segments_follow_derivative_bound_and_device_tolerance() {
242        let curves = [
243            CubicBez::new((0.0, 0.0), (0.0, 8.0), (8.0, -8.0), (8.0, 0.0)),
244            CubicBez::new((1.0, 2.0), (1.0, 2.0), (1.0, 2.0), (8.0, 9.0)),
245            CubicBez::new((0.0, 0.0), (3.0, 1.0), (6.0, 2.0), (9.0, 3.0)),
246            QuadBez::new((0.0, 0.0), (4.0, 8.0), (8.0, 1.0)).raise(),
247        ];
248        for curve in curves {
249            for scale in [0.01, 1.0, 32.0] {
250                let curve = CubicBez::new(
251                    (curve.p0.x * scale, curve.p0.y * scale),
252                    (curve.p1.x * scale, curve.p1.y * scale),
253                    (curve.p2.x * scale, curve.p2.y * scale),
254                    (curve.p3.x * scale, curve.p3.y * scale),
255                );
256                let derivative = curve.deriv().deriv();
257                let bound = derivative
258                    .eval(0.0)
259                    .to_vec2()
260                    .hypot()
261                    .max(derivative.eval(1.0).to_vec2().hypot());
262                for tolerance in [0.001, 0.125, 0.5, 1.0] {
263                    let count = if bound == 0.0 {
264                        1
265                    } else {
266                        let required =
267                            libm::ceil(libm::sqrt(bound / (8.0 * tolerance))).max(1.0) as u64;
268                        required + required % 2
269                    };
270                    let mut budget =
271                        Budget::new(PixelSize::new(1, 1), RasterLimits::default()).unwrap();
272                    let mut actual = Vec::new();
273                    flatten_curve(&mut actual, curve, tolerance, &mut budget, "test.wang").unwrap();
274                    let mut expected = Vec::new();
275                    for segment in 0..count {
276                        let start = curve.eval(segment as f64 / count as f64);
277                        let end = curve.eval((segment + 1) as f64 / count as f64);
278                        if start.y < end.y {
279                            expected.push((
280                                Point::new(start.x, start.y),
281                                Point::new(end.x, end.y),
282                                1,
283                            ));
284                        } else if start.y > end.y {
285                            expected.push((
286                                Point::new(end.x, end.y),
287                                Point::new(start.x, start.y),
288                                -1,
289                            ));
290                        }
291                        for sample in 0..=64 {
292                            let progress = f64::from(sample) / 64.0;
293                            let parameter = (segment as f64 + progress) / count as f64;
294                            let chord = start.lerp(end, progress);
295                            let error = (curve.eval(parameter) - chord).hypot();
296                            assert!(
297                                error <= tolerance * (1.0 + 1e-9),
298                                "error {error}, tolerance {tolerance}, count {count}"
299                            );
300                        }
301                    }
302                    assert_eq!(
303                        actual
304                            .iter()
305                            .map(|edge| (edge.top, edge.bottom, edge.winding))
306                            .collect::<Vec<_>>(),
307                        expected
308                    );
309                }
310            }
311        }
312    }
313
314    #[test]
315    fn wang_reduction_preserves_precision_and_work_failures() {
316        let curve = CubicBez::new((0.0, 0.0), (0.0, 8.0), (8.0, -8.0), (8.0, 0.0));
317        let mut budget = Budget::new(
318            PixelSize::new(1, 1),
319            RasterLimits {
320                max_work: 1,
321                ..RasterLimits::default()
322            },
323        )
324        .unwrap();
325        let error =
326            flatten_curve(&mut Vec::new(), curve, 0.125, &mut budget, "test.wang").unwrap_err();
327        assert_eq!(error.field, "max_work");
328        let curve = CubicBez::new(
329            (1e13, 0.0),
330            (1e13, 8.0),
331            (1e13 + 8.0, -8.0),
332            (1e13 + 8.0, 0.0),
333        );
334        let mut budget = Budget::new(PixelSize::new(1, 1), RasterLimits::default()).unwrap();
335        let error =
336            flatten_curve(&mut Vec::new(), curve, 0.125, &mut budget, "test.wang").unwrap_err();
337        assert_eq!(error.field, "geometry");
338    }
339}