Skip to main content

rasterrocket_render/fill/
mod.rs

1//! Filled-path rasterization — replaces `Splash::fill`, `Splash::eoFill`,
2//! and the core of `Splash::fillWithPattern`.
3//!
4//! Two public entry points:
5//! - [`fill`] — non-zero winding fill
6//! - [`eo_fill`] — even-odd fill
7//!
8//! Both entry points are thin wrappers around the private `fill_impl` which
9//! does the actual work: build `XPath` → optionally AA-scale → build
10//! `XPathScanner` → walk spans → clip → `pipe::render_span`.
11//!
12//! # AA (vector antialias) mode
13//!
14//! When `vector_antialias` is `true` the path is scaled 4× in `XPath` (`aaScale`),
15//! the scanner operates in 4× AA coordinates, `render_aa_line` fills the `AaBuf`,
16//! the clip AA-masks the buf, and then `draw_aa_line` reads the 4-bit coverage
17//! count (0..16) through a gamma LUT (`aaGamma`) and calls `render_span_aa`.
18//!
19//! # Parallel fill
20//!
21//! Rayon-parallel variants `fill_parallel` / `eo_fill_parallel` live in a
22//! private `parallel` submodule and are re-exported here when the `rayon`
23//! feature is enabled.
24//!
25//! # C++ equivalent
26//! `Splash::fillWithPattern`.
27
28#[cfg(feature = "rayon")]
29mod parallel;
30#[cfg(feature = "rayon")]
31pub use parallel::{PARALLEL_FILL_MIN_HEIGHT, eo_fill_parallel, fill_parallel};
32
33use crate::bitmap::{AaBuf, Bitmap, BitmapBand};
34use crate::clip::{Clip, ClipResult};
35use crate::path::Path;
36use crate::pipe::{self, PipeSrc, PipeState};
37use crate::scanner::XPathScanner;
38use crate::scanner::iter::ScanIterator;
39use crate::simd;
40use crate::types::AA_SIZE;
41use crate::xpath::XPath;
42use color::Pixel;
43
44/// AA gamma table for `splashAASize=4`, `splashAAGamma=1.5`.
45/// Entry `i`: `round((i/16)^1.5 * 255)` for i in 0..=16.
46pub(super) const AA_GAMMA: [u8; (AA_SIZE * AA_SIZE + 1) as usize] = [
47    0, 4, 11, 21, 32, 45, 59, 74, 90, 108, 126, 145, 166, 187, 209, 231, 255,
48];
49
50/// Non-zero winding fill.
51#[expect(
52    clippy::too_many_arguments,
53    reason = "mirrors SplashFillWithPattern API; all params necessary"
54)]
55pub fn fill<P: Pixel>(
56    bitmap: &mut Bitmap<P>,
57    clip: &Clip,
58    path: &Path,
59    pipe: &PipeState<'_>,
60    src: &PipeSrc<'_>,
61    matrix: &[f64; 6],
62    flatness: f64,
63    vector_antialias: bool,
64) {
65    fill_impl::<P>(
66        bitmap,
67        clip,
68        path,
69        pipe,
70        src,
71        matrix,
72        flatness,
73        vector_antialias,
74        false,
75    );
76}
77
78/// Even-odd fill.
79#[expect(
80    clippy::too_many_arguments,
81    reason = "mirrors SplashFillWithPattern API; all params necessary"
82)]
83pub fn eo_fill<P: Pixel>(
84    bitmap: &mut Bitmap<P>,
85    clip: &Clip,
86    path: &Path,
87    pipe: &PipeState<'_>,
88    src: &PipeSrc<'_>,
89    matrix: &[f64; 6],
90    flatness: f64,
91    vector_antialias: bool,
92) {
93    fill_impl::<P>(
94        bitmap,
95        clip,
96        path,
97        pipe,
98        src,
99        matrix,
100        flatness,
101        vector_antialias,
102        true,
103    );
104}
105
106#[expect(
107    clippy::too_many_arguments,
108    reason = "mirrors SplashFillWithPattern API; all params necessary"
109)]
110pub(super) fn fill_impl<P: Pixel>(
111    bitmap: &mut Bitmap<P>,
112    clip: &Clip,
113    path: &Path,
114    pipe: &PipeState<'_>,
115    src: &PipeSrc<'_>,
116    matrix: &[f64; 6],
117    flatness: f64,
118    vector_antialias: bool,
119    eo: bool,
120) {
121    if path.pts.is_empty() {
122        return;
123    }
124
125    let mut xpath = XPath::new(path, matrix, flatness, true);
126
127    // Clip y bounds in scanner coordinates (AA or normal).
128    let (y_min_clip, y_max_clip) = if vector_antialias {
129        xpath.aa_scale();
130        let y_min = clip
131            .y_min_i
132            .checked_mul(AA_SIZE)
133            .expect("AA y_lo overflows i32: clip.y_min_i is unreasonably large");
134        let y_max = clip
135            .y_max_i
136            .checked_add(1)
137            .and_then(|v| v.checked_mul(AA_SIZE))
138            .map(|v| v - 1)
139            .expect("AA y_hi overflows i32: clip.y_max_i is unreasonably large");
140        (y_min, y_max)
141    } else {
142        (clip.y_min_i, clip.y_max_i)
143    };
144
145    let scanner = XPathScanner::new(&xpath, eo, y_min_clip, y_max_clip);
146
147    if scanner.is_empty() {
148        return;
149    }
150
151    if bitmap.width == 0 {
152        return;
153    }
154
155    // Compute pixel-space bbox from scanner.
156    let (x_min_i, y_min_i, x_max_i, y_max_i) = if vector_antialias {
157        (
158            scanner.x_min / AA_SIZE,
159            scanner.y_min / AA_SIZE,
160            scanner.x_max / AA_SIZE,
161            scanner.y_max / AA_SIZE,
162        )
163    } else {
164        (scanner.x_min, scanner.y_min, scanner.x_max, scanner.y_max)
165    };
166
167    let clip_res = clip.test_rect(x_min_i, y_min_i, x_max_i, y_max_i);
168    if clip_res == ClipResult::AllOutside {
169        return;
170    }
171
172    if vector_antialias {
173        let bitmap_width = bitmap.width as usize;
174        let mut aa_buf = AaBuf::new(bitmap_width);
175
176        for aa_y in scanner.y_min..=scanner.y_max {
177            let y = aa_y / AA_SIZE;
178            // The AA scanner row index within the 4-row AaBuf band.
179            // aa_y % AA_SIZE is in 0..AA_SIZE so always non-negative.
180            #[expect(clippy::cast_sign_loss, reason = "aa_y % AA_SIZE is in 0..AA_SIZE ≥ 0")]
181            let aa_row = (aa_y % AA_SIZE) as usize;
182
183            // Determine x span for this AA scanline.
184            let mut x0 = scanner.x_min / AA_SIZE;
185            let mut x1 = scanner.x_max / AA_SIZE;
186
187            scanner.render_aa_line(&mut aa_buf, &mut x0, &mut x1, aa_y);
188
189            if clip_res != ClipResult::AllInside {
190                clip.clip_aa_line(&mut aa_buf, &mut x0, &mut x1, aa_y);
191            }
192
193            // At the boundary of each output row, emit one composited line.
194            if aa_row == AA_SIZE as usize - 1 {
195                #[expect(
196                    clippy::cast_sign_loss,
197                    reason = "y = aa_y / AA_SIZE ≥ 0 since scanner.y_min ≥ 0"
198                )]
199                if x0 <= x1 && y >= 0 && (y as u32) < bitmap.height {
200                    draw_aa_line::<P>(bitmap, pipe, src, &aa_buf, x0, x1, y);
201                }
202                aa_buf.clear();
203            }
204        }
205    } else {
206        #[expect(
207            clippy::cast_possible_wrap,
208            reason = "bitmap.width ≤ i32::MAX in practice; zero checked above scanner.is_empty()"
209        )]
210        let width_i = bitmap.width as i32;
211
212        // Iterate only over scanlines that have at least one intersection —
213        // skips empty rows in the bounding box without touching the fill loop.
214        for y in scanner.nonempty_rows() {
215            #[expect(clippy::cast_sign_loss, reason = "cast after y < 0 guard")]
216            if y < 0 || (y as u32) >= bitmap.height {
217                continue;
218            }
219            for (x0, x1) in ScanIterator::new(&scanner, y) {
220                let (mut sx0, mut sx1) = (x0, x1);
221                let inner_clip = if clip_res == ClipResult::AllInside {
222                    // Clamp to bitmap bounds only.
223                    sx0 = sx0.max(0);
224                    sx1 = sx1.min(width_i - 1);
225                    true
226                } else {
227                    sx0 = sx0.max(clip.x_min_i);
228                    sx1 = sx1.min(clip.x_max_i);
229                    clip.test_span(sx0, sx1, y) == ClipResult::AllInside
230                };
231
232                if sx0 > sx1 {
233                    continue;
234                }
235
236                if inner_clip {
237                    draw_span::<P, _>(bitmap, pipe, src, sx0, sx1, y);
238                } else {
239                    draw_span_clipped::<P, _>(bitmap, clip, pipe, src, sx0, sx1, y);
240                }
241            }
242        }
243    }
244}
245
246// ── RowSink — shared abstraction over Bitmap and BitmapBand ──────────────────
247
248/// A target that can vend a mutable pixel row and an optional alpha row.
249///
250/// Implemented by [`Bitmap`] and [`BitmapBand`], letting `draw_span` and
251/// `draw_span_clipped` work without duplication across the sequential and
252/// parallel fill paths.
253pub(super) trait RowSink<P: Pixel> {
254    /// Return mutable access to the raw pixel bytes and the optional alpha
255    /// plane for absolute row `y`.
256    ///
257    /// # Panics
258    ///
259    /// Panics if `y` is out of range for the sink (matches the behaviour of
260    /// `Bitmap::row_and_alpha_mut` and `BitmapBand::row_and_alpha_mut`).
261    fn row_and_alpha_mut(&mut self, y: u32) -> (&mut [u8], Option<&mut [u8]>);
262}
263
264impl<P: Pixel> RowSink<P> for Bitmap<P> {
265    #[inline]
266    fn row_and_alpha_mut(&mut self, y: u32) -> (&mut [u8], Option<&mut [u8]>) {
267        self.row_and_alpha_mut(y)
268    }
269}
270
271impl<P: Pixel> RowSink<P> for BitmapBand<'_, P> {
272    #[inline]
273    fn row_and_alpha_mut(&mut self, y: u32) -> (&mut [u8], Option<&mut [u8]>) {
274        self.row_and_alpha_mut(y)
275    }
276}
277
278// ── Span drawing helpers ──────────────────────────────────────────────────────
279
280/// Emit a solid span into `sink` that is fully inside the clip — no per-pixel test.
281pub(super) fn draw_span<P: Pixel, S: RowSink<P>>(
282    sink: &mut S,
283    pipe: &PipeState<'_>,
284    src: &PipeSrc<'_>,
285    x0: i32,
286    x1: i32,
287    y: i32,
288) {
289    debug_assert!(x0 <= x1, "draw_span: x0={x0} > x1={x1}");
290    debug_assert!(
291        x0 >= 0,
292        "draw_span: x0={x0} is negative (caller must clamp before calling)"
293    );
294    debug_assert!(y >= 0, "draw_span: y={y} is negative");
295    #[expect(clippy::cast_sign_loss, reason = "y >= 0 asserted above")]
296    let y_u = y as u32;
297    #[expect(clippy::cast_sign_loss, reason = "x0 >= 0 asserted above")]
298    let byte_off = x0 as usize * P::BYTES;
299    #[expect(clippy::cast_sign_loss, reason = "x1 >= x0 >= 0 asserted above")]
300    let byte_end = (x1 as usize + 1) * P::BYTES;
301    #[expect(clippy::cast_sign_loss, reason = "x0 >= 0, x1 >= x0 asserted above")]
302    let alpha_range = x0 as usize..=x1 as usize;
303
304    let (row, alpha) = sink.row_and_alpha_mut(y_u);
305    let dst_pixels = &mut row[byte_off..byte_end];
306    let dst_alpha = alpha.map(|a| &mut a[alpha_range]);
307
308    pipe::render_span::<P>(pipe, src, dst_pixels, dst_alpha, None, x0, x1, y);
309}
310
311/// Emit a span with per-pixel clip test (partial clip region).
312pub(super) fn draw_span_clipped<P: Pixel, S: RowSink<P>>(
313    sink: &mut S,
314    clip: &Clip,
315    pipe: &PipeState<'_>,
316    src: &PipeSrc<'_>,
317    x0: i32,
318    x1: i32,
319    y: i32,
320) {
321    // Walk pixel by pixel, skipping those outside the clip.
322    // Batch contiguous inside-clip runs into single render_span calls.
323    let mut run_start: Option<i32> = None;
324
325    for x in x0..=x1 {
326        if clip.test(x, y) {
327            if run_start.is_none() {
328                run_start = Some(x);
329            }
330        } else if let Some(rs) = run_start.take() {
331            draw_span(sink, pipe, src, rs, x - 1, y);
332        }
333    }
334    if let Some(rs) = run_start {
335        draw_span(sink, pipe, src, rs, x1, y);
336    }
337}
338
339/// Emit one composited output pixel row from the 4-row `AaBuf`.
340///
341/// For each output pixel `x` in `[x0, x1]`, count the set bits across all 4
342/// AA sub-rows via `simd::aa_coverage_span` (SIMD-accelerated), look up the
343/// gamma-corrected shape byte, and call `render_span_aa` with shape > 0.
344fn draw_aa_line<P: Pixel>(
345    bitmap: &mut Bitmap<P>,
346    pipe: &PipeState<'_>,
347    src: &PipeSrc<'_>,
348    aa_buf: &AaBuf,
349    x0: i32,
350    x1: i32,
351    y: i32,
352) {
353    debug_assert!(x0 >= 0, "draw_aa_line: x0={x0} is negative");
354    debug_assert!(x0 <= x1, "draw_aa_line: x0={x0} > x1={x1}");
355    debug_assert!(y >= 0, "draw_aa_line: y={y} is negative");
356
357    #[expect(clippy::cast_sign_loss, reason = "x0 >= 0: asserted above")]
358    let x0_usize = x0 as usize;
359    #[expect(clippy::cast_sign_loss, reason = "x1 >= x0 >= 0")]
360    let count = (x1 - x0 + 1) as usize;
361
362    // Gather raw coverage counts (0..=16) into shape[], then gamma-map in place.
363    // Single allocation: aa_coverage_span writes raw counts; we overwrite with
364    // AA_GAMMA[t] in the same buffer (0 stays 0; non-zero gets the LUT value).
365    let rows = [
366        aa_buf.row_slice(0),
367        aa_buf.row_slice(1),
368        aa_buf.row_slice(2),
369        aa_buf.row_slice(3),
370    ];
371    let mut shape = vec![0u8; count];
372    simd::aa_coverage_span(rows, x0_usize, &mut shape);
373
374    // Gamma-map in place: 0 → 0 (skip), 1..=16 → AA_GAMMA[t].
375    let mut any_nonzero = false;
376    for s in &mut shape {
377        let t = *s as usize;
378        if t > 0 {
379            *s = AA_GAMMA[t];
380            any_nonzero = true;
381        }
382    }
383
384    if !any_nonzero {
385        return;
386    }
387
388    #[expect(clippy::cast_sign_loss, reason = "y >= 0")]
389    let y_u = y as u32;
390    let byte_off = x0_usize * P::BYTES;
391    #[expect(clippy::cast_sign_loss, reason = "x1 >= x0 >= 0")]
392    let byte_end = (x1 as usize + 1) * P::BYTES;
393    #[expect(clippy::cast_sign_loss, reason = "x1 >= x0 >= 0")]
394    let alpha_range = x0_usize..=x1 as usize;
395
396    let (row, alpha) = bitmap.row_and_alpha_mut(y_u);
397    let dst_pixels = &mut row[byte_off..byte_end];
398    let dst_alpha = alpha.map(|a| &mut a[alpha_range]);
399
400    pipe::render_span::<P>(pipe, src, dst_pixels, dst_alpha, Some(&shape), x0, x1, y);
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::bitmap::Bitmap;
407    use crate::path::PathBuilder;
408    use crate::pipe::PipeSrc;
409    use crate::testutil::{identity_matrix, make_clip, rect_path, simple_pipe};
410    use color::Rgb8;
411
412    #[test]
413    fn fill_rect_paints_solid() {
414        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 4, false);
415        let clip = make_clip(8, 8);
416        let pipe = simple_pipe();
417        let color = [200u8, 100, 50];
418        let src = PipeSrc::Solid(&color);
419        // Rect (1,1)→(5,5): horizontal edges at y=1 and y=5 have count=0 and
420        // produce no interior spans; interior rows are y=2,3,4.
421        // Columns span x=1..5 (the two vertical edges at x=1 and x=5).
422        let path = rect_path(1.0, 1.0, 5.0, 5.0);
423
424        fill::<Rgb8>(
425            &mut bmp,
426            &clip,
427            &path,
428            &pipe,
429            &src,
430            &identity_matrix(),
431            1.0,
432            false,
433        );
434
435        // Interior pixels (rows 2..4, cols 1..5) should be painted.
436        for y in 2..5u32 {
437            let row = bmp.row(y);
438            for (x, px) in row.iter().enumerate().skip(1).take(5) {
439                assert_eq!(px.r, 200, "y={y} x={x} R");
440                assert_eq!(px.g, 100, "y={y} x={x} G");
441                assert_eq!(px.b, 50, "y={y} x={x} B");
442            }
443        }
444
445        // Pixels outside should be untouched (zero).
446        assert_eq!(bmp.row(0)[0].r, 0, "row 0 should be untouched");
447        assert_eq!(bmp.row(1)[0].r, 0, "top edge row should be untouched");
448        assert_eq!(bmp.row(2)[0].r, 0, "x=0 should be untouched");
449    }
450
451    #[test]
452    fn fill_empty_path_is_noop() {
453        let mut bmp: Bitmap<Rgb8> = Bitmap::new(8, 8, 4, false);
454        let clip = make_clip(8, 8);
455        let pipe = simple_pipe();
456        let color = [255u8, 0, 0];
457        let src = PipeSrc::Solid(&color);
458        let path = PathBuilder::new().build(); // empty
459
460        fill::<Rgb8>(
461            &mut bmp,
462            &clip,
463            &path,
464            &pipe,
465            &src,
466            &identity_matrix(),
467            1.0,
468            false,
469        );
470
471        // Nothing should be painted.
472        assert_eq!(bmp.row(0)[0].r, 0);
473    }
474
475    #[test]
476    fn eo_fill_donut_leaves_interior_clear() {
477        // Even-odd rule: a square inside a larger square leaves the inner area unfilled.
478        let mut bmp: Bitmap<Rgb8> = Bitmap::new(10, 10, 4, false);
479        let clip = make_clip(10, 10);
480        let pipe = simple_pipe();
481        let color = [255u8, 0, 0];
482        let src = PipeSrc::Solid(&color);
483
484        // Outer square 1..8, inner square 3..6.
485        let mut b = PathBuilder::new();
486        b.move_to(1.0, 1.0).unwrap();
487        b.line_to(8.0, 1.0).unwrap();
488        b.line_to(8.0, 8.0).unwrap();
489        b.line_to(1.0, 8.0).unwrap();
490        b.close(true).unwrap();
491        b.move_to(3.0, 3.0).unwrap();
492        b.line_to(6.0, 3.0).unwrap();
493        b.line_to(6.0, 6.0).unwrap();
494        b.line_to(3.0, 6.0).unwrap();
495        b.close(true).unwrap();
496        let path = b.build();
497
498        eo_fill::<Rgb8>(
499            &mut bmp,
500            &clip,
501            &path,
502            &pipe,
503            &src,
504            &identity_matrix(),
505            1.0,
506            false,
507        );
508
509        // Interior (4,4) should be unpainted.
510        assert_eq!(bmp.row(4)[4].r, 0, "interior should be clear with EO rule");
511        // Band (2, 2) should be painted.
512        assert_eq!(bmp.row(2)[2].r, 255, "outer band should be painted");
513    }
514
515    #[test]
516    fn aa_gamma_table_correct() {
517        // Validate every entry against the defining formula:
518        // round((i / (AA_SIZE² )) ^ 1.5 * 255).  Derive the divisor from the
519        // table's own length so the test stays correct if AA_SIZE changes.
520        let max_idx = AA_GAMMA.len() - 1; // == AA_SIZE * AA_SIZE
521        #[expect(
522            clippy::cast_precision_loss,
523            reason = "AA_GAMMA.len() ≤ 65 in practice; f64 represents it exactly"
524        )]
525        let divisor = max_idx as f64;
526        for (i, &actual) in AA_GAMMA.iter().enumerate() {
527            // i ∈ [0, max_idx]; (i/max_idx)^1.5 ∈ [0,1]; ×255 ∈ [0,255]; round
528            // to non-negative integer in [0,255] — the `as u8` is provably in range.
529            #[expect(
530                clippy::cast_possible_truncation,
531                clippy::cast_sign_loss,
532                clippy::cast_precision_loss,
533                reason = "value is f64::round() of a [0,255]-bounded expression"
534            )]
535            let expected = ((i as f64 / divisor).powf(1.5) * 255.0).round() as u8;
536            assert_eq!(actual, expected, "AA_GAMMA[{i}]: expected {expected}");
537        }
538    }
539
540    #[test]
541    fn scanner_produces_spans_for_rect() {
542        use crate::scanner::XPathScanner;
543        use crate::scanner::iter::ScanIterator;
544        use crate::xpath::XPath;
545
546        let path = rect_path(1.0, 1.0, 5.0, 5.0);
547        let xpath = XPath::new(&path, &identity_matrix(), 1.0, true);
548        let scanner = XPathScanner::new(&xpath, false, 0, 7);
549        // Interior rows 2,3,4 should have spans; boundary rows 1,5 have horizontal
550        // edges (count=0) and produce no interior spans.
551        assert!(
552            ScanIterator::new(&scanner, 2).next().is_some(),
553            "no spans at y=2"
554        );
555        assert!(
556            ScanIterator::new(&scanner, 3).next().is_some(),
557            "no spans at y=3"
558        );
559        assert!(
560            ScanIterator::new(&scanner, 4).next().is_some(),
561            "no spans at y=4"
562        );
563    }
564
565    // ── Parallel fill tests ───────────────────────────────────────────────────
566
567    #[cfg(feature = "rayon")]
568    mod parallel {
569        use super::*;
570        use crate::fill::{eo_fill_parallel, fill_parallel};
571
572        /// Parallel fill (n_bands=4) must produce identical pixel output to
573        /// sequential fill for a large rectangle.
574        #[test]
575        fn fill_parallel_matches_sequential() {
576            const W: u32 = 64;
577            const H: u32 = 512;
578
579            let mut seq: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
580            let mut par: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
581
582            let clip = make_clip(W, H);
583            let pipe = simple_pipe();
584            let color = [77u8, 155, 211];
585            let src = PipeSrc::Solid(&color);
586            let path = rect_path(4.0, 4.0, 60.0, 508.0);
587            let matrix = identity_matrix();
588
589            fill::<Rgb8>(&mut seq, &clip, &path, &pipe, &src, &matrix, 1.0, false);
590            fill_parallel::<Rgb8>(&mut par, &clip, &path, &pipe, &src, &matrix, 1.0, false, 4);
591
592            assert_eq!(
593                seq.data(),
594                par.data(),
595                "parallel fill output differs from sequential"
596            );
597        }
598
599        /// A single-band parallel fill must produce identical output to sequential fill.
600        #[test]
601        fn fill_parallel_single_band_is_sequential() {
602            const W: u32 = 32;
603            const H: u32 = 512;
604
605            let mut seq: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
606            let mut par: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
607
608            let clip = make_clip(W, H);
609            let pipe = simple_pipe();
610            let color = [33u8, 66, 99];
611            let src = PipeSrc::Solid(&color);
612            let path = rect_path(2.0, 2.0, 30.0, 510.0);
613            let matrix = identity_matrix();
614
615            fill::<Rgb8>(&mut seq, &clip, &path, &pipe, &src, &matrix, 1.0, false);
616            // n_bands=1 must behave identically to sequential.
617            fill_parallel::<Rgb8>(&mut par, &clip, &path, &pipe, &src, &matrix, 1.0, false, 1);
618
619            assert_eq!(
620                seq.data(),
621                par.data(),
622                "single-band parallel fill output differs from sequential"
623            );
624        }
625
626        /// Parallel even-odd fill (n_bands=4) must match sequential eo_fill.
627        #[test]
628        fn eo_fill_parallel_matches_sequential() {
629            const W: u32 = 64;
630            const H: u32 = 512;
631
632            let mut seq: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
633            let mut par: Bitmap<Rgb8> = Bitmap::new(W, H, 1, false);
634
635            let clip = make_clip(W, H);
636            let pipe = simple_pipe();
637            let color = [200u8, 100, 50];
638            let src = PipeSrc::Solid(&color);
639
640            // Donut: outer 4→60, inner 16→48 → EO fills only the ring.
641            let mut b = PathBuilder::new();
642            b.move_to(4.0, 4.0).unwrap();
643            b.line_to(60.0, 4.0).unwrap();
644            b.line_to(60.0, 508.0).unwrap();
645            b.line_to(4.0, 508.0).unwrap();
646            b.close(true).unwrap();
647            b.move_to(16.0, 16.0).unwrap();
648            b.line_to(48.0, 16.0).unwrap();
649            b.line_to(48.0, 496.0).unwrap();
650            b.line_to(16.0, 496.0).unwrap();
651            b.close(true).unwrap();
652            let path = b.build();
653            let matrix = identity_matrix();
654
655            eo_fill::<Rgb8>(&mut seq, &clip, &path, &pipe, &src, &matrix, 1.0, false);
656            eo_fill_parallel::<Rgb8>(&mut par, &clip, &path, &pipe, &src, &matrix, 1.0, false, 4);
657
658            assert_eq!(
659                seq.data(),
660                par.data(),
661                "parallel eo_fill output differs from sequential"
662            );
663        }
664    }
665}