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, Rect, 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/// Device-space bounds of flattened edges; infinite and inverted when there are none.
92pub fn edge_bounds(edges: &[Edge]) -> Rect {
93    let mut bounds = Rect::new(
94        f64::INFINITY,
95        f64::INFINITY,
96        f64::NEG_INFINITY,
97        f64::NEG_INFINITY,
98    );
99    for edge in edges {
100        bounds.left = bounds.left.min(edge.top.x).min(edge.bottom.x);
101        bounds.right = bounds.right.max(edge.top.x).max(edge.bottom.x);
102        bounds.top = bounds.top.min(edge.top.y);
103        bounds.bottom = bounds.bottom.max(edge.bottom.y);
104    }
105    bounds
106}
107
108/// Transforms verbs into directed edges, implicitly closing each contour.
109/// Curve precision, generated storage and flattening work share the caller's budget.
110pub fn path_edges(
111    verbs: impl Iterator<Item = PathVerb>,
112    transform: Transform,
113    tolerance: f64,
114    budget: &mut Budget,
115    path: &str,
116) -> Result<Vec<Edge>, ValidationError> {
117    let mut edges = Vec::new();
118    let mut current = Point::ORIGIN;
119    let mut start = Point::ORIGIN;
120    for verb in verbs {
121        budget.work(1, path)?;
122        match verb {
123            PathVerb::MoveTo(point) => {
124                add_edge(&mut edges, current, start, budget, path)?;
125                start = transform.apply(point);
126                current = start;
127            }
128            PathVerb::LineTo(point) => {
129                let end = transform.apply(point);
130                add_edge(&mut edges, current, end, budget, path)?;
131                current = end;
132            }
133            PathVerb::Close => {
134                add_edge(&mut edges, current, start, budget, path)?;
135                current = start;
136            }
137            PathVerb::QuadraticTo(control, end) => {
138                let control = transform.apply(control);
139                let end = transform.apply(end);
140                let curve = QuadBez::new(
141                    (current.x, current.y),
142                    (control.x, control.y),
143                    (end.x, end.y),
144                )
145                .raise();
146                flatten_curve(&mut edges, curve, tolerance, budget, path)?;
147                current = end;
148            }
149            PathVerb::CubicTo(first, second, end) => {
150                let first = transform.apply(first);
151                let second = transform.apply(second);
152                let end = transform.apply(end);
153                let curve = CubicBez::new(
154                    (current.x, current.y),
155                    (first.x, first.y),
156                    (second.x, second.y),
157                    (end.x, end.y),
158                );
159                flatten_curve(&mut edges, curve, tolerance, budget, path)?;
160                current = end;
161            }
162        }
163    }
164    add_edge(&mut edges, current, start, budget, path)?;
165    Ok(edges)
166}
167
168/// Appends bounded curve edges, charging generated storage and flattening work.
169/// Rejects coordinates that cannot resolve the requested device-space tolerance.
170/// Wang's uniform subdivision bounds chord error by `3 * curvature / (4 * count^2)`.
171/// Curved counts remain even so symmetric reversals do not collapse into omitted horizontal edges.
172pub fn flatten_curve(
173    edges: &mut Vec<Edge>,
174    curve: CubicBez,
175    tolerance: f64,
176    budget: &mut Budget,
177    path: &str,
178) -> Result<(), ValidationError> {
179    budget.work(1, path)?;
180    if curve.p0 == curve.p1 && curve.p0 == curve.p2 && curve.p0 == curve.p3 {
181        return Ok(());
182    }
183    let magnitude = [curve.p0, curve.p1, curve.p2, curve.p3]
184        .iter()
185        .fold(0.0f64, |scale, point| {
186            scale.max(point.x.abs()).max(point.y.abs())
187        });
188    if magnitude * (128.0 * f64::EPSILON) > tolerance * 0.25 {
189        return Err(ValidationError::invalid(
190            path,
191            "geometry",
192            "Device-space curve arithmetic cannot resolve the requested tolerance",
193        ));
194    }
195    let first = (curve.p2 - curve.p1) - (curve.p1 - curve.p0);
196    let second = (curve.p3 - curve.p2) - (curve.p2 - curve.p1);
197    let curvature = libm::hypot(first.x, first.y).max(libm::hypot(second.x, second.y));
198    let count = if curvature == 0.0 {
199        1.0
200    } else {
201        (libm::ceil(libm::sqrt(0.75 * curvature / tolerance) * 0.5) * 2.0).max(2.0)
202    };
203    if !count.is_finite() || count > u64::MAX as f64 {
204        return Err(ValidationError::limit(
205            path,
206            "max_work",
207            "Wang subdivision count overflow",
208        ));
209    }
210    budget.work(count as u64, path)?;
211    let mut previous = Point::new(curve.p0.x, curve.p0.y);
212    for index in 1..=count as u64 {
213        let point = if index == count as u64 {
214            curve.p3
215        } else {
216            curve.eval(index as f64 / count)
217        };
218        let current = Point::new(point.x, point.y);
219        add_edge(edges, previous, current, budget, path)?;
220        previous = current;
221    }
222    Ok(())
223}
224
225/// Appends a directed, nonhorizontal edge and charges its retained storage.
226pub fn add_edge(
227    edges: &mut Vec<Edge>,
228    start: Point,
229    end: Point,
230    budget: &mut Budget,
231    path: &str,
232) -> Result<(), ValidationError> {
233    if start.y != end.y {
234        let edge = if start.y < end.y {
235            Edge {
236                top: start,
237                bottom: end,
238                winding: 1,
239            }
240        } else {
241            Edge {
242                top: end,
243                bottom: start,
244                winding: -1,
245            }
246        };
247        budget.push(edges, edge, path)?;
248    }
249    Ok(())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use kurbo::ParamCurveDeriv;
256
257    #[test]
258    fn wang_segments_follow_derivative_bound_and_device_tolerance() {
259        let curves = [
260            CubicBez::new((0.0, 0.0), (0.0, 8.0), (8.0, -8.0), (8.0, 0.0)),
261            CubicBez::new((1.0, 2.0), (1.0, 2.0), (1.0, 2.0), (8.0, 9.0)),
262            CubicBez::new((0.0, 0.0), (3.0, 1.0), (6.0, 2.0), (9.0, 3.0)),
263            QuadBez::new((0.0, 0.0), (4.0, 8.0), (8.0, 1.0)).raise(),
264        ];
265        for curve in curves {
266            for scale in [0.01, 1.0, 32.0] {
267                let curve = CubicBez::new(
268                    (curve.p0.x * scale, curve.p0.y * scale),
269                    (curve.p1.x * scale, curve.p1.y * scale),
270                    (curve.p2.x * scale, curve.p2.y * scale),
271                    (curve.p3.x * scale, curve.p3.y * scale),
272                );
273                let derivative = curve.deriv().deriv();
274                let bound = derivative
275                    .eval(0.0)
276                    .to_vec2()
277                    .hypot()
278                    .max(derivative.eval(1.0).to_vec2().hypot());
279                for tolerance in [0.001, 0.125, 0.5, 1.0] {
280                    let count = if bound == 0.0 {
281                        1
282                    } else {
283                        let required =
284                            libm::ceil(libm::sqrt(bound / (8.0 * tolerance))).max(1.0) as u64;
285                        required + required % 2
286                    };
287                    let mut budget =
288                        Budget::new(PixelSize::new(1, 1), RasterLimits::default()).unwrap();
289                    let mut actual = Vec::new();
290                    flatten_curve(&mut actual, curve, tolerance, &mut budget, "test.wang").unwrap();
291                    let mut expected = Vec::new();
292                    for segment in 0..count {
293                        let start = curve.eval(segment as f64 / count as f64);
294                        let end = curve.eval((segment + 1) as f64 / count as f64);
295                        if start.y < end.y {
296                            expected.push((
297                                Point::new(start.x, start.y),
298                                Point::new(end.x, end.y),
299                                1,
300                            ));
301                        } else if start.y > end.y {
302                            expected.push((
303                                Point::new(end.x, end.y),
304                                Point::new(start.x, start.y),
305                                -1,
306                            ));
307                        }
308                        for sample in 0..=64 {
309                            let progress = f64::from(sample) / 64.0;
310                            let parameter = (segment as f64 + progress) / count as f64;
311                            let chord = start.lerp(end, progress);
312                            let error = (curve.eval(parameter) - chord).hypot();
313                            assert!(
314                                error <= tolerance * (1.0 + 1e-9),
315                                "error {error}, tolerance {tolerance}, count {count}"
316                            );
317                        }
318                    }
319                    assert_eq!(
320                        actual
321                            .iter()
322                            .map(|edge| (edge.top, edge.bottom, edge.winding))
323                            .collect::<Vec<_>>(),
324                        expected
325                    );
326                }
327            }
328        }
329    }
330
331    #[test]
332    fn wang_reduction_preserves_precision_and_work_failures() {
333        let curve = CubicBez::new((0.0, 0.0), (0.0, 8.0), (8.0, -8.0), (8.0, 0.0));
334        let mut budget = Budget::new(
335            PixelSize::new(1, 1),
336            RasterLimits {
337                max_work: 1,
338                ..RasterLimits::default()
339            },
340        )
341        .unwrap();
342        let error =
343            flatten_curve(&mut Vec::new(), curve, 0.125, &mut budget, "test.wang").unwrap_err();
344        assert_eq!(error.field, "max_work");
345        let curve = CubicBez::new(
346            (1e13, 0.0),
347            (1e13, 8.0),
348            (1e13 + 8.0, -8.0),
349            (1e13 + 8.0, 0.0),
350        );
351        let mut budget = Budget::new(PixelSize::new(1, 1), RasterLimits::default()).unwrap();
352        let error =
353            flatten_curve(&mut Vec::new(), curve, 0.125, &mut budget, "test.wang").unwrap_err();
354        assert_eq!(error.field, "geometry");
355    }
356}