Skip to main content

backdrop_blur_core/
gl_region.rs

1//! The GL-origin region type — a **structural** guard against this design's recurring bug
2//! class, the y-flip (DESIGN §5). The flip between egui's top-left sampling space and GL's
3//! bottom-left framebuffer space got two design reviews wrong and made the v1 seam doc place
4//! the flip in the wrong place; the response, mandated post-review, is to make the orientation
5//! a *type* rather than a convention a reader must track.
6//!
7//! [`GlRegion`] is a physical-pixel rectangle whose origin is **bottom-left** (the GL
8//! framebuffer convention). It is *constructed, never flipped*: both egui call sites the glow
9//! adapter uses ([`PaintCallbackInfo::viewport_in_pixels`] and `clip_rect_in_pixels`) already
10//! expose a bottom-origin `from_bottom_px`, so [`from_bottom_px`](GlRegion::from_bottom_px)
11//! takes GL coordinates directly and no `framebuffer_height − y` arithmetic ever appears. The
12//! one bridge into the orientation-free [`Region`] the seam speaks is
13//! [`into_region`](GlRegion::into_region) — a documented *reinterpret*, no math — which is the
14//! single line a reviewer audits. **Any literal `height − y` in this module or its callers is a
15//! review red flag.**
16//!
17//! Why a newtype rather than a phantom `Region<Space>`: lower blast radius. [`GrabPass`] is
18//! glow-only, so only the glow-facing surfaces (the grab region, `GlPrepared`'s target rect,
19//! the composite uniforms) take `GlRegion`; the frozen wgpu backend keeps the top-left
20//! [`Region`] untouched, with no generic churn.
21//!
22//! [`Region`]: crate::Region
23//! [`GrabPass`]: crate::GrabPass
24//! [`PaintCallbackInfo::viewport_in_pixels`]: https://docs.rs/egui/latest/egui/struct.PaintCallbackInfo.html
25
26use crate::geometry::{Region, Scale};
27
28/// A physical-pixel rectangle in **GL bottom-left** coordinates (origin at the framebuffer's
29/// bottom-left corner, `y` increasing upward). The glow backend's read coordinates, composite
30/// `rect_origin`, SDF, and `backdrop_uv_remap` all operate in this one consistent system, so a
31/// `copyTexSubImage2D` from the bottom-left framebuffer lines up with `rect_uv.y` increasing
32/// upward and nothing is rendered upside-down (DESIGN §5).
33///
34/// Fields are private: the only way to obtain one is [`from_bottom_px`](Self::from_bottom_px)
35/// from already-bottom-left inputs, so the "never flipped" invariant holds by construction.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct GlRegion {
38    origin_bl: [u32; 2],
39    size: [u32; 2],
40    scale: Scale,
41}
42
43impl GlRegion {
44    // --- Constructors ---
45
46    /// Build from a **bottom-left** physical-pixel origin and size. Named `_bl`/`from_bottom_px`
47    /// because the input is required to already be GL-origin (egui's `from_bottom_px` field); the
48    /// constructor performs no flip, which is the whole point of the type.
49    pub fn from_bottom_px(origin_bl: [u32; 2], size: [u32; 2], scale: Scale) -> Self {
50        Self {
51            origin_bl,
52            size,
53            scale,
54        }
55    }
56
57    // --- Combinators (origin-agnostic: an axis-aligned box ∩ box, valid in either origin) ---
58
59    /// Intersect with another region in the **same** bottom-left space — the `clip_rect ∩
60    /// viewport` step the adapter runs before grabbing. Saturating; returns `None` when the two
61    /// are disjoint or touch only at an edge (zero area), the blur **no-op**. The result keeps
62    /// `self`'s [`Scale`]; both inputs are expected to share it (same frame, same DPI).
63    pub fn intersect(&self, other: &GlRegion) -> Option<GlRegion> {
64        let [ax, ay] = self.origin_bl;
65        let [aw, ah] = self.size;
66        let [bx, by] = other.origin_bl;
67        let [bw, bh] = other.size;
68
69        let x0 = ax.max(bx);
70        let y0 = ay.max(by);
71        let x1 = ax.saturating_add(aw).min(bx.saturating_add(bw));
72        let y1 = ay.saturating_add(ah).min(by.saturating_add(bh));
73
74        let w = x1.saturating_sub(x0);
75        let h = y1.saturating_sub(y0);
76
77        if w == 0 || h == 0 {
78            None
79        } else {
80            Some(GlRegion {
81                origin_bl: [x0, y0],
82                size: [w, h],
83                scale: self.scale,
84            })
85        }
86    }
87
88    /// Clip to a framebuffer of size `extent` (the `framebuffer ∩` step). Equivalent to
89    /// [`intersect`](Self::intersect) with the box `origin (0, 0)`, size `extent`; `None` when
90    /// the region lies fully outside the framebuffer (the no-op).
91    pub fn clip_to(&self, extent: [u32; 2]) -> Option<GlRegion> {
92        self.intersect(&GlRegion::from_bottom_px([0, 0], extent, self.scale))
93    }
94
95    // --- Getters (bottom-left physical pixels) ---
96
97    /// The bottom-left origin, physical pixels. Used by the grab to set `copyTexSubImage2D` /
98    /// `blitFramebuffer` read coordinates directly — GL's read origin is bottom-left, so no flip.
99    pub fn origin_bl(self) -> [u32; 2] {
100        self.origin_bl
101    }
102
103    /// Width and height, physical pixels. The grab texture is sized to this.
104    pub fn size(self) -> [u32; 2] {
105        self.size
106    }
107
108    // --- The bridge (DESIGN §5: the one audited reinterpret) ---
109
110    /// Reinterpret as the orientation-free [`Region`] the seam speaks. **Pure reinterpret — no
111    /// arithmetic, no flip:** the bottom-left numbers pass straight through, and every *compute*
112    /// consumer on the glow path (`grab_source`, the SDF, `backdrop_uv_remap`, the composite
113    /// uniforms) treats the resulting `Region` as bottom-left consistently, so no coordinate is
114    /// ever double-interpreted. This is the single line a review checks for a hidden `height − y`.
115    ///
116    /// The one consumer that must **not** receive an `into_region()`'d value is a human-facing
117    /// error: [`Region`]'s `Display` is documented top-left, so a bottom-left number printed
118    /// through it would mislead a debugger. That is why [`BlurError::GrabFailed`] carries a
119    /// `GlRegion` directly (which prints with an explicit bottom-left marker), not a reinterpreted
120    /// `Region`.
121    ///
122    /// [`BlurError::GrabFailed`]: crate::BlurError::GrabFailed
123    pub fn into_region(self) -> Region {
124        Region {
125            origin: self.origin_bl,
126            size: self.size,
127            scale: self.scale,
128        }
129    }
130}
131
132impl std::fmt::Display for GlRegion {
133    /// Prints with an explicit `bottom-left` marker so an error message can never be mistaken for
134    /// the top-left [`Region`] convention.
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        let [x, y] = self.origin_bl;
137        let [w, h] = self.size;
138        let scale = self.scale.factor();
139        write!(f, "origin-bl ({x}, {y}), size {w}×{h}, scale {scale}")
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn close(a: f32, b: f32) -> bool {
148        (a - b).abs() < 1e-4
149    }
150
151    fn gl(origin_bl: [u32; 2], size: [u32; 2], scale: f32) -> GlRegion {
152        GlRegion::from_bottom_px(origin_bl, size, Scale::new(scale))
153    }
154
155    #[test]
156    fn into_region_is_a_pure_reinterpret() {
157        // The numbers pass straight through — no height − y, no swap.
158        let r = gl([10, 20], [100, 60], 2.0).into_region();
159        assert_eq!(r.origin, [10, 20]);
160        assert_eq!(r.size, [100, 60]);
161        assert!(close(r.scale.factor(), 2.0));
162    }
163
164    #[test]
165    fn intersect_overlapping_yields_the_overlap_box() {
166        // [10,10]+[40,40] ∩ [30,20]+[40,40] = [30,20]+[20,30].
167        let overlap = gl([10, 10], [40, 40], 1.0)
168            .intersect(&gl([30, 20], [40, 40], 1.0))
169            .expect("the boxes overlap");
170        assert_eq!(overlap.origin_bl, [30, 20]);
171        assert_eq!(overlap.size, [20, 30]);
172    }
173
174    #[test]
175    fn intersect_preserves_self_scale() {
176        let overlap = gl([0, 0], [50, 50], 2.0)
177            .intersect(&gl([10, 10], [50, 50], 1.0))
178            .expect("the boxes overlap");
179        assert!(close(overlap.scale.factor(), 2.0));
180    }
181
182    #[test]
183    fn intersect_disjoint_is_none() {
184        assert_eq!(
185            gl([0, 0], [10, 10], 1.0).intersect(&gl([20, 0], [10, 10], 1.0)),
186            None
187        );
188    }
189
190    #[test]
191    fn intersect_edge_touching_is_none() {
192        // Share the x=10 edge only → zero width.
193        assert_eq!(
194            gl([0, 0], [10, 10], 1.0).intersect(&gl([10, 0], [10, 10], 1.0)),
195            None
196        );
197    }
198
199    #[test]
200    fn clip_to_leaves_an_in_bounds_region_unchanged() {
201        let r = gl([10, 10], [20, 20], 1.0);
202        assert_eq!(r.clip_to([100, 100]), Some(r));
203    }
204
205    #[test]
206    fn clip_to_clamps_a_partially_offscreen_region() {
207        // Runs 10px past the top/right of a 100×100 framebuffer.
208        let clipped = gl([90, 90], [20, 20], 2.0)
209            .clip_to([100, 100])
210            .expect("partial overlap is not a no-op");
211        assert_eq!(clipped.origin_bl, [90, 90]);
212        assert_eq!(clipped.size, [10, 10]);
213        assert!(close(clipped.scale.factor(), 2.0));
214    }
215
216    #[test]
217    fn clip_to_is_none_when_origin_past_extent() {
218        assert_eq!(gl([100, 0], [10, 10], 1.0).clip_to([100, 100]), None);
219    }
220
221    #[test]
222    fn clip_to_is_none_for_zero_area() {
223        assert_eq!(gl([0, 0], [0, 10], 1.0).clip_to([100, 100]), None);
224    }
225
226    #[test]
227    fn intersect_saturates_instead_of_overflowing() {
228        // origin + size would overflow u32; saturating arithmetic clips cleanly.
229        let r = gl([u32::MAX - 1, 0], [10, 10], 1.0);
230        assert_eq!(r.clip_to([100, 100]), None);
231    }
232
233    #[test]
234    fn display_marks_the_origin_bottom_left() {
235        // The "-bl" marker is what keeps an error message from being read as top-left Region.
236        assert_eq!(
237            gl([4, 8], [100, 60], 2.0).to_string(),
238            "origin-bl (4, 8), size 100×60, scale 2"
239        );
240    }
241}