Skip to main content

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}