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}