rasterrocket_render/clip.rs
1//! Clip region: an axis-aligned rectangle intersected with zero or more
2//! arbitrary path clip regions.
3//!
4//! Mirrors `SplashClip` from `splash/SplashClip.h/.cc`.
5//!
6//! ## Sharing semantics
7//!
8//! When a [`Clip`] is cloned (e.g. for `GraphicsState::save`), the path-clip
9//! scanners are shared via [`Arc`] — matching the C++ `shared_ptr` behaviour.
10//! [`XPathScanner`] instances are immutable after construction, so sharing
11//! across threads and across `clone_shared` copies is safe: there is no
12//! interior mutability in the shared objects.
13
14use std::sync::Arc;
15
16use crate::bitmap::AaBuf;
17use crate::scanner::XPathScanner;
18use crate::types::{AA_SIZE, splash_ceil, splash_floor};
19use crate::xpath::XPath;
20
21// ── ClipResult ────────────────────────────────────────────────────────────────
22
23/// Result of a rectangular or span clip test. Matches `SplashClipResult`.
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub enum ClipResult {
26 /// The entire tested region lies within the clip boundary.
27 AllInside,
28 /// The entire tested region lies outside the clip boundary.
29 AllOutside,
30 /// The tested region straddles the clip boundary; per-pixel testing is required.
31 Partial,
32}
33
34// ── Clip ──────────────────────────────────────────────────────────────────────
35
36/// A clipping region combining an axis-aligned rectangle with an optional
37/// stack of arbitrary path clips.
38///
39/// The effective clip is the intersection of the rectangle and all path clips.
40pub struct Clip {
41 /// Whether anti-aliasing (supersampling) is enabled; scales coordinates by
42 /// [`AA_SIZE`] when testing path clips.
43 pub antialias: bool,
44 /// Left edge of the clip rectangle in floating-point device space (inclusive).
45 pub x_min: f64,
46 /// Top edge of the clip rectangle in floating-point device space (inclusive).
47 pub y_min: f64,
48 /// Right edge of the clip rectangle in floating-point device space (exclusive).
49 pub x_max: f64,
50 /// Bottom edge of the clip rectangle in floating-point device space (exclusive).
51 pub y_max: f64,
52 /// Integer pixel column of the left clip edge: `floor(x_min)`.
53 pub x_min_i: i32,
54 /// Integer pixel row of the top clip edge: `floor(y_min)`.
55 pub y_min_i: i32,
56 /// Integer pixel column of the right clip edge: `ceil(x_max) - 1`.
57 pub x_max_i: i32,
58 /// Integer pixel row of the bottom clip edge: `ceil(y_max) - 1`.
59 pub y_max_i: i32,
60 /// Arbitrary path-clip scanners.
61 ///
62 /// Shared across [`clone_shared`](Clip::clone_shared) copies via [`Arc`].
63 /// [`XPathScanner`] is immutable after construction so no interior-mutability
64 /// hazard exists.
65 scanners: Vec<Arc<XPathScanner>>,
66}
67
68impl Clip {
69 /// Create a new clip region from a rectangle.
70 ///
71 /// Matches `SplashClip(x0, y0, x1, y1, antialiasA)` in `SplashClip.cc`.
72 #[must_use]
73 pub fn new(x0: f64, y0: f64, x1: f64, y1: f64, antialias: bool) -> Self {
74 let mut clip = Self {
75 antialias,
76 x_min: 0.0,
77 y_min: 0.0,
78 x_max: 0.0,
79 y_max: 0.0,
80 x_min_i: 0,
81 y_min_i: 0,
82 x_max_i: 0,
83 y_max_i: 0,
84 scanners: Vec::new(),
85 };
86 clip.set_rect(x0, y0, x1, y1);
87 clip
88 }
89
90 /// Clone this `Clip`, sharing all path-clip scanners via [`Arc`].
91 ///
92 /// The cloned value and the original share the same [`XPathScanner`]
93 /// instances. Because scanners are immutable after construction there is
94 /// no interior-mutability hazard. This mirrors C++ `shared_ptr` copy
95 /// semantics used in `GraphicsState::save`.
96 #[must_use]
97 pub fn clone_shared(&self) -> Self {
98 Self {
99 antialias: self.antialias,
100 x_min: self.x_min,
101 y_min: self.y_min,
102 x_max: self.x_max,
103 y_max: self.y_max,
104 x_min_i: self.x_min_i,
105 y_min_i: self.y_min_i,
106 x_max_i: self.x_max_i,
107 y_max_i: self.y_max_i,
108 scanners: self.scanners.clone(), // Arc::clone per element
109 }
110 }
111
112 /// Replace the clip rectangle and clear all path clips.
113 pub fn reset_to_rect(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
114 self.set_rect(x0, y0, x1, y1);
115 self.scanners.clear();
116 }
117
118 /// Intersect the clip rectangle with `[x0, y0, x1, y1]`.
119 pub fn clip_to_rect(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
120 let (lx, rx) = (x0.min(x1), x0.max(x1));
121 let (ly, ry) = (y0.min(y1), y0.max(y1));
122 self.x_min = self.x_min.max(lx);
123 self.x_max = self.x_max.min(rx);
124 self.y_min = self.y_min.max(ly);
125 self.y_max = self.y_max.min(ry);
126 self.recompute_int_bounds();
127 }
128
129 /// Intersect with an arbitrary path clip.
130 ///
131 /// If the path resolves to a simple axis-aligned rectangle (4 segments,
132 /// axis-aligned), it is reduced to `clip_to_rect`. Otherwise a new
133 /// [`XPathScanner`] is pushed onto the scanner stack.
134 ///
135 /// An empty path forces the clip to be empty (nothing passes through).
136 ///
137 /// # Panics
138 ///
139 /// Panics in debug builds if the AA y-range arithmetic overflows `i32`.
140 /// In practice `y_max_i` is bounded by the bitmap height (≪ `i32::MAX / AA_SIZE`).
141 pub fn clip_to_path(&mut self, xpath: &XPath, eo: bool) {
142 if xpath.segs.is_empty() {
143 // Force empty: nothing passes.
144 self.x_max = self.x_min - 1.0;
145 self.y_max = self.y_min - 1.0;
146 self.recompute_int_bounds();
147 return;
148 }
149 // Detect axis-aligned rect (4 segments, 2 horiz + 2 vert, forming a closed box).
150 if let Some((rx0, ry0, rx1, ry1)) = detect_rect(xpath) {
151 self.clip_to_rect(rx0, ry0, rx1, ry1);
152 return;
153 }
154 // General path clip: compute scanline range in (possibly scaled) space.
155 let (y_lo, y_hi) = if self.antialias {
156 // Invariant: y_max_i is a pixel coordinate bounded by bitmap height,
157 // which is far below i32::MAX / AA_SIZE. The additions below cannot
158 // realistically overflow, but we assert in debug builds.
159 let lo = self
160 .y_min_i
161 .checked_mul(AA_SIZE)
162 .expect("AA y_lo overflows i32: y_min_i is unreasonably large");
163 let hi = self
164 .y_max_i
165 .checked_add(1)
166 .and_then(|v| v.checked_mul(AA_SIZE))
167 .map(|v| v - 1)
168 .expect("AA y_hi overflows i32: y_max_i is unreasonably large");
169 (lo, hi)
170 } else {
171 (self.y_min_i, self.y_max_i)
172 };
173 let scanner = XPathScanner::new(xpath, eo, y_lo, y_hi);
174 self.scanners.push(Arc::new(scanner));
175 }
176
177 // ── Pixel-level tests ─────────────────────────────────────────────────────
178
179 /// Test whether pixel `(x, y)` is inside the clip region.
180 ///
181 /// Returns `false` immediately if `(x, y)` is outside the axis-aligned
182 /// rectangle; otherwise all path-clip scanners are consulted.
183 #[inline]
184 #[must_use]
185 pub fn test(&self, x: i32, y: i32) -> bool {
186 if x < self.x_min_i || x > self.x_max_i || y < self.y_min_i || y > self.y_max_i {
187 return false;
188 }
189 self.test_clip_paths(x, y)
190 }
191
192 /// Test a pixel rectangle against the clip region.
193 ///
194 /// The rectangle is inclusive on both ends: `[left, right] × [top, bottom]`.
195 #[must_use]
196 pub fn test_rect(&self, left: i32, top: i32, right: i32, bottom: i32) -> ClipResult {
197 // Half-open pixel rect: [left, right+1) × [top, bottom+1).
198 // Clip rect: [x_min, x_max) × [y_min, y_max).
199 if f64::from(right + 1) <= self.x_min
200 || f64::from(left) >= self.x_max
201 || f64::from(bottom + 1) <= self.y_min
202 || f64::from(top) >= self.y_max
203 {
204 return ClipResult::AllOutside;
205 }
206 if f64::from(left) >= self.x_min
207 && f64::from(right + 1) <= self.x_max
208 && f64::from(top) >= self.y_min
209 && f64::from(bottom + 1) <= self.y_max
210 && self.scanners.is_empty()
211 {
212 return ClipResult::AllInside;
213 }
214 ClipResult::Partial
215 }
216
217 /// Test whether the span `[x0, x1]` on scanline `y` is fully inside the clip.
218 ///
219 /// Returns [`ClipResult::AllInside`] only when the span is inside both the
220 /// bounding rectangle and every path-clip scanner. Returns
221 /// [`ClipResult::AllOutside`] when the span is fully outside the rectangle.
222 /// Otherwise returns [`ClipResult::Partial`].
223 #[must_use]
224 pub fn test_span(&self, x0: i32, x1: i32, y: i32) -> ClipResult {
225 let result = self.test_rect(x0, y, x1, y);
226 if result != ClipResult::AllInside {
227 return result;
228 }
229 for scanner in &self.scanners {
230 let (sx0, sx1, sy) = aa_coords(x0, x1, y, self.antialias);
231 if !scanner.test_span(sx0, sx1, sy) {
232 return ClipResult::Partial;
233 }
234 }
235 ClipResult::AllInside
236 }
237
238 /// Clip an AA buffer row, zeroing bits outside the clip region.
239 ///
240 /// Matches `SplashClip::clipAALine` in `SplashClip.cc`. Each path-clip
241 /// scanner is asked to render its coverage into `aa_buf`, and the output
242 /// span `[*x0, *x1]` is clamped to the integer clip bounds.
243 ///
244 /// This method does not panic. `AA_SIZE` is the compile-time constant `4`,
245 /// which is always representable as `usize`.
246 pub fn clip_aa_line(&self, aa_buf: &mut AaBuf, x0: &mut i32, x1: &mut i32, y: i32) {
247 // Apply path-clip scanners.
248 for scanner in &self.scanners {
249 scanner.render_aa_line(aa_buf, x0, x1, y);
250 }
251 // Clamp output range to the integer clip bounds.
252 *x0 = (*x0).max(self.x_min_i);
253 *x1 = (*x1).min(self.x_max_i);
254 }
255
256 // ── Private ───────────────────────────────────────────────────────────────
257
258 fn set_rect(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) {
259 self.x_min = x0.min(x1);
260 self.x_max = x0.max(x1);
261 self.y_min = y0.min(y1);
262 self.y_max = y0.max(y1);
263 self.recompute_int_bounds();
264 }
265
266 fn recompute_int_bounds(&mut self) {
267 self.x_min_i = splash_floor(self.x_min);
268 self.y_min_i = splash_floor(self.y_min);
269 self.x_max_i = splash_ceil(self.x_max) - 1;
270 self.y_max_i = splash_ceil(self.y_max) - 1;
271 }
272
273 fn test_clip_paths(&self, x: i32, y: i32) -> bool {
274 let (tx, ty, _) = aa_coords(x, x, y, self.antialias);
275 self.scanners.iter().all(|s| s.test(tx, ty))
276 }
277}
278
279// ── AA coordinate scaling ─────────────────────────────────────────────────────
280
281/// Scale pixel coordinates to the supersampled AA grid when `antialias` is set.
282///
283/// Returns `(sx0, sx1, sy)` where:
284/// - `sx0 = x0 * AA_SIZE` if AA, else `x0`
285/// - `sx1 = x1 * AA_SIZE + (AA_SIZE - 1)` if AA, else `x1`
286/// - `sy = y * AA_SIZE` if AA, else `y`
287///
288/// The expanded `sx1` covers all supersampled sub-pixels within device pixel `x1`.
289///
290/// # Panics
291///
292/// Panics in debug builds on overflow; in practice pixel coordinates are bounded
293/// by the bitmap dimensions, which are far below `i32::MAX / AA_SIZE`.
294#[inline]
295fn aa_coords(x0: i32, x1: i32, y: i32, antialias: bool) -> (i32, i32, i32) {
296 if antialias {
297 let sx0 = x0
298 .checked_mul(AA_SIZE)
299 .expect("aa_coords: x0 * AA_SIZE overflows i32");
300 let sx1 = x1
301 .checked_mul(AA_SIZE)
302 .and_then(|v| v.checked_add(AA_SIZE - 1))
303 .expect("aa_coords: x1 * AA_SIZE + (AA_SIZE-1) overflows i32");
304 let sy = y
305 .checked_mul(AA_SIZE)
306 .expect("aa_coords: y * AA_SIZE overflows i32");
307 (sx0, sx1, sy)
308 } else {
309 (x0, x1, y)
310 }
311}
312
313// ── Rectangle detection ───────────────────────────────────────────────────────
314
315/// Detect whether an `XPath` is an axis-aligned rectangle.
316///
317/// Returns `Some((x0, y0, x1, y1))` giving the bounding box of the rectangle
318/// if the path consists of exactly 4 axis-aligned segments (2 vertical + 2
319/// horizontal). Returns `None` for any other path.
320///
321/// Matches the `SplashClip::isRect` logic in `SplashClip.cc`.
322fn detect_rect(xpath: &XPath) -> Option<(f64, f64, f64, f64)> {
323 use crate::xpath::XPathFlags;
324 if xpath.segs.len() != 4 {
325 return None;
326 }
327 let segs = &xpath.segs;
328 // Need exactly 2 vertical + 2 horizontal segments.
329 let verts = segs
330 .iter()
331 .filter(|s| s.flags.contains(XPathFlags::VERT))
332 .count();
333 let horizs = segs
334 .iter()
335 .filter(|s| s.flags.contains(XPathFlags::HORIZ))
336 .count();
337 if verts != 2 || horizs != 2 {
338 return None;
339 }
340 // Extract x extents from vertical segments and y extents from horizontal
341 // segments by folding directly — no intermediate allocation.
342 let vert_xs = segs
343 .iter()
344 .filter(|s| s.flags.contains(XPathFlags::VERT))
345 .flat_map(|s| [s.x0, s.x1]);
346 let horiz_ys = segs
347 .iter()
348 .filter(|s| s.flags.contains(XPathFlags::HORIZ))
349 .flat_map(|s| [s.y0, s.y1]);
350
351 let (x0, x1) = vert_xs.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
352 (lo.min(v), hi.max(v))
353 });
354 let (y0, y1) = horiz_ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
355 (lo.min(v), hi.max(v))
356 });
357
358 Some((x0, y0, x1, y1))
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn new_clip_rect_bounds() {
367 let c = Clip::new(1.5, 2.5, 10.5, 8.5, false);
368 assert_eq!(c.x_min_i, 1); // floor(1.5) = 1
369 assert_eq!(c.y_min_i, 2); // floor(2.5) = 2
370 assert_eq!(c.x_max_i, 10); // ceil(10.5) - 1 = 11 - 1 = 10
371 assert_eq!(c.y_max_i, 8); // ceil(8.5) - 1 = 9 - 1 = 8
372 }
373
374 #[test]
375 fn test_inside() {
376 let c = Clip::new(0.0, 0.0, 10.0, 10.0, false);
377 assert!(c.test(5, 5));
378 }
379
380 #[test]
381 fn test_outside() {
382 let c = Clip::new(0.0, 0.0, 10.0, 10.0, false);
383 assert!(!c.test(15, 5));
384 assert!(!c.test(5, 15));
385 }
386
387 #[test]
388 fn clip_to_rect_shrinks() {
389 let mut c = Clip::new(0.0, 0.0, 10.0, 10.0, false);
390 c.clip_to_rect(2.0, 3.0, 8.0, 7.0);
391 assert_eq!(c.x_min_i, 2);
392 assert_eq!(c.y_min_i, 3);
393 }
394
395 #[test]
396 fn test_rect_all_inside() {
397 let c = Clip::new(0.0, 0.0, 20.0, 20.0, false);
398 assert_eq!(c.test_rect(1, 1, 5, 5), ClipResult::AllInside);
399 }
400
401 #[test]
402 fn test_rect_all_outside() {
403 let c = Clip::new(0.0, 0.0, 10.0, 10.0, false);
404 assert_eq!(c.test_rect(15, 15, 20, 20), ClipResult::AllOutside);
405 }
406
407 #[test]
408 fn clone_shares_scanners() {
409 let c = Clip::new(0.0, 0.0, 10.0, 10.0, false);
410 let c2 = c.clone_shared();
411 assert_eq!(c2.x_min_i, c.x_min_i);
412 // Both should have the same (empty) scanner list.
413 assert_eq!(c.scanners.len(), c2.scanners.len());
414 }
415
416 #[test]
417 fn aa_coords_non_aa_passthrough() {
418 assert_eq!(aa_coords(3, 7, 5, false), (3, 7, 5));
419 }
420
421 #[test]
422 fn aa_coords_aa_scales() {
423 // AA_SIZE = 4
424 // sx0 = 3 * 4 = 12
425 // sx1 = 7 * 4 + 3 = 31
426 // sy = 5 * 4 = 20
427 assert_eq!(aa_coords(3, 7, 5, true), (12, 31, 20));
428 }
429}