Skip to main content

oxiui_render_soft/
scanline.rs

1//! Active-Edge-Table (AET) scanline rasteriser for polygon and triangle fill.
2//!
3//! Supports sub-pixel vertical-supersample coverage anti-aliasing, even-odd
4//! fill rule, and non-zero winding fill rule. Both are computed by the same
5//! AET machinery — the rule only changes how the accumulated winding count is
6//! interpreted per scanline span.
7
8use crate::framebuffer::Framebuffer;
9use oxiui_core::Color;
10
11/// How self-intersecting or overlapping sub-paths are filled.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
13pub enum FillRule {
14    /// Standard even-odd rule: regions enclosed by an odd number of boundary
15    /// crossings are filled; even crossings produce holes.
16    EvenOdd,
17    /// Non-zero winding rule: a point is inside if the signed crossing count is
18    /// non-zero.
19    #[default]
20    NonZero,
21}
22
23// ---------------------------------------------------------------------------
24// Internal AET machinery
25// ---------------------------------------------------------------------------
26
27/// A single edge for the active-edge-table algorithm, stored in float for
28/// sub-pixel accuracy.
29#[derive(Clone, Debug)]
30pub(crate) struct Edge {
31    /// Current X position at the current scanline's top.
32    x: f32,
33    /// X increment per scanline (dx/dy).
34    dx: f32,
35    /// Scanline at which this edge ends (exclusive).
36    y_max: i32,
37    /// Winding contribution: +1 for upward, -1 for downward (used by
38    /// non-zero winding rule).
39    winding: i32,
40}
41
42// ---------------------------------------------------------------------------
43// Rasterizer scratch buffers — reused across fills to avoid per-polygon alloc
44// ---------------------------------------------------------------------------
45
46/// Scratch buffers owned by a rasterizer, reused across polygon fills.
47///
48/// Call [`RasterizerScratch::clear`] at the start of each fill instead of
49/// creating fresh `Vec`s.
50pub struct RasterizerScratch {
51    /// Global edge table (y_start, Edge) pairs for the current polygon.
52    pub(crate) global_edges: Vec<(i32, Edge)>,
53    /// Active edge table for the current scanline.
54    pub(crate) active: Vec<Edge>,
55}
56
57impl RasterizerScratch {
58    /// Allocate empty scratch buffers.
59    pub fn new() -> Self {
60        Self {
61            global_edges: Vec::new(),
62            active: Vec::new(),
63        }
64    }
65
66    /// Clear both buffers for the next polygon fill (no heap deallocation).
67    pub fn clear(&mut self) {
68        self.global_edges.clear();
69        self.active.clear();
70    }
71}
72
73impl Default for RasterizerScratch {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79/// A scanline rasterizer that owns reusable scratch buffers so per-polygon
80/// heap allocation is avoided.
81///
82/// Use [`Rasterizer::fill_polygon`] / [`Rasterizer::fill_polygon_clipped`]
83/// in performance-critical loops. The free-standing functions in this module
84/// delegate here and are kept for API stability.
85pub struct Rasterizer {
86    scratch: RasterizerScratch,
87}
88
89impl Rasterizer {
90    /// Create a new rasterizer with empty scratch buffers.
91    pub fn new() -> Self {
92        Self {
93            scratch: RasterizerScratch::new(),
94        }
95    }
96
97    /// Fill a polygon into `fb`, reusing internal scratch buffers.
98    pub fn fill_polygon(
99        &mut self,
100        fb: &mut Framebuffer,
101        points: &[(f32, f32)],
102        color: Color,
103        fill_rule: FillRule,
104        aa: bool,
105    ) {
106        fill_polygon_with_scratch(fb, points, color, fill_rule, aa, &mut self.scratch);
107    }
108
109    /// Fill a polygon into `fb`, clipped to `clip`, reusing internal scratch.
110    pub fn fill_polygon_clipped(
111        &mut self,
112        fb: &mut Framebuffer,
113        points: &[(f32, f32)],
114        color: Color,
115        fill_rule: FillRule,
116        aa: bool,
117        clip: crate::clip::ClipRect,
118    ) {
119        fill_polygon_clipped_with_scratch(
120            fb,
121            points,
122            color,
123            fill_rule,
124            aa,
125            clip,
126            &mut self.scratch,
127        );
128    }
129}
130
131impl Default for Rasterizer {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137/// Build the global edge table from a polygon vertex list, appending into `out`.
138///
139/// Each consecutive pair of vertices forms an edge; the last vertex connects
140/// back to the first to close the polygon. Horizontal edges (dy == 0) are
141/// skipped. `out` is *not* cleared by this function — callers must clear it
142/// before calling if a fresh table is needed.
143fn build_edges_into(points: &[(f32, f32)], out: &mut Vec<(i32, Edge)>) {
144    let n = points.len();
145    if n < 2 {
146        return;
147    }
148    out.reserve(n);
149    for i in 0..n {
150        let (x0, y0) = points[i];
151        let (x1, y1) = points[(i + 1) % n];
152        if (y0 - y1).abs() < f32::EPSILON {
153            // Horizontal — skip.
154            continue;
155        }
156        let (top_y, bot_y, top_x, bot_x, winding) = if y0 < y1 {
157            (y0, y1, x0, x1, 1i32)
158        } else {
159            (y1, y0, x1, x0, -1i32)
160        };
161        let dy = bot_y - top_y;
162        let dx = (bot_x - top_x) / dy;
163        let y_start = top_y.ceil() as i32;
164        // Sub-pixel correction: move x to the y_start scanline centre.
165        let x_at_start = top_x + dx * (y_start as f32 - top_y);
166        let y_max = bot_y.ceil() as i32;
167        out.push((
168            y_start,
169            Edge {
170                x: x_at_start,
171                dx,
172                y_max,
173                winding,
174            },
175        ));
176    }
177}
178
179/// Compute the alpha (coverage) for a single pixel column within a span,
180/// given supersample coverage fraction.
181///
182/// This is a simple closed-form computation: sample 8 sub-rows per pixel
183/// to get fractional coverage at the span boundary. The interior receives 1.0.
184#[inline]
185fn span_coverage(left: f32, right: f32, px: f32) -> f32 {
186    // Pixel occupies [px, px+1).
187    let pl = px;
188    let pr = px + 1.0;
189    if right <= pl || left >= pr {
190        return 0.0;
191    }
192    let cl = left.max(pl);
193    let cr = right.min(pr);
194    (cr - cl).clamp(0.0, 1.0)
195}
196
197/// Core fill implementation that reuses caller-provided scratch buffers.
198fn fill_polygon_with_scratch(
199    fb: &mut Framebuffer,
200    points: &[(f32, f32)],
201    color: Color,
202    fill_rule: FillRule,
203    aa: bool,
204    scratch: &mut RasterizerScratch,
205) {
206    if points.len() < 3 {
207        return;
208    }
209
210    // Bounding box.
211    let (mut min_y, mut max_y) = (f32::INFINITY, f32::NEG_INFINITY);
212    for &(_, y) in points {
213        if y < min_y {
214            min_y = y;
215        }
216        if y > max_y {
217            max_y = y;
218        }
219    }
220    // `as i32` on floats saturates to i32::MIN/MAX (stable Rust cast
221    // semantics), so these stay finite even for +-inf or absurd vertex
222    // coordinates — but the *range* itself can still span billions of rows.
223    let raw_y_start = min_y.floor() as i32;
224    let raw_y_end = max_y.ceil() as i32;
225
226    // Clamp the scanline range to the framebuffer height so a wildly
227    // out-of-range vertex Y cannot force the fill loop below to run
228    // billions of empty iterations. `height_i32` itself is clamped first so
229    // the `clamp` calls can never overflow for a (hypothetical) oversized
230    // framebuffer.
231    let height_i32 = fb.height().min(i32::MAX as u32) as i32;
232    let y_start = raw_y_start.clamp(0, height_i32);
233    let y_end = raw_y_end.clamp(y_start, height_i32);
234
235    // Build global edge table into scratch buffer.
236    scratch.clear();
237    build_edges_into(points, &mut scratch.global_edges);
238
239    // Sort global edge table by y_start ascending, then by x.
240    scratch.global_edges.sort_by(|a, b| {
241        a.0.cmp(&b.0).then(
242            a.1.x
243                .partial_cmp(&b.1.x)
244                .unwrap_or(core::cmp::Ordering::Equal),
245        )
246    });
247
248    let mut gi = 0usize;
249
250    // Fast-forward: activate every edge whose (unclamped) `y_start` lies
251    // above the clamped `y_start`, advancing its X to the clamped row
252    // directly via the edge slope. This is O(edge count), not O(rows), so a
253    // polygon whose top is far above the clamped range never costs more
254    // than one pass over its edges, while the visible fill is unaffected.
255    while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 < y_start {
256        let (edge_y_start, edge) = &scratch.global_edges[gi];
257        if edge.y_max > y_start {
258            let mut e = edge.clone();
259            e.x += e.dx * (y_start - edge_y_start) as f32;
260            scratch.active.push(e);
261        }
262        gi += 1;
263    }
264
265    for y in y_start..y_end {
266        // Add edges whose y_start == y.
267        while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 == y {
268            scratch.active.push(scratch.global_edges[gi].1.clone());
269            gi += 1;
270        }
271
272        // Remove expired edges.
273        scratch.active.retain(|e| e.y_max > y);
274
275        // Sort active edges by current x.
276        scratch
277            .active
278            .sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(core::cmp::Ordering::Equal));
279
280        // Fill spans using the chosen fill rule.
281        fill_spans(
282            fb,
283            &scratch.active,
284            y as u32,
285            y as f32,
286            color,
287            fill_rule,
288            aa,
289        );
290
291        // Advance active edges.
292        for e in &mut scratch.active {
293            e.x += e.dx;
294        }
295    }
296}
297
298/// Fill a polygon (closed contour) defined by `points` into `fb`.
299///
300/// `points` is interpreted as an ordered list of (x, y) vertices; the polygon
301/// is implicitly closed (last → first). Uses a vertical-supersample AET for
302/// sub-pixel AA.
303///
304/// Does nothing if there are fewer than 3 points.
305///
306/// For tight loops over many polygons, prefer [`Rasterizer::fill_polygon`]
307/// which reuses scratch buffers across calls.
308pub fn fill_polygon(
309    fb: &mut Framebuffer,
310    points: &[(f32, f32)],
311    color: Color,
312    fill_rule: FillRule,
313    aa: bool,
314) {
315    let mut scratch = RasterizerScratch::new();
316    fill_polygon_with_scratch(fb, points, color, fill_rule, aa, &mut scratch);
317}
318
319/// Process active edges for one scanline using the chosen fill rule.
320fn fill_spans(
321    fb: &mut Framebuffer,
322    active: &[Edge],
323    y: u32,
324    _y_float: f32,
325    color: Color,
326    fill_rule: FillRule,
327    aa: bool,
328) {
329    if y >= fb.height() || active.is_empty() {
330        return;
331    }
332
333    match fill_rule {
334        FillRule::EvenOdd => fill_even_odd(fb, active, y, color, aa),
335        FillRule::NonZero => fill_non_zero(fb, active, y, color, aa),
336    }
337}
338
339/// Even-odd rule: pairs of edges delimit filled spans.
340fn fill_even_odd(fb: &mut Framebuffer, active: &[Edge], y: u32, color: Color, aa: bool) {
341    let mut i = 0;
342    while i + 1 < active.len() {
343        let left = active[i].x;
344        let right = active[i + 1].x;
345        if right > left {
346            paint_span(fb, left, right, y, color, aa);
347        }
348        i += 2;
349    }
350}
351
352/// Non-zero winding rule: accumulate winding; fill while winding != 0.
353fn fill_non_zero(fb: &mut Framebuffer, active: &[Edge], y: u32, color: Color, aa: bool) {
354    let mut winding = 0i32;
355    let mut fill_start: Option<f32> = None;
356
357    for edge in active {
358        let prev_winding = winding;
359        winding += edge.winding;
360
361        if prev_winding == 0 && winding != 0 {
362            // Entering a filled region.
363            fill_start = Some(edge.x);
364        } else if prev_winding != 0 && winding == 0 {
365            // Leaving a filled region.
366            if let Some(start) = fill_start.take() {
367                let end = edge.x;
368                if end > start {
369                    paint_span(fb, start, end, y, color, aa);
370                }
371            }
372        }
373    }
374}
375
376/// Paint a horizontal span `[left, right)` on row `y`, with optional edge AA.
377fn paint_span(fb: &mut Framebuffer, left: f32, right: f32, y: u32, color: Color, aa: bool) {
378    if y >= fb.height() {
379        return;
380    }
381    // Clamp the span to the framebuffer width up front. `left`/`right` are
382    // raw float edge coordinates that can be arbitrarily large (an extreme
383    // edge slope or vertex X), so without this the `x0..x1` loop below could
384    // run billions of no-op iterations before a per-pixel bounds check ever
385    // discarded anything.
386    let width_i32 = fb.width().min(i32::MAX as u32) as i32;
387    let x0 = (left.floor() as i32).clamp(0, width_i32);
388    let x1 = (right.ceil() as i32).clamp(x0, width_i32);
389    let Color(cr, cg, cb, ca) = color;
390
391    for px in x0..x1 {
392        // `px` is guaranteed to lie in `[0, fb.width())` by the clamp above.
393        let coverage = if aa {
394            span_coverage(left, right, px as f32)
395        } else {
396            // Hard edge: any pixel whose centre is inside the span.
397            let centre = px as f32 + 0.5;
398            if centre >= left && centre < right {
399                1.0
400            } else {
401                0.0
402            }
403        };
404        if coverage <= 0.0 {
405            continue;
406        }
407        let alpha = (ca as f32 * coverage).round() as u8;
408        fb.blend(
409            px as u32,
410            y,
411            crate::framebuffer::pack_rgba(cr, cg, cb, alpha),
412        );
413    }
414}
415
416/// Core clipped-fill implementation that reuses caller-provided scratch buffers.
417fn fill_polygon_clipped_with_scratch(
418    fb: &mut Framebuffer,
419    points: &[(f32, f32)],
420    color: Color,
421    fill_rule: FillRule,
422    aa: bool,
423    clip: crate::clip::ClipRect,
424    scratch: &mut RasterizerScratch,
425) {
426    if points.len() < 3 {
427        return;
428    }
429
430    // Bounding box.
431    let (mut min_y, mut max_y) = (f32::INFINITY, f32::NEG_INFINITY);
432    for &(_, y) in points {
433        if y < min_y {
434            min_y = y;
435        }
436        if y > max_y {
437            max_y = y;
438        }
439    }
440    // `as i32` on floats saturates to i32::MIN/MAX, so these stay finite
441    // even for +-inf or absurd vertex coordinates — but the *range* can
442    // still span billions of rows before being intersected with the clip.
443    let raw_y_start = min_y.floor() as i32;
444    let raw_y_end = max_y.ceil() as i32;
445
446    // Clamp the scanline range to both the clip rectangle and the
447    // framebuffer height so neither a wildly out-of-range vertex Y nor an
448    // oversized clip rectangle can force the fill loop below to run
449    // billions of empty iterations. `height_i32` (and the clip bounds
450    // derived from it) are clamped first so none of the arithmetic here can
451    // overflow.
452    let height_i32 = fb.height().min(i32::MAX as u32) as i32;
453    let clip_y0 = clip.y0.clamp(0, height_i32 as i64) as i32;
454    let clip_y1 = clip.y1.clamp(0, height_i32 as i64) as i32;
455    let y_clip_start = raw_y_start.max(clip_y0);
456    let y_end = raw_y_end.min(clip_y1);
457
458    scratch.clear();
459    build_edges_into(points, &mut scratch.global_edges);
460    scratch.global_edges.sort_by(|a, b| {
461        a.0.cmp(&b.0).then(
462            a.1.x
463                .partial_cmp(&b.1.x)
464                .unwrap_or(core::cmp::Ordering::Equal),
465        )
466    });
467
468    let mut gi = 0usize;
469
470    // Fast-forward: activate every edge whose (unclamped) `y_start` lies
471    // above the clamped start row, advancing its X to that row directly via
472    // the edge slope instead of visiting each intermediate row. O(edge
473    // count), not O(rows).
474    while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 < y_clip_start {
475        let (edge_y_start, edge) = &scratch.global_edges[gi];
476        if edge.y_max > y_clip_start {
477            let mut e = edge.clone();
478            e.x += e.dx * (y_clip_start - edge_y_start) as f32;
479            scratch.active.push(e);
480        }
481        gi += 1;
482    }
483
484    for y in y_clip_start..y_end {
485        while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 == y {
486            scratch.active.push(scratch.global_edges[gi].1.clone());
487            gi += 1;
488        }
489        scratch.active.retain(|e| e.y_max > y);
490        scratch
491            .active
492            .sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(core::cmp::Ordering::Equal));
493        fill_spans_clipped(fb, &scratch.active, y as u32, color, fill_rule, aa, &clip);
494        for e in &mut scratch.active {
495            e.x += e.dx;
496        }
497    }
498}
499
500/// Fill a polygon clipped to a [`crate::clip::ClipRect`].
501///
502/// This is the same as [`fill_polygon`] but additionally clips to `clip`.
503///
504/// For tight loops over many polygons, prefer [`Rasterizer::fill_polygon_clipped`]
505/// which reuses scratch buffers across calls.
506pub fn fill_polygon_clipped(
507    fb: &mut Framebuffer,
508    points: &[(f32, f32)],
509    color: Color,
510    fill_rule: FillRule,
511    aa: bool,
512    clip: crate::clip::ClipRect,
513) {
514    let mut scratch = RasterizerScratch::new();
515    fill_polygon_clipped_with_scratch(fb, points, color, fill_rule, aa, clip, &mut scratch);
516}
517
518/// Like `fill_spans` but clips each span to `clip`.
519fn fill_spans_clipped(
520    fb: &mut Framebuffer,
521    active: &[Edge],
522    y: u32,
523    color: Color,
524    fill_rule: FillRule,
525    aa: bool,
526    clip: &crate::clip::ClipRect,
527) {
528    if y >= fb.height() || (y as i64) < clip.y0 || (y as i64) >= clip.y1 || active.is_empty() {
529        return;
530    }
531    match fill_rule {
532        FillRule::EvenOdd => {
533            let mut i = 0;
534            while i + 1 < active.len() {
535                let left = active[i].x.max(clip.x0 as f32);
536                let right = active[i + 1].x.min(clip.x1 as f32);
537                if right > left {
538                    paint_span(fb, left, right, y, color, aa);
539                }
540                i += 2;
541            }
542        }
543        FillRule::NonZero => {
544            let mut winding = 0i32;
545            let mut fill_start: Option<f32> = None;
546            for edge in active {
547                let prev_winding = winding;
548                winding += edge.winding;
549                if prev_winding == 0 && winding != 0 {
550                    fill_start = Some(edge.x.max(clip.x0 as f32));
551                } else if prev_winding != 0 && winding == 0 {
552                    if let Some(start) = fill_start.take() {
553                        let end = edge.x.min(clip.x1 as f32);
554                        if end > start {
555                            paint_span(fb, start, end, y, color, aa);
556                        }
557                    }
558                }
559            }
560        }
561    }
562}
563
564/// Fill a triangle defined by three points into `fb` with coverage AA.
565///
566/// Delegates to [`fill_polygon`] with three vertices.
567pub fn fill_triangle(
568    fb: &mut Framebuffer,
569    p0: (f32, f32),
570    p1: (f32, f32),
571    p2: (f32, f32),
572    color: Color,
573) {
574    fill_polygon(fb, &[p0, p1, p2], color, FillRule::NonZero, true);
575}
576
577// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::framebuffer::Framebuffer;
585
586    fn fresh(w: u32, h: u32) -> Framebuffer {
587        Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
588    }
589
590    #[test]
591    fn triangle_fill_coverage() {
592        let mut fb = fresh(20, 20);
593        // Fill a large triangle covering roughly half the buffer.
594        fill_triangle(
595            &mut fb,
596            (0.0, 0.0),
597            (20.0, 0.0),
598            (10.0, 20.0),
599            Color(255, 255, 255, 255),
600        );
601        // Count non-background pixels.
602        let mut count = 0u32;
603        for y in 0..20 {
604            for x in 0..20 {
605                let (r, _, _, _) = fb.get_rgba(x, y).unwrap_or((0, 0, 0, 0));
606                if r > 0 {
607                    count += 1;
608                }
609            }
610        }
611        // The triangle should fill at least 50 pixels in a 20x20 buffer.
612        assert!(count >= 50, "expected >= 50 filled pixels, got {count}");
613    }
614
615    #[test]
616    fn polygon_fill_even_odd() {
617        // 5-pointed star: a self-intersecting polygon where even-odd leaves the
618        // center pentagon empty, while non-zero fills it.
619        //
620        // Use a large star (radius=30, centre=35,35) so individual arms are wide.
621        let cx = 35.0f32;
622        let cy = 35.0f32;
623        let r = 30.0f32;
624        let star: Vec<(f32, f32)> = (0..5)
625            .map(|i| {
626                // "Skip-one" order produces a 5-pointed star.
627                let angle = std::f32::consts::PI * (2.0 * (2 * i) as f32 / 5.0 - 0.5);
628                (cx + r * angle.cos(), cy + r * angle.sin())
629            })
630            .collect();
631
632        let mut fb_eo = fresh(70, 70);
633        let mut fb_nz = fresh(70, 70);
634        fill_polygon(
635            &mut fb_eo,
636            &star,
637            Color(255, 0, 0, 255),
638            FillRule::EvenOdd,
639            false,
640        );
641        fill_polygon(
642            &mut fb_nz,
643            &star,
644            Color(255, 0, 0, 255),
645            FillRule::NonZero,
646            false,
647        );
648
649        // The top arm of the star spans roughly x=[32,38] at y=8 (well inside the arm).
650        let (r_eo_tip, _, _, _) = fb_eo.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
651        let (r_nz_tip, _, _, _) = fb_nz.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
652        assert!(r_eo_tip > 0, "EvenOdd: star arm should be painted");
653        assert!(r_nz_tip > 0, "NonZero: star arm should be painted");
654
655        // Center pentagon: NonZero should fill it; EvenOdd should leave it empty.
656        let (r_nz_ctr, _, _, _) = fb_nz.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
657        assert!(r_nz_ctr > 0, "NonZero: star center must be filled");
658        let (r_eo_ctr, _, _, _) = fb_eo.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
659        assert_eq!(
660            r_eo_ctr, 0,
661            "EvenOdd: star center should be a hole (r={r_eo_ctr})"
662        );
663    }
664
665    #[test]
666    fn fill_rect_via_polygon() {
667        let mut fb = fresh(10, 10);
668        let pts = [(2.0f32, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)];
669        fill_polygon(
670            &mut fb,
671            &pts,
672            Color(0, 255, 0, 255),
673            FillRule::NonZero,
674            false,
675        );
676        // Centre should be green.
677        assert_eq!(fb.get_rgba(5, 5), Some((0, 255, 0, 255)));
678        // Corner outside should be black.
679        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
680    }
681
682    #[test]
683    fn fill_rule_noop_on_empty_polygon() {
684        let mut fb = fresh(5, 5);
685        fill_polygon(
686            &mut fb,
687            &[],
688            Color(255, 0, 0, 255),
689            FillRule::NonZero,
690            false,
691        );
692        // Nothing should change.
693        assert_eq!(fb.get_rgba(2, 2), Some((0, 0, 0, 255)));
694    }
695
696    // -----------------------------------------------------------------------
697    // S2: scanline buffer reuse regression test
698    // -----------------------------------------------------------------------
699
700    #[test]
701    fn test_scanline_reuse_output_identical() {
702        // Regression test: filling a triangle into a 100×100 framebuffer twice
703        // using the `Rasterizer` (scratch-reuse path) must produce byte-identical
704        // results across both calls.  This verifies that `scratch.clear()` fully
705        // resets state between fills so stale edges never contaminate the next fill.
706        let triangle: &[(f32, f32)] = &[(10.0, 90.0), (50.0, 10.0), (90.0, 90.0)];
707        let color = Color(200, 100, 50, 255);
708
709        // First fill — using the reuse-scratch Rasterizer.
710        let mut fb1 = fresh(100, 100);
711        let mut ras = Rasterizer::new();
712        ras.fill_polygon(&mut fb1, triangle, color, FillRule::NonZero, true);
713
714        // Second fill into a fresh buffer — same Rasterizer, scratch is reused.
715        let mut fb2 = fresh(100, 100);
716        ras.fill_polygon(&mut fb2, triangle, color, FillRule::NonZero, true);
717
718        // Results must be byte-identical.
719        for y in 0..100 {
720            for x in 0..100 {
721                let p1 = fb1.get_rgba(x, y);
722                let p2 = fb2.get_rgba(x, y);
723                assert_eq!(
724                    p1, p2,
725                    "pixel ({x},{y}) differs: first={p1:?} second={p2:?}"
726                );
727            }
728        }
729
730        // Also verify the public free-fn path gives the same pixels (API stability).
731        let mut fb3 = fresh(100, 100);
732        fill_polygon(&mut fb3, triangle, color, FillRule::NonZero, true);
733        for y in 0..100 {
734            for x in 0..100 {
735                let p1 = fb1.get_rgba(x, y);
736                let p3 = fb3.get_rgba(x, y);
737                assert_eq!(
738                    p1, p3,
739                    "pixel ({x},{y}): Rasterizer={p1:?} vs free fn={p3:?}"
740                );
741            }
742        }
743    }
744
745    // -----------------------------------------------------------------------
746    // Security regression: unclamped raster spans (billions-of-iterations DoS)
747    // -----------------------------------------------------------------------
748
749    #[test]
750    fn fill_polygon_far_out_of_bounds_is_clamped_and_fast() {
751        // A quad whose vertices sit ~1e9 units outside a tiny framebuffer.
752        // Before the vertical/horizontal span clamps were added, the scanline
753        // loop iterated the *raw* vertex extent (billions of rows/columns)
754        // instead of the framebuffer's actual size, so this call would take
755        // an unreasonable amount of time (effectively a DoS from one
756        // attacker-controlled coordinate). With the fix, the fill must be
757        // bounded by the framebuffer's own dimensions and complete quickly.
758        let mut fb = fresh(10, 10);
759        let huge = 1.0e9_f32;
760        let pts = [(-huge, -huge), (huge, -huge), (huge, huge), (-huge, huge)];
761        let start = std::time::Instant::now();
762        fill_polygon(
763            &mut fb,
764            &pts,
765            Color(255, 0, 0, 255),
766            FillRule::NonZero,
767            false,
768        );
769        let elapsed = start.elapsed();
770        assert!(
771            elapsed < std::time::Duration::from_secs(2),
772            "fill_polygon took {elapsed:?} for far-out-of-bounds coordinates; \
773             the vertical/horizontal span is not being clamped to the framebuffer"
774        );
775        // The quad fully covers the framebuffer, so every in-bounds pixel
776        // must be painted — clamping must not silently drop the fill.
777        for y in 0..10 {
778            for x in 0..10 {
779                assert_eq!(
780                    fb.get_rgba(x, y),
781                    Some((255, 0, 0, 255)),
782                    "pixel ({x},{y}) should be filled"
783                );
784            }
785        }
786    }
787
788    #[test]
789    fn fill_polygon_clipped_far_out_of_bounds_is_clamped_and_fast() {
790        // Same DoS scenario as above but through the clip-aware fill path,
791        // which has its own (previously unclamped) vertical loop.
792        let mut fb = fresh(10, 10);
793        let huge = 1.0e9_f32;
794        let pts = [(-huge, -huge), (huge, -huge), (huge, huge), (-huge, huge)];
795        let clip = crate::clip::ClipRect::full(10, 10);
796        let start = std::time::Instant::now();
797        fill_polygon_clipped(
798            &mut fb,
799            &pts,
800            Color(0, 0, 255, 255),
801            FillRule::NonZero,
802            false,
803            clip,
804        );
805        let elapsed = start.elapsed();
806        assert!(
807            elapsed < std::time::Duration::from_secs(2),
808            "fill_polygon_clipped took {elapsed:?} for far-out-of-bounds coordinates"
809        );
810        for y in 0..10 {
811            for x in 0..10 {
812                assert_eq!(fb.get_rgba(x, y), Some((0, 0, 255, 255)));
813            }
814        }
815    }
816
817    #[test]
818    fn paint_span_far_out_of_bounds_x_is_clamped() {
819        // Directly exercises `paint_span`'s horizontal clamp: a span whose
820        // edges are far outside the framebuffer width must not attempt to
821        // iterate that raw (billions-wide) pixel range.
822        let mut fb = fresh(8, 8);
823        let start = std::time::Instant::now();
824        paint_span(&mut fb, -1.0e9, 1.0e9, 3, Color(10, 20, 30, 255), false);
825        let elapsed = start.elapsed();
826        assert!(
827            elapsed < std::time::Duration::from_secs(2),
828            "paint_span took {elapsed:?} for a far-out-of-bounds span"
829        );
830        // Row 3 should be fully painted (span covers the whole width) and
831        // every other row must remain untouched.
832        for x in 0..8 {
833            assert_eq!(fb.get_rgba(x, 3), Some((10, 20, 30, 255)));
834        }
835        assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
836    }
837
838    #[test]
839    fn fill_polygon_partial_offscreen_top_still_paints_visible_rows() {
840        // Regression for the fast-forward logic added alongside the vertical
841        // clamp: a shape that starts well above the framebuffer but extends
842        // into the visible area must still be filled correctly for the
843        // visible rows (clamping the loop start must not skip edges that
844        // were already active when the visible range begins).
845        let mut fb = fresh(10, 10);
846        let pts = [(2.0f32, -1000.0), (8.0, -1000.0), (8.0, 5.0), (2.0, 5.0)];
847        fill_polygon(
848            &mut fb,
849            &pts,
850            Color(0, 255, 0, 255),
851            FillRule::NonZero,
852            false,
853        );
854        for y in 0..5 {
855            assert_eq!(
856                fb.get_rgba(5, y),
857                Some((0, 255, 0, 255)),
858                "row {y} should be filled by the far-off-screen rectangle"
859            );
860        }
861        // Below the rectangle's bottom edge (y=5) should be untouched.
862        assert_eq!(fb.get_rgba(5, 9), Some((0, 0, 0, 255)));
863    }
864}