Skip to main content

rasterrocket_render/scanner/
mod.rs

1//! Scanline intersection lists for path fill and clip.
2//!
3//! [`XPathScanner`] converts an [`XPath`] edge table into per-scanline
4//! intersection spans, which are then consumed by `ScanIterator` (a
5//! private iterator type) to emit `(x0, x1)` spans for compositing.
6//!
7//! ## Design vs. C++ original
8//!
9//! The C++ `SplashXPathScanner` stores `std::vector<SplashIntersect>` (or
10//! `boost::container::small_vector<SplashIntersect, 4>`) per scanline — one
11//! heap allocation per row. This replacement uses a **flat `SoA` layout**:
12//!
13//! - `row_start[i]` is the index into `intersects` of the first intersection
14//!   on scanline `y_min + i`.
15//! - `row_start[n_rows]` is the total number of intersections (sentinel).
16//! - All intersections are stored in a single `Vec<Intersect>`, sorted by `x0`
17//!   within each row.
18//!
19//! This eliminates per-row heap allocations and is friendlier to the CPU cache
20//! and future SIMD work.
21//!
22//! ## Two-pass construction (Tier 3)
23//!
24//! `XPathScanner::new` uses a two-pass counting-sort to avoid `n_rows`
25//! individual `Vec` allocations:
26//!
27//! 1. **Count pass**: walk segments, increment a `counts[row]` entry for each
28//!    intersection generated (identical edge-walking logic as the fill pass).
29//! 2. **Prefix-sum**: convert counts into `row_start` offsets; allocate the
30//!    flat `intersects` buffer in one shot.
31//! 3. **Fill pass**: walk segments again, writing into the pre-allocated slots.
32//! 4. **Sort**: sort each row's slice in-place by `x0`.
33//!
34//! This reduces construction from `O(n_rows)` heap allocs + flatten to a single
35//! pair of allocations regardless of path height.
36
37/// The coalescing span iterator for a scanner row.
38pub mod iter;
39
40use crate::bitmap::AaBuf;
41use crate::types::{AA_SIZE, splash_floor};
42use crate::xpath::{XPath, XPathFlags};
43
44// ── Fixed-point helpers ───────────────────────────────────────────────────────
45
46/// Convert an `f64` device-space coordinate to 16.16 fixed-point (`i32`).
47///
48/// Values outside the i32 range are saturated — in practice this only happens
49/// for degenerate coordinates beyond ±32 768 px (impossible for a real page).
50#[inline]
51fn fp_from_f64(x: f64) -> i32 {
52    let fp = x * 65536.0;
53    if fp >= f64::from(i32::MAX) {
54        i32::MAX
55    } else if fp <= f64::from(i32::MIN) {
56        i32::MIN
57    } else {
58        #[expect(
59            clippy::cast_possible_truncation,
60            reason = "fp is bounds-checked to [i32::MIN, i32::MAX] by the branches above"
61        )]
62        {
63            fp as i32
64        }
65    }
66}
67
68// ── Intersect ─────────────────────────────────────────────────────────────────
69
70/// One intersection entry for a scanline.
71///
72/// Matches `SplashIntersect` in `splash/SplashXPathScanner.h`.
73#[derive(Copy, Clone, Debug)]
74pub struct Intersect {
75    /// Left pixel of the intersection span, inclusive.
76    pub x0: i32,
77    /// Right pixel of the intersection span, inclusive. Always `x0 ≤ x1`.
78    pub x1: i32,
79    /// Winding contribution: +1 or -1 for sloped/vertical segments, 0 for horizontal.
80    /// Used by non-zero winding number fill rule; ignored by even-odd.
81    pub count: i32,
82}
83
84// ── XPathScanner ──────────────────────────────────────────────────────────────
85
86/// Per-scanline intersection table built from an [`XPath`].
87///
88/// After construction the scanner is read-only; it is cheaply shareable via
89/// `Arc` (matching the C++ `shared_ptr<SplashXPathScanner>` used in `SplashClip`).
90///
91/// ## `SoA` layout invariant
92///
93/// `row_start` has length `(y_max - y_min + 2)` when non-empty: one entry per
94/// scanline plus a sentinel at index `n_rows`. For any valid row index `i`:
95///
96/// ```text
97/// row_start[i] <= row_start[i + 1] <= intersects.len()
98/// ```
99///
100/// Intersections within each row are sorted in ascending `x0` order.
101pub struct XPathScanner {
102    /// Fill rule: `true` = even-odd, `false` = non-zero winding number.
103    pub eo: bool,
104    /// Minimum device-pixel x-coordinate of any intersection in the table.
105    pub x_min: i32,
106    /// Maximum device-pixel x-coordinate of any intersection in the table.
107    pub x_max: i32,
108    /// First scanline (device-pixel y) covered by this scanner.
109    pub y_min: i32,
110    /// Last scanline (device-pixel y) covered by this scanner, inclusive.
111    pub y_max: i32,
112    /// `row_start[i]` = start of row `y_min + i` in `intersects`.
113    /// Length = `(y_max - y_min + 2)` (includes sentinel at the end).
114    row_start: Vec<u32>,
115    /// Flat sorted intersection list for all rows.
116    intersects: Vec<Intersect>,
117}
118
119impl XPathScanner {
120    /// Build a scanner from `xpath`, clipping to `[clip_y_min, clip_y_max]`.
121    ///
122    /// If the xpath is empty or the y-ranges don't overlap, returns an empty
123    /// scanner (`is_empty()` returns true).
124    ///
125    /// # Panics
126    ///
127    /// Panics if the number of rows after clamping overflows `usize` (cannot
128    /// happen for valid PDF coordinate ranges, as `y_max - y_min + 1` is at
129    /// most `i32::MAX`).
130    #[must_use]
131    pub fn new(xpath: &XPath, eo: bool, clip_y_min: i32, clip_y_max: i32) -> Self {
132        if xpath.segs.is_empty() || clip_y_min > clip_y_max {
133            return Self::empty(eo);
134        }
135
136        // Compute floating-point bounding box over segments that intersect the
137        // clip range, discarding NaN-bearing segments.
138        let mut x_min_fp = f64::INFINITY;
139        let mut x_max_fp = f64::NEG_INFINITY;
140        let mut y_min_fp = f64::INFINITY;
141        let mut y_max_fp = f64::NEG_INFINITY;
142
143        for seg in &xpath.segs {
144            if seg.x0.is_nan() || seg.y0.is_nan() || seg.x1.is_nan() || seg.y1.is_nan() {
145                continue;
146            }
147            let sy_min = seg.y0.min(seg.y1);
148            let sy_max = seg.y0.max(seg.y1);
149            if sy_min >= f64::from(clip_y_max) + 1.0 || sy_max < f64::from(clip_y_min) {
150                continue;
151            }
152            x_min_fp = x_min_fp.min(seg.x0).min(seg.x1);
153            x_max_fp = x_max_fp.max(seg.x0).max(seg.x1);
154            y_min_fp = y_min_fp.min(sy_min);
155            y_max_fp = y_max_fp.max(sy_max);
156        }
157
158        if x_min_fp > x_max_fp {
159            return Self::empty(eo);
160        }
161
162        let x_min = splash_floor(x_min_fp);
163        let x_max = splash_floor(x_max_fp);
164        let y_min = splash_floor(y_min_fp).max(clip_y_min);
165        let y_max = splash_floor(y_max_fp).min(clip_y_max);
166
167        if y_min > y_max {
168            return Self::empty(eo);
169        }
170
171        let n_rows = usize::try_from(i64::from(y_max) - i64::from(y_min) + 1)
172            .expect("n_rows overflows usize; page height is impossibly large");
173
174        // ── Two-pass construction: count → prefix-sum → fill → finalise ──────
175        //
176        // Pass 1: count the maximum number of Intersect slots needed per row.
177        // Adjacent-span merging may reduce the final count, so this is an upper bound.
178        let mut counts = vec![0u32; n_rows];
179        count_intersections(xpath, y_min, y_max, &mut counts);
180
181        // Pass 2: prefix-sum to produce base offsets; allocate the flat buffer.
182        let mut alloc_start = Vec::with_capacity(n_rows + 1);
183        let mut total = 0u32;
184        for &c in &counts {
185            alloc_start.push(total);
186            total = total
187                .checked_add(c)
188                .expect("intersection count exceeds u32::MAX; path has too many segments");
189        }
190        alloc_start.push(total);
191        let total_n = total as usize;
192        let mut intersects = vec![
193            Intersect {
194                x0: 0,
195                x1: 0,
196                count: 0
197            };
198            total_n
199        ];
200
201        // Pass 3: fill the flat buffer.  `cursors[i]` counts the actual number of
202        // entries written into row i (≤ counts[i] after merging).  Reset counts
203        // to zero and use them as the write cursors.
204        counts.fill(0);
205        fill_intersections(
206            xpath,
207            y_min,
208            y_max,
209            &alloc_start,
210            &mut counts,
211            &mut intersects,
212        );
213        // `counts` now holds the actual written count per row.
214
215        // Pass 4: build the final row_start from actual counts, compact intersects,
216        // and sort each row slice by x0.
217        //
218        // Compaction is needed because merging may have left unfilled slots between
219        // rows (each row was pre-allocated for its upper-bound count, but the actual
220        // fill may be smaller).  We re-pack in place using a single forward pass.
221        let mut row_start = Vec::with_capacity(n_rows + 1);
222        let mut write_pos = 0usize;
223        for i in 0..n_rows {
224            row_start.push(
225                u32::try_from(write_pos).expect("compacted intersection count exceeds u32::MAX"),
226            );
227            let read_base = alloc_start[i] as usize;
228            let n_written = counts[i] as usize;
229            // Sort the actual written slice before copying.
230            intersects[read_base..read_base + n_written].sort_unstable_by_key(|ix| ix.x0);
231            // Copy into the compacted position (may overlap if no merging occurred).
232            intersects.copy_within(read_base..read_base + n_written, write_pos);
233            write_pos += n_written;
234        }
235        row_start
236            .push(u32::try_from(write_pos).expect("compacted intersection count exceeds u32::MAX"));
237        intersects.truncate(write_pos);
238
239        Self {
240            eo,
241            x_min,
242            x_max,
243            y_min,
244            y_max,
245            row_start,
246            intersects,
247        }
248    }
249
250    const fn empty(eo: bool) -> Self {
251        Self {
252            eo,
253            x_min: 1,
254            x_max: 0, // x_min > x_max → is_empty()
255            y_min: 0,
256            y_max: -1,
257            row_start: Vec::new(),
258            intersects: Vec::new(),
259        }
260    }
261
262    /// True when the scanner covers no scanlines.
263    #[must_use]
264    pub const fn is_empty(&self) -> bool {
265        self.x_min > self.x_max || self.y_min > self.y_max
266    }
267
268    /// Iterate over only the scanlines that have at least one intersection.
269    ///
270    /// Skips empty rows using the `row_start` sentinel array — an empty row has
271    /// `row_start[i] == row_start[i+1]`.  For paths that are sparse over the
272    /// bounding-box height (e.g., a thin diagonal across a tall page), this
273    /// eliminates the per-row overhead for all-empty rows.
274    ///
275    /// Yields absolute device-pixel `y` values in ascending order.
276    pub fn nonempty_rows(&self) -> impl Iterator<Item = i32> + '_ {
277        // row_start has length n_rows + 1 (sentinel).  Row i is non-empty
278        // iff row_start[i] < row_start[i + 1].
279        self.row_start
280            .windows(2)
281            .enumerate()
282            .filter(|(_, w)| w[0] < w[1])
283            // i < n_rows ≤ i32::MAX (enforced by n_rows construction); saturating_add
284            // is a no-cost guard against logic errors in debug builds.
285            .map(|(i, _)| {
286                self.y_min
287                    .saturating_add(i32::try_from(i).unwrap_or(i32::MAX))
288            })
289    }
290
291    /// The intersections for scanline `y`, sorted by `x0`.
292    ///
293    /// Returns an empty slice if `y` is outside `[y_min, y_max]`. Never panics.
294    #[must_use]
295    pub fn row(&self, y: i32) -> &[Intersect] {
296        if y < self.y_min || y > self.y_max {
297            return &[];
298        }
299        // y_min <= y <= y_max is guaranteed by the check above, so the
300        // subtraction is non-negative and within [0, y_max - y_min].
301        // That range fits in usize on any platform; cast is safe.
302        #[expect(
303            clippy::cast_sign_loss,
304            reason = "y >= y_min is asserted by the bounds check above; \
305                      the difference is non-negative"
306        )]
307        let i = (y - self.y_min) as usize;
308        debug_assert!(i + 1 < self.row_start.len(), "row_start sentinel missing");
309        // row_start entries are u32; on all supported (32/64-bit) platforms
310        // usize >= u32, so `as usize` is a widening cast that cannot lose data.
311        let s = self.row_start[i] as usize;
312        let e = self.row_start[i + 1] as usize;
313        debug_assert!(
314            s <= e && e <= self.intersects.len(),
315            "row_start invariant violated"
316        );
317        &self.intersects[s..e]
318    }
319
320    /// Accumulate winding crossings for all intersections in `row` up to (but
321    /// not including) pixel `x`.
322    ///
323    /// Returns `(count, on_span)` where:
324    /// - `count` is the sum of `Intersect::count` for every entry whose span
325    ///   ends before `x` (i.e. `x1 < x`).
326    /// - `on_span` is `true` if `x` falls within one of the intersection spans.
327    ///
328    /// The caller applies the fill-rule mask to `count` to decide whether the
329    /// pixel is inside the path when `on_span` is `false`.
330    fn count_crossings(row: &[Intersect], x: i32) -> (i32, bool) {
331        let mut count = 0i32;
332        for int in row {
333            if int.x0 > x {
334                break;
335            }
336            if x <= int.x1 {
337                // `x` falls inside this intersection span.
338                return (count, true);
339            }
340            count = count.wrapping_add(int.count);
341        }
342        (count, false)
343    }
344
345    /// Test whether pixel `(x, y)` is inside the path.
346    #[must_use]
347    pub fn test(&self, x: i32, y: i32) -> bool {
348        let row = self.row(y);
349        let eo_mask = if self.eo { 1i32 } else { !0i32 };
350        let (count, on_span) = Self::count_crossings(row, x);
351        on_span || (count & eo_mask) != 0
352    }
353
354    /// Test whether the entire span `[x0, x1]` on scanline `y` is inside the path.
355    ///
356    /// Returns `true` only if every pixel in `[x0, x1]` is covered by the fill.
357    #[must_use]
358    pub fn test_span(&self, x0: i32, x1: i32, y: i32) -> bool {
359        let row = self.row(y);
360        let eo_mask = if self.eo { 1i32 } else { !0i32 };
361        let mut count = 0i32;
362        let mut i = 0;
363        let mut xx1 = x0.saturating_sub(1);
364        while xx1 < x1 {
365            if i >= row.len() {
366                return false;
367            }
368            if row[i].x0 > xx1.saturating_add(1) && (count & eo_mask) == 0 {
369                return false;
370            }
371            if row[i].x1 > xx1 {
372                xx1 = row[i].x1;
373            }
374            count = count.wrapping_add(row[i].count);
375            i += 1;
376        }
377        true
378    }
379
380    /// Render the AA supersampled row to `aa_buf` and update `[x0, x1]` output range.
381    ///
382    /// Matches `SplashXPathScanner::renderAALine`. `y` is in device-pixel space;
383    /// the scanner must have been built from an `aa_scale()`'d `XPath`.
384    ///
385    /// `x0` and `x1` are updated to the tightest bounding range of pixels
386    /// written into `aa_buf`. If no pixels are written, `*x0` is set to `0`
387    /// and `*x1` to `-1` (so `*x0 > *x1` reliably indicates an empty range).
388    ///
389    /// # Panics
390    ///
391    /// Does not panic in practice. `AA_SIZE` is a positive compile-time constant
392    /// so all `try_from` conversions on loop indices succeed.
393    pub fn render_aa_line(&self, aa_buf: &mut AaBuf, x0: &mut i32, x1: &mut i32, y: i32) {
394        aa_buf.clear();
395        let aa_width_i32 = i32::try_from(aa_buf.width).unwrap_or(i32::MAX);
396        let mut xx_min = aa_width_i32;
397        let mut xx_max = -1i32;
398
399        let eo_mask = if self.eo { 1i32 } else { !0i32 };
400        let aa = AA_SIZE;
401
402        for yy in 0..aa {
403            let scan_y = y * aa + yy;
404            let row = self.row(scan_y);
405            let mut count = 0i32;
406            let mut i = 0usize;
407            let mut xx = 0i32;
408
409            // Walk through intersections, emitting covered spans into aa_buf.
410            //
411            // Winding rule: we are "inside" the path when
412            //   (count & eo_mask) != 0
413            // where count is the running sum of `Intersect::count` values seen so
414            // far. For EO fill eo_mask=1 so the low bit tracks parity; for NZ
415            // winding eo_mask=!0 so any non-zero count means inside.
416            while i < row.len() || (count & eo_mask) != 0 {
417                if (count & eo_mask) == 0 {
418                    // Currently outside — skip to the next intersection entry.
419                    if i >= row.len() {
420                        break;
421                    }
422                    xx = row[i].x0;
423                    count = count.wrapping_add(row[i].count);
424                    i += 1;
425                    continue;
426                }
427
428                // Currently inside a covered region — find the end of this span.
429                let span_x1 = loop {
430                    if i >= row.len() {
431                        break aa_width_i32;
432                    }
433                    count = count.wrapping_add(row[i].count);
434                    let end = row[i].x1 + 1;
435                    i += 1;
436                    if (count & eo_mask) == 0 {
437                        break end;
438                    }
439                };
440
441                let bx0 = usize::try_from(xx.max(0)).unwrap_or(0);
442                let bx1 = usize::try_from(span_x1.min(aa_width_i32)).unwrap_or(0);
443                if bx0 < bx1 {
444                    let row_idx = usize::try_from(yy).expect("yy in 0..AA_SIZE, always >= 0");
445                    aa_buf.set_span(row_idx, bx0, bx1);
446                    let bx0_i32 = i32::try_from(bx0).unwrap_or(i32::MAX);
447                    let bx1_i32 = i32::try_from(bx1).unwrap_or(i32::MAX);
448                    if bx0_i32 < xx_min {
449                        xx_min = bx0_i32;
450                    }
451                    if bx1_i32 > xx_max {
452                        xx_max = bx1_i32;
453                    }
454                }
455                xx = span_x1;
456            }
457        }
458
459        if xx_min > xx_max {
460            // Empty range signal: x0 > x1.
461            *x0 = 0;
462            *x1 = -1;
463            return;
464        }
465        *x0 = xx_min / aa;
466        *x1 = (xx_max - 1) / aa;
467    }
468}
469
470// ── Two-pass intersection construction ───────────────────────────────────────
471
472/// Pass 1: count how many `Intersect` entries each row will receive.
473///
474/// Walks the same segment logic as [`fill_intersections`] without writing
475/// anything, so the two passes must stay in sync.
476fn count_intersections(xpath: &XPath, y_min: i32, y_max: i32, counts: &mut [u32]) {
477    for seg in &xpath.segs {
478        if seg.x0.is_nan() || seg.y0.is_nan() {
479            continue;
480        }
481        let seg_y_min = seg.y0.min(seg.y1);
482        let seg_y_max = seg.y0.max(seg.y1);
483        let row_y0 = splash_floor(seg_y_min).max(y_min);
484        let row_y1 = splash_floor(seg_y_max).min(y_max);
485
486        if seg.flags.contains(XPathFlags::HORIZ) {
487            let row = splash_floor(seg_y_min);
488            if row >= y_min && row <= y_max {
489                let idx = usize::try_from(row - y_min).expect("row >= y_min");
490                counts[idx] = counts[idx].saturating_add(1);
491            }
492        } else {
493            for row in row_y0..=row_y1 {
494                let idx = usize::try_from(row - y_min).expect("row >= y_min");
495                counts[idx] = counts[idx].saturating_add(1);
496            }
497        }
498    }
499}
500
501/// Pass 3: write `Intersect` entries into the pre-allocated flat buffer.
502///
503/// `row_start` holds the base offset for each row (prefix-summed from counts).
504/// `cursors` starts at all-zero and is incremented per row as entries are written —
505/// so `row_start[i] + cursors[i]` is the next free slot for row `i`.
506///
507/// Sloped segments use a 16.16 fixed-point integer accumulator (`xx1_fp += dxdy_fp`)
508/// for the per-scanline step.  The accumulator is initialised from f64 for the
509/// first row to preserve accuracy, then stepped by the pre-computed `i32` slope
510/// — eliminating all f64 arithmetic from the inner loop.
511fn fill_intersections(
512    xpath: &XPath,
513    y_min: i32,
514    y_max: i32,
515    row_start: &[u32],
516    cursors: &mut [u32],
517    intersects: &mut [Intersect],
518) {
519    for seg in &xpath.segs {
520        if seg.x0.is_nan() || seg.y0.is_nan() {
521            continue;
522        }
523        let seg_y_min = seg.y0.min(seg.y1);
524        let seg_y_max = seg.y0.max(seg.y1);
525        let row_y0 = splash_floor(seg_y_min).max(y_min);
526        let row_y1 = splash_floor(seg_y_max).min(y_max);
527
528        if seg.flags.contains(XPathFlags::HORIZ) {
529            let row = splash_floor(seg_y_min);
530            if row >= y_min && row <= y_max {
531                let x0 = splash_floor(seg.x0.min(seg.x1));
532                let x1 = splash_floor(seg.x0.max(seg.x1));
533                write_intersect(row, y_min, row_start, cursors, intersects, x0, x1, 0);
534            }
535        } else if seg.flags.contains(XPathFlags::VERT) {
536            let count = if seg.flags.contains(XPathFlags::FLIPPED) {
537                1
538            } else {
539                -1
540            };
541            let x = splash_floor(seg.x0);
542            for row in row_y0..=row_y1 {
543                let c = if seg_y_min < f64::from(row) { count } else { 0 };
544                write_intersect(row, y_min, row_start, cursors, intersects, x, x, c);
545            }
546        } else {
547            let count = if seg.flags.contains(XPathFlags::FLIPPED) {
548                1
549            } else {
550                -1
551            };
552            let x_base = seg.y0.mul_add(-seg.dxdy, seg.x0);
553            let sloped_x_min = seg.x0.min(seg.x1);
554            let sloped_x_max = seg.x0.max(seg.x1);
555
556            // Clamp bounds in 16.16 fixed-point for the integer inner loop.
557            // Shift left 16 bits; saturate to i32 range (overflows only for
558            // geometrically impossible coordinates, >32k px).
559            let fp_clamp_min = fp_from_f64(sloped_x_min);
560            let fp_clamp_max = fp_from_f64(sloped_x_max);
561
562            // Initial x at the top of the first row (or seg.x0 if row_y0 == seg.y0).
563            let xx_init = if f64::from(row_y0) > seg.y0 {
564                f64::from(row_y0).mul_add(seg.dxdy, x_base)
565            } else {
566                seg.x0
567            };
568            let mut px0 = splash_floor(xx_init.clamp(sloped_x_min, sloped_x_max));
569
570            // 16.16 fixed-point accumulator for the right edge of each span.
571            // Initialised from f64 to preserve accuracy at the first row, then
572            // stepped by `dxdy_fp` (integer add) per scanline — no f64 arithmetic
573            // inside the hot loop.
574            let mut xx1_fp = fp_from_f64((f64::from(row_y0) + 1.0).mul_add(seg.dxdy, x_base));
575
576            for row in row_y0..=row_y1 {
577                let xx1_fp_clamped = xx1_fp.clamp(fp_clamp_min, fp_clamp_max);
578                // Arithmetic right-shift gives floor() for both positive and negative.
579                let px1 = xx1_fp_clamped >> 16;
580                let c = if seg_y_min < f64::from(row) { count } else { 0 };
581                write_intersect(row, y_min, row_start, cursors, intersects, px0, px1, c);
582                px0 = px1;
583                xx1_fp = xx1_fp.saturating_add(seg.dxdy_fp);
584            }
585        }
586    }
587}
588
589/// Write one `Intersect` entry into its row slot, applying the same
590/// adjacent-span merge logic as `add_intersection`.
591///
592/// `cursors[idx]` tracks how many entries have been written into row `idx` so far.
593/// The slot is `row_start[idx] + cursors[idx] - 1` for merge, or `+ cursors[idx]`
594/// for a new entry.
595#[inline]
596#[expect(
597    clippy::too_many_arguments,
598    reason = "private helper: all params are load-bearing"
599)]
600fn write_intersect(
601    row: i32,
602    y_min: i32,
603    row_start: &[u32],
604    cursors: &mut [u32],
605    intersects: &mut [Intersect],
606    x0: i32,
607    x1: i32,
608    count: i32,
609) {
610    let (lo, hi) = (x0.min(x1), x0.max(x1));
611    let entry = Intersect {
612        x0: lo,
613        x1: hi,
614        count,
615    };
616    let idx = usize::try_from(row - y_min).expect("row >= y_min");
617    let base = row_start[idx] as usize;
618    let cur = cursors[idx] as usize;
619
620    if cur > 0 {
621        // Check the last written entry in this row for adjacency/overlap.
622        let last = &mut intersects[base + cur - 1];
623        if last.x1.saturating_add(1) >= entry.x0 && last.x0 <= entry.x1.saturating_add(1) {
624            // Merge: expand span and accumulate count.
625            last.count = last.count.wrapping_add(entry.count);
626            last.x0 = last.x0.min(entry.x0);
627            last.x1 = last.x1.max(entry.x1);
628            return;
629        }
630    }
631
632    // New entry: write into the next free slot.
633    // The count pass guarantees capacity; the debug_assert guards against
634    // logic divergence between the two passes.
635    debug_assert!(
636        base + cur < intersects.len(),
637        "write_intersect: slot out of range (row={row}, base={base}, cur={cur}, len={})",
638        intersects.len()
639    );
640    intersects[base + cur] = entry;
641    cursors[idx] += 1;
642}
643
644// ── Intersection helper ───────────────────────────────────────────────────────
645
646/// Add or merge one intersection entry into a `Vec<Intersect>` row buffer.
647///
648/// Used only in unit tests to exercise the merge/disjoint logic independently.
649/// Production code uses [`write_intersect`] with the pre-allocated flat buffer.
650///
651/// Two spans are merged when `last.x1 + 1 >= entry.x0` *and*
652/// `last.x0 <= entry.x1 + 1` (i.e. they touch or overlap in either direction).
653/// This matches `SplashXPathScanner::addIntersection` in `SplashXPathScanner.cc`.
654#[cfg(test)]
655fn add_intersection(row: &mut Vec<Intersect>, x0: i32, x1: i32, count: i32, _is_horiz: bool) {
656    let (lo, hi) = (x0.min(x1), x0.max(x1));
657    let entry = Intersect {
658        x0: lo,
659        x1: hi,
660        count,
661    };
662
663    if row.is_empty() {
664        row.push(entry);
665        return;
666    }
667    let last = row.last_mut().unwrap();
668    if last.x1.saturating_add(1) < entry.x0 || last.x0 > entry.x1.saturating_add(1) {
669        row.push(entry);
670    } else {
671        last.count = last.count.wrapping_add(entry.count);
672        last.x0 = last.x0.min(entry.x0);
673        last.x1 = last.x1.max(entry.x1);
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::path::PathBuilder;
681    use crate::xpath::XPath;
682
683    fn identity() -> [f64; 6] {
684        [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
685    }
686
687    fn triangle_xpath() -> XPath {
688        let mut b = PathBuilder::new();
689        b.move_to(0.0, 0.0).unwrap();
690        b.line_to(4.0, 0.0).unwrap();
691        b.line_to(2.0, 4.0).unwrap();
692        b.close(false).unwrap();
693        XPath::new(&b.build(), &identity(), 1.0, true)
694    }
695
696    #[test]
697    fn empty_on_no_segs() {
698        let xpath = XPath::empty();
699        let scanner = XPathScanner::new(&xpath, false, 0, 10);
700        assert!(scanner.is_empty());
701    }
702
703    #[test]
704    fn triangle_has_rows() {
705        let xpath = triangle_xpath();
706        let scanner = XPathScanner::new(&xpath, false, 0, 4);
707        assert!(!scanner.is_empty());
708        assert!(scanner.y_min <= scanner.y_max);
709    }
710
711    #[test]
712    fn horizontal_segment_count_zero() {
713        // A purely horizontal segment should produce count=0.
714        let mut row = Vec::new();
715        add_intersection(&mut row, 0, 5, 0, true);
716        assert_eq!(row[0].count, 0);
717    }
718
719    #[test]
720    fn test_inside_triangle() {
721        let xpath = triangle_xpath();
722        let scanner = XPathScanner::new(&xpath, false, 0, 4);
723        // Centroid of the triangle is roughly (2, 1.33)
724        assert!(scanner.test(2, 1));
725    }
726
727    #[test]
728    fn test_outside_triangle() {
729        let xpath = triangle_xpath();
730        let scanner = XPathScanner::new(&xpath, false, 0, 4);
731        assert!(!scanner.test(10, 2));
732        assert!(!scanner.test(2, 5));
733    }
734
735    #[test]
736    fn row_out_of_range_returns_empty() {
737        let xpath = triangle_xpath();
738        let scanner = XPathScanner::new(&xpath, false, 0, 4);
739        // y below y_min
740        assert!(scanner.row(scanner.y_min - 1).is_empty());
741        // y above y_max
742        assert!(scanner.row(scanner.y_max + 1).is_empty());
743        // large out-of-range values
744        assert!(scanner.row(i32::MIN).is_empty());
745        assert!(scanner.row(i32::MAX).is_empty());
746    }
747
748    #[test]
749    fn add_intersection_merge_adjacent() {
750        // [0,3] then [4,7]: last.x1+1 == entry.x0, so they should merge.
751        let mut row = Vec::new();
752        add_intersection(&mut row, 0, 3, -1, false);
753        add_intersection(&mut row, 4, 7, 1, false);
754        assert_eq!(row.len(), 1, "adjacent spans must merge");
755        assert_eq!(row[0].x0, 0);
756        assert_eq!(row[0].x1, 7);
757        assert_eq!(row[0].count, 0); // -1 + 1 = 0
758    }
759
760    #[test]
761    fn add_intersection_disjoint() {
762        // [0,2] then [5,7]: gap of 2 pixels, must NOT merge.
763        let mut row = Vec::new();
764        add_intersection(&mut row, 0, 2, -1, false);
765        add_intersection(&mut row, 5, 7, -1, false);
766        assert_eq!(row.len(), 2, "disjoint spans must not merge");
767    }
768
769    #[test]
770    fn count_crossings_inside_span() {
771        // count_crossings with x inside an intersection span returns on_span=true.
772        let row = vec![Intersect {
773            x0: 2,
774            x1: 5,
775            count: -1,
776        }];
777        let (count, on_span) = XPathScanner::count_crossings(&row, 3);
778        assert!(on_span);
779        assert_eq!(count, 0); // not yet accumulated the entry's count
780    }
781
782    #[test]
783    fn count_crossings_after_span() {
784        // After span [2,5] count=-1: pixel 6 is outside, count reflects crossing.
785        let row = vec![Intersect {
786            x0: 2,
787            x1: 5,
788            count: -1,
789        }];
790        let (count, on_span) = XPathScanner::count_crossings(&row, 6);
791        assert!(!on_span);
792        assert_eq!(count, -1);
793    }
794}