Skip to main content

rasterrocket_render/path/
flatten.rs

1//! Adaptive Bezier curve flattening via De Casteljau subdivision.
2//!
3//! Mirrors `SplashXPath::addCurve` from `splash/SplashXPath.cc` exactly,
4//! including the `cx/cy/cNext` stack representation and the
5//! `MAX_CURVE_SPLITS = 1024` hard limit.
6//!
7//! ## Algorithm
8//!
9//! The curve is stored as a linked list of pending segments in the `CurveData`
10//! array. Each segment `[p1, p2]` covers control points `cx[p1*3..p1*3+3]` and
11//! endpoint `cx[p2*3]`. The loop advances `p1` rightward, either emitting a
12//! line segment when the chord deviation is within `flatness_sq`, or splitting
13//! via De Casteljau and inserting a midpoint `p3`.
14//!
15//! ## Output convention
16//!
17//! `p0` (the curve start) is **implicit** — it is the caller's current point and
18//! is **not** included in `out`. The first point appended to `out` is the
19//! endpoint of the first emitted sub-segment, and the last point appended is
20//! always `p3` (the curve endpoint).
21
22use super::PathPoint;
23use crate::types::MAX_CURVE_SPLITS;
24
25// ── helpers ───────────────────────────────────────────────────────────────────
26
27/// Convert a non-negative `i32` slot index to `usize`.
28///
29/// # Panics
30///
31/// Panics in debug builds if `v < 0`. By construction every value passed here
32/// starts at 0 and is only ever increased, so this invariant always holds.
33#[inline]
34fn to_idx(v: i32) -> usize {
35    usize::try_from(v).expect("curve stack index must be non-negative")
36}
37
38// ── CurveData ─────────────────────────────────────────────────────────────────
39
40/// Stack storage for the De Casteljau subdivision loop.
41///
42/// Lazy-allocated (via `Option<Box<CurveData>>`) because it is ~25 KB —
43/// only needed for paths containing Bezier curves.
44///
45/// ## Array sizing
46///
47/// `MAX_CURVE_SPLITS = 1024`, so the slot count is `n = 1025`.
48///
49/// * `cx` / `cy`: length `n * 3 = 3075`.  The maximum slot index used is
50///   `max_u = 1024`, giving a maximum array index of `1024 * 3 + 2 = 3074`,
51///   which is within bounds (no off-by-one).
52/// * `c_next`: length `n = 1025`.  The maximum index used is `max_u = 1024`,
53///   which is also within bounds.
54pub struct CurveData {
55    /// X coordinates: 3 control points per slot, indexed by `slot * 3 + {0,1,2}`.
56    pub cx: Box<[f64]>,
57    /// Y coordinates: same layout as `cx`.
58    pub cy: Box<[f64]>,
59    /// `cNext[p1]` = index of the next segment's first slot after `p1`.
60    pub c_next: Box<[i32]>,
61}
62
63impl CurveData {
64    /// Allocate a new `CurveData`.
65    ///
66    /// # Panics
67    ///
68    /// Panics if `MAX_CURVE_SPLITS` is negative. It is defined as the positive
69    /// constant `1024` (matching `splashMaxCurveSplits` in `SplashTypes.h`), so
70    /// this branch is unreachable in practice.
71    #[must_use]
72    pub fn new() -> Box<Self> {
73        let n = usize::try_from(MAX_CURVE_SPLITS)
74            .expect("MAX_CURVE_SPLITS must be non-negative (it is the constant 1024)")
75            + 1;
76        Box::new(Self {
77            cx: vec![0.0f64; n * 3].into_boxed_slice(),
78            cy: vec![0.0f64; n * 3].into_boxed_slice(),
79            c_next: vec![0i32; n].into_boxed_slice(),
80        })
81    }
82}
83
84// ── flatten_curve ─────────────────────────────────────────────────────────────
85
86/// Flatten a cubic Bezier curve into a sequence of line endpoints.
87///
88/// Appends one `PathPoint` per emitted line segment endpoint to `out`. The
89/// implicit start point `p0` (the caller's current point) is **not** appended.
90/// The first appended point is the endpoint of the first sub-segment; the last
91/// appended point is always `p3`.
92///
93/// `flatness_sq` is the **squared** maximum allowed deviation from the true
94/// curve to a chord. Use `flatness * flatness` when calling from `XPath::new`.
95///
96/// `curve_data` is lazy-allocated storage reused across multiple calls on the
97/// same `XPath`. Pass `None` on the first call; the `Box<CurveData>` is
98/// returned and should be stored for the next call.
99///
100/// ## Midpoint arithmetic
101///
102/// All midpoints are computed with `f64::midpoint` / `i32::midpoint` (stable
103/// since Rust 1.85). These methods guarantee a correctly-rounded result and
104/// never overflow, unlike the naive `(a + b) / 2`.
105///
106/// # Panics
107///
108/// Panics if `MAX_CURVE_SPLITS` is negative (it is the positive constant 1024).
109pub fn flatten_curve(
110    p0: PathPoint,
111    p1: PathPoint,
112    p2: PathPoint,
113    p3: PathPoint,
114    flatness_sq: f64,
115    out: &mut Vec<PathPoint>,
116    curve_data: &mut Option<Box<CurveData>>,
117) {
118    let data = curve_data.get_or_insert_with(CurveData::new);
119
120    let max = MAX_CURVE_SPLITS;
121    let max_u = usize::try_from(max)
122        .expect("MAX_CURVE_SPLITS must be non-negative (it is the constant 1024)");
123
124    // Initialise the stack: one segment covering [0, max].
125    let i0 = 0usize;
126    let i2 = max_u;
127    // Slot 0: control points (x0/y0, x1/y1, x2/y2).
128    data.cx[0] = p0.x;
129    data.cy[0] = p0.y;
130    data.cx[1] = p1.x;
131    data.cy[1] = p1.y;
132    data.cx[2] = p2.x;
133    data.cy[2] = p2.y;
134    // Slot max: just the endpoint (x3/y3).
135    data.cx[i2 * 3] = p3.x;
136    data.cy[i2 * 3] = p3.y;
137    data.c_next[i0] = max;
138
139    // Loop invariant: `pp1 >= 0` always, because it starts at 0 and is only
140    // ever set to `pp2` (which comes from `c_next`, initialised to non-negative
141    // values) or to `pp3 = i32::midpoint(pp1, pp2)` (always in [pp1, pp2]).
142    // Therefore `pp2 >= pp1 >= 0` throughout, so `pp2 - pp1` never underflows.
143    let mut pp1 = 0i32; // current left slot
144
145    while pp1 < max {
146        let pp1u = to_idx(pp1);
147        let pp2 = data.c_next[pp1u];
148        let pp2u = to_idx(pp2);
149
150        // Read this segment's endpoints and control points.
151        let xl0 = data.cx[pp1u * 3];
152        let yl0 = data.cy[pp1u * 3];
153        let xx1 = data.cx[pp1u * 3 + 1];
154        let yy1 = data.cy[pp1u * 3 + 1];
155        let xx2 = data.cx[pp1u * 3 + 2];
156        let yy2 = data.cy[pp1u * 3 + 2];
157        let xr3 = data.cx[pp2u * 3];
158        let yr3 = data.cy[pp2u * 3];
159
160        // Midpoint of the chord (f64::midpoint: stable Rust 1.85, never overflows).
161        let mx = xl0.midpoint(xr3);
162        let my = yl0.midpoint(yr3);
163
164        // Squared deviation of the two control points from the chord midpoint.
165        let dx1 = xx1 - mx;
166        let dy1 = yy1 - my;
167        let dx2 = xx2 - mx;
168        let dy2 = yy2 - my;
169        let d1 = dx1.mul_add(dx1, dy1 * dy1);
170        let d2 = dx2.mul_add(dx2, dy2 * dy2);
171
172        if pp2 - pp1 == 1 || (d1 <= flatness_sq && d2 <= flatness_sq) {
173            // Emit this segment as a straight line.
174            out.push(PathPoint::new(xr3, yr3));
175            pp1 = pp2;
176        } else {
177            // De Casteljau midpoint subdivision.
178            // All midpoint calls use f64::midpoint (stable Rust 1.85).
179            let xl1 = xl0.midpoint(xx1);
180            let yl1 = yl0.midpoint(yy1);
181            let xh = xx1.midpoint(xx2);
182            let yh = yy1.midpoint(yy2);
183            let xl2 = xl1.midpoint(xh);
184            let yl2 = yl1.midpoint(yh);
185            let xr2 = xx2.midpoint(xr3);
186            let yr2 = yy2.midpoint(yr3);
187            let xr1 = xh.midpoint(xr2);
188            let yr1 = yh.midpoint(yr2);
189            let xr0 = xl2.midpoint(xr1);
190            let yr0 = yl2.midpoint(yr1);
191
192            // i32::midpoint (stable Rust 1.85) gives the floor of the average,
193            // always in [pp1, pp2], so pp3u is a valid slot index.
194            let pp3 = i32::midpoint(pp1, pp2);
195            let pp3u = to_idx(pp3);
196
197            // Update left segment: [pp1, pp3] with left sub-curve controls.
198            data.cx[pp1u * 3 + 1] = xl1;
199            data.cy[pp1u * 3 + 1] = yl1;
200            data.cx[pp1u * 3 + 2] = xl2;
201            data.cy[pp1u * 3 + 2] = yl2;
202            data.c_next[pp1u] = pp3;
203
204            // New right sub-curve at pp3: [xr0, xr1, xr2] then endpoint at pp2.
205            data.cx[pp3u * 3] = xr0;
206            data.cy[pp3u * 3] = yr0;
207            data.cx[pp3u * 3 + 1] = xr1;
208            data.cy[pp3u * 3 + 1] = yr1;
209            data.cx[pp3u * 3 + 2] = xr2;
210            data.cy[pp3u * 3 + 2] = yr2;
211            data.c_next[pp3u] = pp2;
212            // (endpoint at pp2 is already set from a prior iteration or init)
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn pt(x: f64, y: f64) -> PathPoint {
222        PathPoint::new(x, y)
223    }
224
225    #[test]
226    fn flat_curve_emits_one_segment() {
227        // A degenerate curve with all control points on the line y=0.
228        // The curve is geometrically flat, but control points deviate from the
229        // chord midpoint, so it may still subdivide. All outputs must have y≈0
230        // and the last point must be the endpoint (3, 0).
231        let mut out = Vec::new();
232        let mut data = None;
233        flatten_curve(
234            pt(0.0, 0.0),
235            pt(1.0, 0.0),
236            pt(2.0, 0.0),
237            pt(3.0, 0.0),
238            0.1,
239            &mut out,
240            &mut data,
241        );
242        assert!(!out.is_empty());
243        for p in &out {
244            assert!(p.y.abs() < 1e-10, "y={} should be 0", p.y);
245        }
246        let last = out.last().unwrap();
247        assert!((last.x - 3.0).abs() < 1e-10);
248        assert!(last.y.abs() < 1e-10);
249    }
250
251    #[test]
252    fn endpoints_match_cubic() {
253        // Quarter-circle-ish curve.
254        let p0 = pt(0.0, 0.0);
255        let p3 = pt(1.0, 1.0);
256        let k = crate::types::BEZIER_CIRCLE;
257        let p1 = pt(0.0, k);
258        let p2 = pt(1.0 - k, 1.0);
259        let mut out = Vec::new();
260        let mut data = None;
261        flatten_curve(p0, p1, p2, p3, 0.01 * 0.01, &mut out, &mut data);
262        // First emitted point is the last in the sequence → approaches p3.
263        let last = out.last().unwrap();
264        assert!((last.x - p3.x).abs() < 1e-10, "last x = {}", last.x);
265        assert!((last.y - p3.y).abs() < 1e-10, "last y = {}", last.y);
266    }
267
268    #[test]
269    fn does_not_exceed_max_curve_splits() {
270        // Adversarial: a curve that would recurse forever without the split limit.
271        let p0 = pt(0.0, 0.0);
272        let p1 = pt(1e8, -1e8);
273        let p2 = pt(-1e8, 1e8);
274        let p3 = pt(0.0, 0.0);
275        let mut out = Vec::new();
276        let mut data = None;
277        flatten_curve(p0, p1, p2, p3, 0.5 * 0.5, &mut out, &mut data);
278        // Must terminate and emit ≤ MAX_CURVE_SPLITS segments.
279        assert!(out.len() <= usize::try_from(MAX_CURVE_SPLITS).expect("non-negative"));
280    }
281
282    #[test]
283    fn curve_to_line_is_two_points() {
284        // Degenerate curve: all four control points are identical.
285        // The chord has zero length, both control points sit exactly on the
286        // chord midpoint (deviation = 0), so the flatness test passes
287        // immediately. The output must contain exactly one point (the endpoint
288        // p3 = p0 = the single shared coordinate).
289        let origin = pt(5.0, 7.0);
290        let mut out = Vec::new();
291        let mut data = None;
292        flatten_curve(
293            origin,
294            origin,
295            origin,
296            origin,
297            f64::EPSILON,
298            &mut out,
299            &mut data,
300        );
301        assert_eq!(out.len(), 1, "degenerate curve must emit exactly one point");
302        assert_eq!(out[0], origin, "emitted point must equal p3");
303    }
304}