Skip to main content

vello_common/
flatten_simd.rs

1// Copyright 2025 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! This is a temporary module that contains a SIMD version of flattening of cubic curves, as
5//! well as some code that was copied from kurbo, which is needed to reimplement the
6//! full `flatten` method.
7
8#[cfg(not(feature = "std"))]
9use crate::kurbo::common::FloatFuncs as _;
10use crate::kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathEl, Point, QuadBez};
11use crate::{
12    flatten::{SQRT_TOL, TOL, TOL_2},
13    geometry::RectU16,
14    kurbo::Affine,
15    tile::Tile,
16};
17use alloc::vec::Vec;
18use bytemuck::{Pod, Zeroable};
19use fearless_simd::*;
20
21/// The element of a path made of lines.
22///
23/// Each subpath must start with a `MoveTo`. Closing of subpaths is not supported, and subpaths are
24/// not closed implicitly when a new subpath (with `MoveTo`) is started. It is expected that closed
25/// subpaths are watertight in the sense that the last `LineTo` matches exactly with the first
26/// `MoveTo`.
27///
28/// This intentionally allows for non-watertight subpaths, as, e.g., lines that are fully outside
29/// of the viewport do not need to be drawn.
30///
31/// See [`PathEl`] for a more general-purpose path element type.
32pub(crate) enum LinePathEl {
33    MoveTo(Point),
34    LineTo(Point),
35}
36
37// Unlike kurbo, which takes a closure with a callback for outputting the lines, we use a trait
38// instead. The reason is that this way the callback can be inlined, which is not possible with
39// a closure and turned out to have a noticeable overhead.
40pub(crate) trait Callback {
41    fn callback(&mut self, el: LinePathEl);
42}
43
44/// See the docs for the kurbo implementation of flattening:
45/// <https://docs.rs/kurbo/latest/kurbo/fn.flatten.html>
46///
47/// This version works using a similar approach but using f32x4/f32x8 SIMD instead.
48#[inline(always)]
49pub(crate) fn flatten<S: Simd>(
50    simd: S,
51    path: impl IntoIterator<Item = PathEl>,
52    // Note: we explicitly pass the `Affine` here instead of using `map` on
53    // the iterator because it seems like the `map` function isn't always inlined
54    // properly, even when annotating the closure with `#[inline(always)]`.
55    // See https://github.com/linebender/vello/pull/1600.
56    affine: Affine,
57    callback: &mut impl Callback,
58    flatten_ctx: &mut FlattenCtx,
59    cull_bbox: RectU16,
60) {
61    flatten_ctx.flattened_cubics.clear();
62
63    // For the culling performed here to be correct, the top y coordinate of the cull bbox must be
64    // aligned to strip row boundaries. Consider the alternative: for example, a strip row starting
65    // at y=8, a cull bbox starting at y=10, and (nearly) horizontal geometry at y=9 that does not
66    // cross into the cull bbox and does not extend above y=8 (and note this is all in a y-down
67    // coordinate space).
68    //
69    // The culling performed here would remove that geometry. However, as it does not extend above
70    // the strip row, it does not add coarse winding to the strip, and does not produce a sparse
71    // fill. Yet, if this is the top part of, say, a rectangle that is partially visible, the
72    // geometry is necessary for tiling to emit intermediate tiles that are necessary for the
73    // bottom part of the strip to get filled.
74    //
75    // Therefore, we align `top` to strip row boundaries, such that all intermediate tiles are
76    // produced.
77    //
78    // This is not necessary for the bottom. Consider a path where a segment extends just below the
79    // cull bbox, but remains in the same strip row. The path is closed, so if anything is to be
80    // rendered at all, there will be other geometry above that edge of the cull bbox. If that
81    // geometry extends above the row, there will be coarse winding for a sparse fill. If not, it
82    // there will be geometry to generate the intermediate tiles.
83    let left = cull_bbox.x0 as f64;
84    let top = ((cull_bbox.y0 / Tile::HEIGHT) * Tile::HEIGHT) as f64;
85    let right = cull_bbox.x1 as f64;
86    let bottom = cull_bbox.y1 as f64;
87
88    let mut path = path.into_iter();
89    let Some(first_el) = path.next() else {
90        return;
91    };
92    let first_el = affine * first_el;
93    let PathEl::MoveTo(start_pt) = first_el else {
94        debug_assert!(
95            matches!(first_el, PathEl::MoveTo(_)),
96            "Non-empty paths must begin with `PathEl::MoveTo`, got {first_el:?}"
97        );
98        return;
99    };
100
101    let mut start_pt = start_pt;
102    let mut last_pt = start_pt;
103    callback.callback(LinePathEl::MoveTo(start_pt));
104
105    for el in path {
106        match affine * el {
107            PathEl::MoveTo(p) => {
108                if last_pt != start_pt {
109                    callback.callback(LinePathEl::LineTo(start_pt));
110                }
111                last_pt = p;
112                start_pt = p;
113                callback.callback(LinePathEl::MoveTo(p));
114            }
115            PathEl::LineTo(p) => {
116                last_pt = p;
117                callback.callback(LinePathEl::LineTo(p));
118            }
119            PathEl::QuadTo(p1, p2) => {
120                let p0 = last_pt;
121                let line = Line::new(p0, p2);
122                // If the quadratic Bézier is fully to the right, top, or bottom of the culling
123                // bbox, it does not impact pixel coverage or winding. We can ignore it. The
124                // following checks that conservatively by checking whether the bounding box of the
125                // Bézier's control points is fully outside the culling bbox.
126                if [p0, p1, p2].into_iter().all(|p| p.x > right)
127                    || [p0, p1, p2].into_iter().all(|p| p.y < top)
128                    || [p0, p1, p2].into_iter().all(|p| p.y > bottom)
129                {
130                    callback.callback(LinePathEl::MoveTo(p2));
131                }
132                // The following checks two things. First, if the quadratic Bézier is fully to the
133                // left of the culling bbox, it may affect pixel coverage and winding, but its
134                // exact shape does not matter. It can be emitted as a line segment [p0, p2].
135                //
136                // Second, an upper bound on the shortest distance of any point on the quadratic
137                // Bézier curve to the line segment [p0, p2] is 1/2 of the control-point-to-line-segment
138                // distance.
139                //
140                // The derivation is similar to that for the cubic Bézier (see below). In
141                // short:
142                //
143                // q(t) = B0(t) p0 + B1(t) p1 + B2(t) p2
144                // dist(q(t), [p0, p1]) <= B1(t) dist(p1, [p0, p1])
145                //                       = 2 (1-t)t dist(p1, [p0, p1]).
146                //
147                // The maximum occurs at t=1/2, hence
148                // max(dist(q(t), [p0, p1] <= 1/2 dist(p1, [p0, p1])).
149                //
150                // The following takes the square to elide the square root of the Euclidean
151                // distance.
152                else if [p0, p1, p2].into_iter().all(|p| p.x < left)
153                    || line.nearest(p1, 0.).distance_sq <= 4. * TOL_2
154                {
155                    callback.callback(LinePathEl::LineTo(p2));
156                } else {
157                    let q = QuadBez::new(p0, p1, p2);
158                    let params = q.estimate_subdiv(SQRT_TOL);
159                    let n = ((0.5 / SQRT_TOL * params.val).ceil() as usize).max(1);
160                    let step = 1.0 / (n as f64);
161                    for i in 1..n {
162                        let u = (i as f64) * step;
163                        let t = q.determine_subdiv_t(&params, u);
164                        let p = q.eval(t);
165                        callback.callback(LinePathEl::LineTo(p));
166                    }
167                    callback.callback(LinePathEl::LineTo(p2));
168                }
169                last_pt = p2;
170            }
171            PathEl::CurveTo(p1, p2, p3) => {
172                let p0 = last_pt;
173                let line = Line::new(p0, p3);
174                // If the cubic Bézier is fully to the right, top, or bottom of the culling bbox,
175                // it does not impact pixel coverage or winding. We can ignore it. The following
176                // checks that conservatively by checking whether the bounding box of the Bézier's
177                // control points is fully outside the culling bbox.
178                if [p0, p1, p2, p3].into_iter().all(|p| p.x > right)
179                    || [p0, p1, p2, p3].into_iter().all(|p| p.y < top)
180                    || [p0, p1, p2, p3].into_iter().all(|p| p.y > bottom)
181                {
182                    callback.callback(LinePathEl::MoveTo(p3));
183                }
184                // The following checks two things. First, if the cubic Bézier is fully to the left
185                // of the culling bbox, it may affect pixel coverage and winding, but its exact
186                // shape does not matter. It can be emitted as a line segment [p0, p3].
187                //
188                // Second, an upper bound on the shortest distance of any point on the cubic Bézier
189                // curve to the line segment [p0, p3] is 3/4 of the maximum of the
190                // control-point-to-line-segment distances.
191                //
192                // With Bernstein weights Bi(t), we have
193                // c(t) = B0(t) p0 + B1(t) p1 + B2(t) p2 + B3(t) p3
194                // with t from 0 to 1 (inclusive).
195                //
196                // Through convexivity of the Euclidean distance function and the line segment,
197                // we have
198                // dist(c(t), [p0, p3]) <= B1(t) dist(p1, [p0, p3]) + B2(t) dist(p2, [p0, p3])
199                //                      <= (B1(t) + B2(t)) max(dist(p1, [p0, p3]), dist(p2, [p0, p3]))
200                //                       = 3 ((1-t)t^2 + (1-t)^2t) max(dist(p1, [p0, p3]), dist(p2, [p0, p3])).
201                //
202                // The inner polynomial has its maximum of 1/4 at t=1/2, hence
203                // max(dist(c(t), [p0, p3])) <= 3/4 max(dist(p1, [p0, p3]), dist(p2, [p0, p3])).
204                //
205                // The following takes the square to elide the square root of the Euclidean
206                // distance.
207                else if [p0, p1, p2, p3].into_iter().all(|p| p.x < left)
208                    || f64::max(
209                        line.nearest(p1, 0.).distance_sq,
210                        line.nearest(p2, 0.).distance_sq,
211                    ) <= 16. / 9. * TOL_2
212                {
213                    callback.callback(LinePathEl::LineTo(p3));
214                } else {
215                    let c = CubicBez::new(p0, p1, p2, p3);
216                    let max = flatten_cubic_simd(simd, c, flatten_ctx);
217
218                    for p in &flatten_ctx.flattened_cubics[1..max] {
219                        callback.callback(LinePathEl::LineTo(Point::new(p.x as f64, p.y as f64)));
220                    }
221                }
222                last_pt = p3;
223            }
224            PathEl::ClosePath => {
225                if last_pt != start_pt {
226                    callback.callback(LinePathEl::LineTo(start_pt));
227
228                    // Kurbo says: "If `quad_to` [or another drawing op] is called immediately
229                    // after `close_path` then the current subpath starts at the initial point of
230                    // the previous subpath."
231                    //
232                    // Hence, we set `last_pt` back to the just-closed subpath's `start_pt`.
233                    last_pt = start_pt;
234                }
235            }
236        }
237    }
238
239    if last_pt != start_pt {
240        callback.callback(LinePathEl::LineTo(start_pt));
241    }
242}
243
244// The below methods are copied from kurbo and needed to implement flattening of normal quad curves.
245
246/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$
247///
248/// This is used for flattening curves.
249fn approx_parabola_integral(x: f64) -> f64 {
250    const D: f64 = 0.67;
251    x / (1.0 - D + (D.powi(4) + 0.25 * x * x).sqrt().sqrt())
252}
253
254/// An approximation to the inverse parabola integral.
255fn approx_parabola_inv_integral(x: f64) -> f64 {
256    const B: f64 = 0.39;
257    x * (1.0 - B + (B * B + 0.25 * x * x).sqrt())
258}
259
260impl FlattenParamsExt for QuadBez {
261    #[inline(always)]
262    fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams {
263        // Determine transformation to $y = x^2$ parabola.
264        let d01 = self.p1 - self.p0;
265        let d12 = self.p2 - self.p1;
266        let dd = d01 - d12;
267        let cross = (self.p2 - self.p0).cross(dd);
268        let x0 = d01.dot(dd) * cross.recip();
269        let x2 = d12.dot(dd) * cross.recip();
270        let scale = (cross / (dd.hypot() * (x2 - x0))).abs();
271
272        // Compute number of subdivisions needed.
273        let a0 = approx_parabola_integral(x0);
274        let a2 = approx_parabola_integral(x2);
275        let val = if scale.is_finite() {
276            let da = (a2 - a0).abs();
277            let sqrt_scale = scale.sqrt();
278            if x0.signum() == x2.signum() {
279                da * sqrt_scale
280            } else {
281                // Handle cusp case (segment contains curvature maximum)
282                let xmin = sqrt_tol / sqrt_scale;
283                sqrt_tol * da / approx_parabola_integral(xmin)
284            }
285        } else {
286            0.0
287        };
288        let u0 = approx_parabola_inv_integral(a0);
289        let u2 = approx_parabola_inv_integral(a2);
290        let uscale = (u2 - u0).recip();
291        FlattenParams {
292            a0,
293            a2,
294            u0,
295            uscale,
296            val,
297        }
298    }
299
300    #[inline(always)]
301    fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64 {
302        let a = params.a0 + (params.a2 - params.a0) * x;
303        let u = approx_parabola_inv_integral(a);
304        (u - params.u0) * params.uscale
305    }
306}
307
308trait FlattenParamsExt {
309    fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams;
310    fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64;
311}
312
313// Everything below is a SIMD implementation of flattening of cubic curves.
314// It's a combination of https://gist.github.com/raphlinus/5f4e9feb85fd79bafc72da744571ec0e
315// and https://gist.github.com/raphlinus/44e114fef2fd33b889383a60ced0129b.
316
317// TODO(laurenz): Perhaps we should get rid of this in the future and work directly with f32,
318// as it's the only reason we have to pull in proc_macros via the `derive` feature of bytemuck.
319#[derive(Clone, Copy, Debug, Default, Zeroable, Pod)]
320#[repr(C)]
321struct Point32 {
322    x: f32,
323    y: f32,
324}
325
326struct FlattenParams {
327    a0: f64,
328    a2: f64,
329    u0: f64,
330    uscale: f64,
331    /// The number of `subdivisions * 2 * sqrt_tol`.
332    val: f64,
333}
334
335/// This limit was chosen based on the pre-existing GitHub gist.
336/// This limit should not be hit in normal operation, but _might_ be hit for very large
337/// transforms.
338const MAX_QUADS: usize = 16;
339
340/// The context needed for flattening curves.
341#[derive(Default, Debug)]
342pub struct FlattenCtx {
343    // The +4 is to encourage alignment; might be better to be explicit
344    even_pts: [Point32; MAX_QUADS + 4],
345    odd_pts: [Point32; MAX_QUADS],
346    a0: [f32; MAX_QUADS],
347    da: [f32; MAX_QUADS],
348    u0: [f32; MAX_QUADS],
349    uscale: [f32; MAX_QUADS],
350    val: [f32; MAX_QUADS],
351    n_quads: usize,
352    /// Reusable buffer for flattened cubic points.
353    flattened_cubics: Vec<Point32>,
354}
355
356#[inline(always)]
357fn is_finite_simd<S: Simd>(x: f32x4<S>) -> mask32x4<S> {
358    let simd = x.simd;
359
360    let x_abs = x.abs();
361    let reinterpreted = u32x4::from_bytes(x_abs.to_bytes());
362    simd.simd_lt_u32x4(reinterpreted, u32x4::splat(simd, 0x7f80_0000))
363}
364
365/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$
366///
367/// This is used for flattening curves.
368///
369/// SIMD version of [`approx_parabola_integral`].
370#[inline(always)]
371fn approx_parabola_integral_simd<S: Simd, F: SimdFloat<S, Element = f32>>(x: F) -> F {
372    let simd = x.witness();
373
374    const D: f32 = 0.67;
375    const D_POWI_4: f32 = 0.201_511_2;
376
377    let temp = F::splat(x.witness(), 0.25)
378        .mul_add(x * x, F::splat(simd, D_POWI_4))
379        .sqrt()
380        .sqrt();
381    let denom = temp + (1. - D);
382    x / denom
383}
384
385/// An approximation to the inverse parabola integral.
386///
387/// SIMD version of [`approx_parabola_inv_integral`].
388#[inline(always)]
389fn approx_parabola_inv_integral_simd<S: Simd>(x: f32x8<S>) -> f32x8<S> {
390    let simd = x.simd;
391
392    const B: f32 = 0.39;
393    const ONE_MINUS_B: f32 = 1.0 - B;
394
395    let temp = f32x8::splat(simd, 0.25).mul_add(x * x, B * B).sqrt();
396    let factor = f32x8::splat(simd, ONE_MINUS_B) + temp;
397    x * factor
398}
399
400#[inline(always)]
401fn pt_splat_simd<S: Simd>(simd: S, pt: Point32) -> f32x8<S> {
402    let p_f64: f64 = bytemuck::cast(pt);
403    f64x4::splat(simd, p_f64).bitcast()
404}
405
406#[inline(always)]
407fn eval_cubics_simd<S: Simd>(simd: S, c: &CubicBez, n: usize, result: &mut FlattenCtx) {
408    result.n_quads = n;
409    let dt = 0.5 / n as f32;
410
411    // TODO(laurenz): Perhaps we can SIMDify this better
412    let p0p1 = f32x4::from_slice(
413        simd,
414        &[c.p0.x as f32, c.p0.y as f32, c.p1.x as f32, c.p1.y as f32],
415    );
416    let p2p3 = f32x4::from_slice(
417        simd,
418        &[c.p2.x as f32, c.p2.y as f32, c.p3.x as f32, c.p3.y as f32],
419    );
420
421    let split_single = |input: f32x4<S>| {
422        let t1: f64x2<S> = input.bitcast();
423        let p0 = simd.zip_low_f64x2(t1, t1);
424        let p1 = simd.zip_high_f64x2(t1, t1);
425
426        let p0: f32x4<S> = p0.bitcast();
427        let p1: f32x4<S> = p1.bitcast();
428
429        (f32x8::block_splat(p0), f32x8::block_splat(p1))
430    };
431
432    let (p0_128, p1_128) = split_single(p0p1);
433    let (p2_128, p3_128) = split_single(p2p3);
434
435    // Use Horner's method to evaluate p(t) as ((a*t + b)*t + c)*t + d. This allows hoisting
436    // coefficients out of the loop, and then evaluating as three sequential FMAs. Estrin's method
437    // would expose more ILP, but the increase in work on such small polynomials (cubic here and
438    // quad below) makes it a net slowdown.
439    let coeff_a = (p1_128 - p2_128).mul_add(3.0, p3_128 - p0_128);
440    let coeff_b = p1_128.mul_add(-2.0, p0_128 + p2_128) * 3.0;
441    let coeff_c = (p1_128 - p0_128) * 3.0;
442    let coeff_d = p0_128;
443
444    let iota = f32x8::from_slice(simd, &[0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0]);
445    let step = iota * dt;
446    let mut t = step;
447    let t_inc = f32x8::splat(simd, 4.0 * dt);
448
449    let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.even_pts);
450    let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.odd_pts);
451
452    for i in 0..n.div_ceil(2) {
453        let evaluated = (coeff_a.mul_add(t, coeff_b))
454            .mul_add(t, coeff_c)
455            .mul_add(t, coeff_d);
456
457        let (low, high) = simd.split_f32x8(evaluated);
458
459        low.store_slice(&mut even_pts[i * 4..][..4]);
460        high.store_slice(&mut odd_pts[i * 4..][..4]);
461
462        t += t_inc;
463    }
464
465    p3_128.store_slice(&mut even_pts[n * 2..][..8]);
466}
467
468#[inline(always)]
469fn estimate_subdiv_simd<S: Simd>(simd: S, sqrt_tol: f32, ctx: &mut FlattenCtx) {
470    let n = ctx.n_quads;
471
472    let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.even_pts);
473    let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.odd_pts);
474
475    for i in 0..n.div_ceil(4) {
476        let p0 = f32x8::from_slice(simd, &even_pts[i * 8..][..8]);
477        let p_onehalf = f32x8::from_slice(simd, &odd_pts[i * 8..][..8]);
478        let p2 = f32x8::from_slice(simd, &even_pts[(i * 8 + 2)..][..8]);
479        let x = p0 * -0.5;
480        let x1 = p_onehalf.mul_add(2.0, x);
481        let p1 = p2.mul_add(-0.5, x1);
482
483        p1.store_slice(&mut odd_pts[(i * 8)..][..8]);
484
485        let d01 = p1 - p0;
486        let d12 = p2 - p1;
487        let d01x = simd.unzip_low_f32x8(d01, d01);
488        let d01y = simd.unzip_high_f32x8(d01, d01);
489        let d12x = simd.unzip_low_f32x8(d12, d12);
490        let d12y = simd.unzip_high_f32x8(d12, d12);
491        let ddx = d01x - d12x;
492        let ddy = d01y - d12y;
493        let d02x = d01x + d12x;
494        let d02y = d01y + d12y;
495        // (d02x * ddy) - (d02y * ddx)
496        let cross = ddx.mul_add(-d02y, d02x * ddy);
497
498        let x0_x2_a = {
499            let (d01x_low, _) = simd.split_f32x8(d01x);
500            let (d12x_low, _) = simd.split_f32x8(d12x);
501
502            simd.combine_f32x4(d12x_low, d01x_low) * ddx
503        };
504        let temp1 = {
505            let (d12y_low, _) = simd.split_f32x8(d12y);
506            let (d01y_low, _) = simd.split_f32x8(d01y);
507
508            simd.combine_f32x4(d12y_low, d01y_low)
509        };
510        let x0_x2_num = temp1.mul_add(ddy, x0_x2_a);
511        let x0_x2 = x0_x2_num / cross;
512        let (ddx_low, _) = simd.split_f32x8(ddx);
513        let (ddy_low, _) = simd.split_f32x8(ddy);
514        let dd_hypot = ddy_low.mul_add(ddy_low, ddx_low * ddx_low).sqrt();
515        let (x0, x2) = simd.split_f32x8(x0_x2);
516        let scale_denom = dd_hypot * (x2 - x0);
517        let (temp2, _) = simd.split_f32x8(cross);
518        let scale = (temp2 / scale_denom).abs();
519        let a0_a2 = approx_parabola_integral_simd(x0_x2);
520        let (a0, a2) = simd.split_f32x8(a0_a2);
521        let da = a2 - a0;
522        let da_abs = da.abs();
523        let sqrt_scale = scale.sqrt();
524        let temp3 = simd.or_i32x4(x0.bitcast(), x2.bitcast());
525        let mask = simd.simd_ge_i32x4(temp3, i32x4::splat(simd, 0));
526        let noncusp = da_abs * sqrt_scale;
527        // TODO: should we skip this if neither is a cusp? Maybe not worth branch prediction cost
528        let xmin = sqrt_tol / sqrt_scale;
529        let approxint = approx_parabola_integral_simd(xmin);
530        let cusp = (sqrt_tol * da_abs) / approxint;
531        let val_raw = simd.select_f32x4(mask, noncusp, cusp);
532        let finite_mask = is_finite_simd(val_raw);
533        let val = simd.select_f32x4(finite_mask, val_raw, f32x4::splat(simd, 0.0));
534        let u0_u2 = approx_parabola_inv_integral_simd(a0_a2);
535        let (u0, u2) = simd.split_f32x8(u0_u2);
536        let uscale_a = u2 - u0;
537        let uscale = 1.0 / uscale_a;
538
539        a0.store_slice(&mut ctx.a0[i * 4..][..4]);
540        da.store_slice(&mut ctx.da[i * 4..][..4]);
541        u0.store_slice(&mut ctx.u0[i * 4..][..4]);
542        uscale.store_slice(&mut ctx.uscale[i * 4..][..4]);
543        val.store_slice(&mut ctx.val[i * 4..][..4]);
544    }
545}
546
547#[inline(always)]
548fn output_lines_simd<S: Simd>(
549    simd: S,
550    ctx: &mut FlattenCtx,
551    i: usize,
552    x0: f32,
553    dx: f32,
554    n: usize,
555    start_idx: usize,
556) {
557    let p0 = pt_splat_simd(simd, ctx.even_pts[i]);
558    let p1 = pt_splat_simd(simd, ctx.odd_pts[i]);
559    let p2 = pt_splat_simd(simd, ctx.even_pts[i + 1]);
560
561    const IOTA2: [f32; 8] = [0., 0., 1., 1., 2., 2., 3., 3.];
562    let iota2 = f32x8::from_slice(simd, IOTA2.as_ref());
563    let x = iota2.mul_add(dx, f32x8::splat(simd, x0));
564    let da = f32x8::splat(simd, ctx.da[i]);
565    let mut a = da.mul_add(x, f32x8::splat(simd, ctx.a0[i]));
566    let a_inc = 4.0 * dx * da;
567    let uscale = f32x8::splat(simd, ctx.uscale[i]);
568    let u0 = f32x8::splat(simd, ctx.u0[i]);
569
570    // See Horner comment above
571    let coeff_a = p1.mul_add(-2.0, p0) + p2;
572    let coeff_b = (p1 - p0) * 2.0;
573    let coeff_c = p0;
574
575    let out: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.flattened_cubics[start_idx..]);
576
577    for j in 0..n.div_ceil(4) {
578        let u = approx_parabola_inv_integral_simd(a);
579        let t = (u - u0) * uscale;
580        let p = coeff_a.mul_add(t, coeff_b).mul_add(t, coeff_c);
581        p.store_slice(&mut out[j * 8..][..8]);
582        a += a_inc;
583    }
584}
585
586#[inline(always)]
587fn flatten_cubic_simd<S: Simd>(simd: S, c: CubicBez, ctx: &mut FlattenCtx) -> usize {
588    let n_quads = estimate_num_quads(c, TOL as f32);
589    eval_cubics_simd(simd, &c, n_quads, ctx);
590    let tol = (TOL as f32) * (1.0 - TO_QUAD_TOL);
591    let sqrt_tol = tol.sqrt();
592    estimate_subdiv_simd(simd, sqrt_tol, ctx);
593    let sum: f32 = ctx.val[..n_quads].iter().sum();
594    let n = ((0.5 * sum / sqrt_tol).ceil() as usize).max(1);
595    let target_len = n + 4;
596    if target_len > ctx.flattened_cubics.len() {
597        ctx.flattened_cubics.resize(target_len, Point32::default());
598    }
599
600    let step = sum / (n as f32);
601    let step_recip = 1.0 / step;
602    let mut val_sum = 0.0;
603    let mut last_n = 0;
604    let mut x0base = 0.0;
605
606    for i in 0..n_quads {
607        let val = ctx.val[i];
608        val_sum += val;
609        let this_n = val_sum * step_recip;
610        let this_n_next = 1.0 + this_n.floor();
611        let dn = this_n_next as usize - last_n;
612        if dn > 0 {
613            let dx = step / val;
614            let x0 = x0base * dx;
615            output_lines_simd(simd, ctx, i, x0, dx, dn, last_n);
616        }
617        x0base = this_n_next - this_n;
618        last_n = this_n_next as usize;
619    }
620
621    ctx.flattened_cubics[n] = ctx.even_pts[n_quads];
622
623    n + 1
624}
625
626#[inline(always)]
627fn estimate_num_quads(c: CubicBez, accuracy: f32) -> usize {
628    let q_accuracy = (accuracy * TO_QUAD_TOL) as f64;
629    let max_hypot2 = 432.0 * q_accuracy * q_accuracy;
630    let p1x2 = c.p1.to_vec2() * 3.0 - c.p0.to_vec2();
631    let p2x2 = c.p2.to_vec2() * 3.0 - c.p3.to_vec2();
632    let err = (p2x2 - p1x2).hypot2();
633    let err_div = err / max_hypot2;
634
635    estimate(err_div)
636}
637
638const TO_QUAD_TOL: f32 = 0.1;
639
640#[inline(always)]
641fn estimate(err_div: f64) -> usize {
642    // The original version of this method was:
643    // let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1);
644    // n_quads.min(MAX_QUADS)
645    //
646    // Note how we always round up and clamp to the range [1, max_quads]. Since we don't
647    // care about the actual fractional value resulting from the powf call we can simply
648    // compute this using a precomputed lookup table evaluating 1^6, 2^6, 3^6, etc. and simply
649    // comparing if the value is less than or equal to each threshold.
650
651    const LUT: [f64; MAX_QUADS] = [
652        1.0, 64.0, 729.0, 4096.0, 15625.0, 46656.0, 117649.0, 262144.0, 531441.0, 1000000.0,
653        1771561.0, 2985984.0, 4826809.0, 7529536.0, 11390625.0, 16777216.0,
654    ];
655
656    #[expect(clippy::needless_range_loop, reason = "better clarity")]
657    for i in 0..MAX_QUADS {
658        if err_div <= LUT[i] {
659            return i + 1;
660        }
661    }
662
663    MAX_QUADS
664}
665
666#[cfg(test)]
667mod tests {
668    use crate::flatten_simd::{MAX_QUADS, estimate};
669
670    fn old_estimate(err_div: f64) -> usize {
671        let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1);
672        n_quads.min(MAX_QUADS)
673    }
674
675    // Test is disabled by default since it takes 10-20 seconds to run, even in release mode.
676    #[test]
677    #[ignore]
678    fn accuracy() {
679        for i in 0..u32::MAX {
680            let num = f32::from_bits(i);
681
682            if num.is_finite() {
683                assert_eq!(old_estimate(num as f64), estimate(num as f64), "{num}");
684            }
685        }
686    }
687}