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}