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}